@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.
Files changed (367) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +7 -7
  3. package/dist/actors/Inventory.d.ts +7 -1
  4. package/dist/actors/Inventory.d.ts.map +1 -1
  5. package/dist/actors/Inventory.js.map +1 -1
  6. package/dist/actors/Shop.d.ts.map +1 -1
  7. package/dist/actors/Shop.js +4 -1
  8. package/dist/actors/Shop.js.map +1 -1
  9. package/dist/actors/craft.d.ts +35 -14
  10. package/dist/actors/craft.d.ts.map +1 -1
  11. package/dist/actors/craft.js +56 -24
  12. package/dist/actors/craft.js.map +1 -1
  13. package/dist/actors/index.d.ts +1 -1
  14. package/dist/actors/index.d.ts.map +1 -1
  15. package/dist/ai/Heuristics.d.ts +240 -0
  16. package/dist/ai/Heuristics.d.ts.map +1 -0
  17. package/dist/ai/Heuristics.js +298 -0
  18. package/dist/ai/Heuristics.js.map +1 -0
  19. package/dist/ai/index.d.ts +4 -0
  20. package/dist/ai/index.d.ts.map +1 -1
  21. package/dist/ai/index.js +2 -0
  22. package/dist/ai/index.js.map +1 -1
  23. package/dist/ai/lua.d.ts +21 -0
  24. package/dist/ai/lua.d.ts.map +1 -1
  25. package/dist/ai/lua.js +21 -0
  26. package/dist/ai/lua.js.map +1 -1
  27. package/dist/ai/score.d.ts +162 -0
  28. package/dist/ai/score.d.ts.map +1 -0
  29. package/dist/ai/score.js +147 -0
  30. package/dist/ai/score.js.map +1 -0
  31. package/dist/assets/loader.d.ts +18 -4
  32. package/dist/assets/loader.d.ts.map +1 -1
  33. package/dist/assets/loader.js +40 -11
  34. package/dist/assets/loader.js.map +1 -1
  35. package/dist/audio/Positional.d.ts +125 -0
  36. package/dist/audio/Positional.d.ts.map +1 -0
  37. package/dist/audio/Positional.js +135 -0
  38. package/dist/audio/Positional.js.map +1 -0
  39. package/dist/audio/Sound.d.ts +7 -1
  40. package/dist/audio/Sound.d.ts.map +1 -1
  41. package/dist/audio/Sound.js +8 -2
  42. package/dist/audio/Sound.js.map +1 -1
  43. package/dist/audio/index.d.ts +2 -0
  44. package/dist/audio/index.d.ts.map +1 -1
  45. package/dist/audio/index.js +1 -0
  46. package/dist/audio/index.js.map +1 -1
  47. package/dist/battle/AttackDialog.d.ts +75 -0
  48. package/dist/battle/AttackDialog.d.ts.map +1 -0
  49. package/dist/battle/AttackDialog.js +115 -0
  50. package/dist/battle/AttackDialog.js.map +1 -0
  51. package/dist/battle/AttackPreview.d.ts +70 -0
  52. package/dist/battle/AttackPreview.d.ts.map +1 -0
  53. package/dist/battle/AttackPreview.js +78 -0
  54. package/dist/battle/AttackPreview.js.map +1 -0
  55. package/dist/battle/BattleStats.d.ts +45 -0
  56. package/dist/battle/BattleStats.d.ts.map +1 -0
  57. package/dist/battle/BattleStats.js +63 -0
  58. package/dist/battle/BattleStats.js.map +1 -0
  59. package/dist/battle/UnitSelector.d.ts +72 -0
  60. package/dist/battle/UnitSelector.d.ts.map +1 -0
  61. package/dist/battle/UnitSelector.js +117 -0
  62. package/dist/battle/UnitSelector.js.map +1 -0
  63. package/dist/battle/Whiteboard.d.ts +50 -0
  64. package/dist/battle/Whiteboard.d.ts.map +1 -0
  65. package/dist/battle/Whiteboard.js +94 -0
  66. package/dist/battle/Whiteboard.js.map +1 -0
  67. package/dist/battle/index.d.ts +10 -0
  68. package/dist/battle/index.d.ts.map +1 -1
  69. package/dist/battle/index.js +5 -0
  70. package/dist/battle/index.js.map +1 -1
  71. package/dist/board/Classics.d.ts.map +1 -1
  72. package/dist/board/Classics.js +51 -39
  73. package/dist/board/Classics.js.map +1 -1
  74. package/dist/board/FogOfWar.d.ts +32 -2
  75. package/dist/board/FogOfWar.d.ts.map +1 -1
  76. package/dist/board/FogOfWar.js +70 -6
  77. package/dist/board/FogOfWar.js.map +1 -1
  78. package/dist/board/HexSkirmish.d.ts.map +1 -1
  79. package/dist/board/HexSkirmish.js +69 -20
  80. package/dist/board/HexSkirmish.js.map +1 -1
  81. package/dist/board/Tactics.d.ts +7 -0
  82. package/dist/board/Tactics.d.ts.map +1 -1
  83. package/dist/board/Tactics.js +2 -2
  84. package/dist/board/Tactics.js.map +1 -1
  85. package/dist/core/Achievements.d.ts +40 -7
  86. package/dist/core/Achievements.d.ts.map +1 -1
  87. package/dist/core/Achievements.js +53 -7
  88. package/dist/core/Achievements.js.map +1 -1
  89. package/dist/core/Hex.d.ts +26 -6
  90. package/dist/core/Hex.d.ts.map +1 -1
  91. package/dist/core/Hex.js +40 -13
  92. package/dist/core/Hex.js.map +1 -1
  93. package/dist/core/Input.d.ts +36 -0
  94. package/dist/core/Input.d.ts.map +1 -1
  95. package/dist/core/Input.js +63 -1
  96. package/dist/core/Input.js.map +1 -1
  97. package/dist/core/Random.d.ts +93 -0
  98. package/dist/core/Random.d.ts.map +1 -1
  99. package/dist/core/Random.js +177 -0
  100. package/dist/core/Random.js.map +1 -1
  101. package/dist/core/SyncGuard.d.ts +57 -0
  102. package/dist/core/SyncGuard.d.ts.map +1 -0
  103. package/dist/core/SyncGuard.js +116 -0
  104. package/dist/core/SyncGuard.js.map +1 -0
  105. package/dist/core/index.d.ts +3 -1
  106. package/dist/core/index.d.ts.map +1 -1
  107. package/dist/core/index.js +2 -1
  108. package/dist/core/index.js.map +1 -1
  109. package/dist/i18n/Po.d.ts +51 -0
  110. package/dist/i18n/Po.d.ts.map +1 -0
  111. package/dist/i18n/Po.js +176 -0
  112. package/dist/i18n/Po.js.map +1 -0
  113. package/dist/i18n/index.d.ts +2 -0
  114. package/dist/i18n/index.d.ts.map +1 -1
  115. package/dist/i18n/index.js +1 -0
  116. package/dist/i18n/index.js.map +1 -1
  117. package/dist/mw_games.global.js +104 -98
  118. package/dist/mwl/MapFile.d.ts +41 -0
  119. package/dist/mwl/MapFile.d.ts.map +1 -0
  120. package/dist/mwl/MapFile.js +43 -0
  121. package/dist/mwl/MapFile.js.map +1 -0
  122. package/dist/mwl/actors.d.ts +46 -2
  123. package/dist/mwl/actors.d.ts.map +1 -1
  124. package/dist/mwl/actors.js +46 -2
  125. package/dist/mwl/actors.js.map +1 -1
  126. package/dist/mwl/campaign.d.ts +51 -0
  127. package/dist/mwl/campaign.d.ts.map +1 -0
  128. package/dist/mwl/campaign.js +58 -0
  129. package/dist/mwl/campaign.js.map +1 -0
  130. package/dist/mwl/carryover.d.ts +86 -0
  131. package/dist/mwl/carryover.d.ts.map +1 -0
  132. package/dist/mwl/carryover.js +70 -0
  133. package/dist/mwl/carryover.js.map +1 -0
  134. package/dist/mwl/catalog.d.ts +10 -1
  135. package/dist/mwl/catalog.d.ts.map +1 -1
  136. package/dist/mwl/catalog.js +10 -1
  137. package/dist/mwl/catalog.js.map +1 -1
  138. package/dist/mwl/compiler.d.ts +70 -3
  139. package/dist/mwl/compiler.d.ts.map +1 -1
  140. package/dist/mwl/compiler.js +70 -3
  141. package/dist/mwl/compiler.js.map +1 -1
  142. package/dist/mwl/conditions.d.ts +10 -1
  143. package/dist/mwl/conditions.d.ts.map +1 -1
  144. package/dist/mwl/conditions.js +10 -1
  145. package/dist/mwl/conditions.js.map +1 -1
  146. package/dist/mwl/content.d.ts +21 -0
  147. package/dist/mwl/content.d.ts.map +1 -1
  148. package/dist/mwl/content.js +19 -0
  149. package/dist/mwl/content.js.map +1 -1
  150. package/dist/mwl/expression.d.ts +21 -0
  151. package/dist/mwl/expression.d.ts.map +1 -1
  152. package/dist/mwl/expression.js +21 -0
  153. package/dist/mwl/expression.js.map +1 -1
  154. package/dist/mwl/fengari.d.ts +12 -1
  155. package/dist/mwl/fengari.d.ts.map +1 -1
  156. package/dist/mwl/fengari.js +12 -1
  157. package/dist/mwl/fengari.js.map +1 -1
  158. package/dist/mwl/grammar.d.ts +45 -1
  159. package/dist/mwl/grammar.d.ts.map +1 -1
  160. package/dist/mwl/grammar.js +45 -1
  161. package/dist/mwl/grammar.js.map +1 -1
  162. package/dist/mwl/hooks.d.ts +51 -6
  163. package/dist/mwl/hooks.d.ts.map +1 -1
  164. package/dist/mwl/hooks.js +45 -1
  165. package/dist/mwl/hooks.js.map +1 -1
  166. package/dist/mwl/index.d.ts +9 -3
  167. package/dist/mwl/index.d.ts.map +1 -1
  168. package/dist/mwl/index.js +5 -2
  169. package/dist/mwl/index.js.map +1 -1
  170. package/dist/mwl/persistence.d.ts +32 -0
  171. package/dist/mwl/persistence.d.ts.map +1 -1
  172. package/dist/mwl/persistence.js +32 -0
  173. package/dist/mwl/persistence.js.map +1 -1
  174. package/dist/mwl/readers.d.ts +24 -2
  175. package/dist/mwl/readers.d.ts.map +1 -1
  176. package/dist/mwl/readers.js +27 -4
  177. package/dist/mwl/readers.js.map +1 -1
  178. package/dist/mwl/report.d.ts +21 -2
  179. package/dist/mwl/report.d.ts.map +1 -1
  180. package/dist/mwl/report.js +21 -2
  181. package/dist/mwl/report.js.map +1 -1
  182. package/dist/mwl/runtime.d.ts +212 -7
  183. package/dist/mwl/runtime.d.ts.map +1 -1
  184. package/dist/mwl/runtime.js +682 -90
  185. package/dist/mwl/runtime.js.map +1 -1
  186. package/dist/mwl/schema.d.ts +57 -1
  187. package/dist/mwl/schema.d.ts.map +1 -1
  188. package/dist/mwl/schema.js +181 -21
  189. package/dist/mwl/schema.js.map +1 -1
  190. package/dist/mwl/scripts.d.ts +12 -1
  191. package/dist/mwl/scripts.d.ts.map +1 -1
  192. package/dist/mwl/scripts.js +12 -1
  193. package/dist/mwl/scripts.js.map +1 -1
  194. package/dist/mwl/utils.d.ts +2 -0
  195. package/dist/mwl/utils.d.ts.map +1 -1
  196. package/dist/mwl/utils.js +4 -1
  197. package/dist/mwl/utils.js.map +1 -1
  198. package/dist/roguelike/MultiTurnBeam.d.ts +50 -12
  199. package/dist/roguelike/MultiTurnBeam.d.ts.map +1 -1
  200. package/dist/roguelike/MultiTurnBeam.js +100 -53
  201. package/dist/roguelike/MultiTurnBeam.js.map +1 -1
  202. package/dist/roguelike/Targeting.d.ts +94 -0
  203. package/dist/roguelike/Targeting.d.ts.map +1 -1
  204. package/dist/roguelike/Targeting.js +123 -0
  205. package/dist/roguelike/Targeting.js.map +1 -1
  206. package/dist/roguelike/index.d.ts +3 -1
  207. package/dist/roguelike/index.d.ts.map +1 -1
  208. package/dist/roguelike/index.js +1 -0
  209. package/dist/roguelike/index.js.map +1 -1
  210. package/dist/rpg/GameState.d.ts +2 -2
  211. package/dist/rpg/GameState.js +2 -2
  212. package/dist/simulation/CampaignSave.d.ts +68 -0
  213. package/dist/simulation/CampaignSave.d.ts.map +1 -0
  214. package/dist/simulation/CampaignSave.js +54 -0
  215. package/dist/simulation/CampaignSave.js.map +1 -0
  216. package/dist/simulation/EventPresentation.d.ts +111 -0
  217. package/dist/simulation/EventPresentation.d.ts.map +1 -0
  218. package/dist/simulation/EventPresentation.js +120 -0
  219. package/dist/simulation/EventPresentation.js.map +1 -0
  220. package/dist/simulation/index.d.ts +4 -0
  221. package/dist/simulation/index.d.ts.map +1 -1
  222. package/dist/simulation/index.js +2 -0
  223. package/dist/simulation/index.js.map +1 -1
  224. package/dist/two-d/pixi-interop.d.ts +18 -6
  225. package/dist/two-d/pixi-interop.d.ts.map +1 -1
  226. package/dist/two-d/pixi-interop.js +18 -6
  227. package/dist/two-d/pixi-interop.js.map +1 -1
  228. package/dist/two-d/render/AnimatedSprite.d.ts +74 -7
  229. package/dist/two-d/render/AnimatedSprite.d.ts.map +1 -1
  230. package/dist/two-d/render/AnimatedSprite.js +88 -26
  231. package/dist/two-d/render/AnimatedSprite.js.map +1 -1
  232. package/dist/two-d/render/Camera.d.ts +64 -4
  233. package/dist/two-d/render/Camera.d.ts.map +1 -1
  234. package/dist/two-d/render/Camera.js +132 -11
  235. package/dist/two-d/render/Camera.js.map +1 -1
  236. package/dist/two-d/render/ColorTransformBatcher.d.ts +7 -1
  237. package/dist/two-d/render/ColorTransformBatcher.d.ts.map +1 -1
  238. package/dist/two-d/render/ColorTransformBatcher.js +10 -1
  239. package/dist/two-d/render/ColorTransformBatcher.js.map +1 -1
  240. package/dist/two-d/render/Halo.d.ts +58 -0
  241. package/dist/two-d/render/Halo.d.ts.map +1 -0
  242. package/dist/two-d/render/Halo.js +48 -0
  243. package/dist/two-d/render/Halo.js.map +1 -0
  244. package/dist/two-d/render/ImageModifiers.d.ts +174 -0
  245. package/dist/two-d/render/ImageModifiers.d.ts.map +1 -1
  246. package/dist/two-d/render/ImageModifiers.js +454 -1
  247. package/dist/two-d/render/ImageModifiers.js.map +1 -1
  248. package/dist/two-d/render/LightningArc.d.ts +57 -0
  249. package/dist/two-d/render/LightningArc.d.ts.map +1 -0
  250. package/dist/two-d/render/LightningArc.js +91 -0
  251. package/dist/two-d/render/LightningArc.js.map +1 -0
  252. package/dist/two-d/render/PaletteRemap.d.ts +135 -0
  253. package/dist/two-d/render/PaletteRemap.d.ts.map +1 -0
  254. package/dist/two-d/render/PaletteRemap.js +181 -0
  255. package/dist/two-d/render/PaletteRemap.js.map +1 -0
  256. package/dist/two-d/render/Particles.d.ts +26 -0
  257. package/dist/two-d/render/Particles.d.ts.map +1 -1
  258. package/dist/two-d/render/Particles.js +23 -2
  259. package/dist/two-d/render/Particles.js.map +1 -1
  260. package/dist/two-d/render/SpriteAttachment.d.ts +55 -0
  261. package/dist/two-d/render/SpriteAttachment.d.ts.map +1 -0
  262. package/dist/two-d/render/SpriteAttachment.js +60 -0
  263. package/dist/two-d/render/SpriteAttachment.js.map +1 -0
  264. package/dist/two-d/render/TerrainGraphics.d.ts +142 -0
  265. package/dist/two-d/render/TerrainGraphics.d.ts.map +1 -0
  266. package/dist/two-d/render/TerrainGraphics.js +161 -0
  267. package/dist/two-d/render/TerrainGraphics.js.map +1 -0
  268. package/dist/two-d/render/TerrainGraphicsLayer.d.ts +55 -0
  269. package/dist/two-d/render/TerrainGraphicsLayer.d.ts.map +1 -0
  270. package/dist/two-d/render/TerrainGraphicsLayer.js +54 -0
  271. package/dist/two-d/render/TerrainGraphicsLayer.js.map +1 -0
  272. package/dist/two-d/render/TintedSprite.js +2 -2
  273. package/dist/two-d/render/TintedSprite.js.map +1 -1
  274. package/dist/two-d/render/index.d.ts +15 -3
  275. package/dist/two-d/render/index.d.ts.map +1 -1
  276. package/dist/two-d/render/index.js +7 -1
  277. package/dist/two-d/render/index.js.map +1 -1
  278. package/dist/two-d/stage/StoryScreen.d.ts +110 -0
  279. package/dist/two-d/stage/StoryScreen.d.ts.map +1 -0
  280. package/dist/two-d/stage/StoryScreen.js +178 -0
  281. package/dist/two-d/stage/StoryScreen.js.map +1 -0
  282. package/dist/two-d/stage/index.d.ts +2 -0
  283. package/dist/two-d/stage/index.d.ts.map +1 -1
  284. package/dist/two-d/stage/index.js +1 -0
  285. package/dist/two-d/stage/index.js.map +1 -1
  286. package/dist/two-d/ui/Checkbox.d.ts +44 -0
  287. package/dist/two-d/ui/Checkbox.d.ts.map +1 -0
  288. package/dist/two-d/ui/Checkbox.js +89 -0
  289. package/dist/two-d/ui/Checkbox.js.map +1 -0
  290. package/dist/two-d/ui/DataTable.d.ts +79 -0
  291. package/dist/two-d/ui/DataTable.d.ts.map +1 -0
  292. package/dist/two-d/ui/DataTable.js +164 -0
  293. package/dist/two-d/ui/DataTable.js.map +1 -0
  294. package/dist/two-d/ui/Dropdown.d.ts +72 -0
  295. package/dist/two-d/ui/Dropdown.d.ts.map +1 -0
  296. package/dist/two-d/ui/Dropdown.js +126 -0
  297. package/dist/two-d/ui/Dropdown.js.map +1 -0
  298. package/dist/two-d/ui/FloatingText.d.ts +43 -4
  299. package/dist/two-d/ui/FloatingText.d.ts.map +1 -1
  300. package/dist/two-d/ui/FloatingText.js +52 -5
  301. package/dist/two-d/ui/FloatingText.js.map +1 -1
  302. package/dist/two-d/ui/FloatingTextStack.d.ts +86 -13
  303. package/dist/two-d/ui/FloatingTextStack.d.ts.map +1 -1
  304. package/dist/two-d/ui/FloatingTextStack.js +89 -18
  305. package/dist/two-d/ui/FloatingTextStack.js.map +1 -1
  306. package/dist/two-d/ui/Layout.d.ts +105 -0
  307. package/dist/two-d/ui/Layout.d.ts.map +1 -0
  308. package/dist/two-d/ui/Layout.js +174 -0
  309. package/dist/two-d/ui/Layout.js.map +1 -0
  310. package/dist/two-d/ui/MarkupText.d.ts +53 -0
  311. package/dist/two-d/ui/MarkupText.d.ts.map +1 -0
  312. package/dist/two-d/ui/MarkupText.js +122 -0
  313. package/dist/two-d/ui/MarkupText.js.map +1 -0
  314. package/dist/two-d/ui/ScrollBox.d.ts +67 -0
  315. package/dist/two-d/ui/ScrollBox.d.ts.map +1 -0
  316. package/dist/two-d/ui/ScrollBox.js +127 -0
  317. package/dist/two-d/ui/ScrollBox.js.map +1 -0
  318. package/dist/two-d/ui/Skins.d.ts +59 -0
  319. package/dist/two-d/ui/Skins.d.ts.map +1 -0
  320. package/dist/two-d/ui/Skins.js +77 -0
  321. package/dist/two-d/ui/Skins.js.map +1 -0
  322. package/dist/two-d/ui/Slider.d.ts +91 -0
  323. package/dist/two-d/ui/Slider.d.ts.map +1 -0
  324. package/dist/two-d/ui/Slider.js +170 -0
  325. package/dist/two-d/ui/Slider.js.map +1 -0
  326. package/dist/two-d/ui/Spinner.d.ts +67 -0
  327. package/dist/two-d/ui/Spinner.d.ts.map +1 -0
  328. package/dist/two-d/ui/Spinner.js +137 -0
  329. package/dist/two-d/ui/Spinner.js.map +1 -0
  330. package/dist/two-d/ui/TabbedList.d.ts +118 -0
  331. package/dist/two-d/ui/TabbedList.d.ts.map +1 -0
  332. package/dist/two-d/ui/TabbedList.js +221 -0
  333. package/dist/two-d/ui/TabbedList.js.map +1 -0
  334. package/dist/two-d/ui/TextModel.d.ts +65 -0
  335. package/dist/two-d/ui/TextModel.d.ts.map +1 -0
  336. package/dist/two-d/ui/TextModel.js +126 -0
  337. package/dist/two-d/ui/TextModel.js.map +1 -0
  338. package/dist/two-d/ui/TreeView.d.ts +68 -0
  339. package/dist/two-d/ui/TreeView.d.ts.map +1 -0
  340. package/dist/two-d/ui/TreeView.js +133 -0
  341. package/dist/two-d/ui/TreeView.js.map +1 -0
  342. package/dist/two-d/ui/Window.d.ts +13 -0
  343. package/dist/two-d/ui/Window.d.ts.map +1 -1
  344. package/dist/two-d/ui/Window.js +23 -0
  345. package/dist/two-d/ui/Window.js.map +1 -1
  346. package/dist/two-d/ui/WindowStack.d.ts.map +1 -1
  347. package/dist/two-d/ui/WindowStack.js +2 -0
  348. package/dist/two-d/ui/WindowStack.js.map +1 -1
  349. package/dist/two-d/ui/index.d.ts +29 -3
  350. package/dist/two-d/ui/index.d.ts.map +1 -1
  351. package/dist/two-d/ui/index.js +15 -2
  352. package/dist/two-d/ui/index.js.map +1 -1
  353. package/dist/two-d/ui/markup.d.ts +181 -0
  354. package/dist/two-d/ui/markup.d.ts.map +1 -0
  355. package/dist/two-d/ui/markup.js +370 -0
  356. package/dist/two-d/ui/markup.js.map +1 -0
  357. package/dist/version.d.ts +1 -1
  358. package/dist/version.js +1 -1
  359. package/dist/world/SideTurns.d.ts +115 -0
  360. package/dist/world/SideTurns.d.ts.map +1 -0
  361. package/dist/world/SideTurns.js +138 -0
  362. package/dist/world/SideTurns.js.map +1 -0
  363. package/dist/world/index.d.ts +2 -0
  364. package/dist/world/index.d.ts.map +1 -1
  365. package/dist/world/index.js +1 -0
  366. package/dist/world/index.js.map +1 -1
  367. package/package.json +2 -1
@@ -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
- const keep = /^([0-9]+)\s+(.+)$/.exec(token);
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[Number(keep[1])] ??= []).push({ x, y });
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 && integer(event, 'side', Number.NaN) !== unit.side)
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
- for (const command of event.children.filter((child) => child.tag !== 'condition' && child.tag !== 'filter' && child.tag !== 'filter_condition')) {
154
- if (command.tag === 'say') {
155
- this.showSay(command);
187
+ this.runBlock(this.commandChildren(event));
188
+ }
189
+ /**
190
+ * Runs a sequence of commands, with `say` and `dialogue` taking the message path rather
191
+ * than `executeNode`. Shared by an event's top level, an `[if]`/`[else]` body and a
192
+ * dialogue branch, so all three treat a nested pair the same way.
193
+ */
194
+ runBlock(children) {
195
+ for (let index = 0; index < children.length; index++) {
196
+ const child = children[index];
197
+ if (this.isConsumedElse(children, index))
198
+ continue;
199
+ if (child.tag === 'say') {
200
+ this.showSay(child);
156
201
  continue;
157
202
  }
158
- if (command.tag === 'dialogue') {
159
- this.showDialogue(command);
203
+ if (child.tag === 'dialogue') {
204
+ this.showDialogue(child);
160
205
  continue;
161
206
  }
162
- this.executeNode(command);
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
- ...(Number.isFinite(side) ? { side } : {}),
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.world.variables[child.attributes.variable], child.attributes.equals))
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
- for (const command of choice.branch ?? [])
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 = optionalInteger(start, '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 = integer(unit, 'side', 0);
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 sideNumber = optionalInteger(side, 'id');
393
- if (sideNumber === undefined)
532
+ const sideId = side.attributes.id;
533
+ if (!sideId)
394
534
  continue;
395
- const starts = this.world.map?.starts[sideNumber];
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} ${sideNumber} (${start.x},${start.y})`;
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: sideNumber,
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 === undefined ? undefined : integer(node, 'side', 0), integer(node, 'x', 0), integer(node, 'y', 0), node.attributes.id ?? node.attributes.target, node.attributes.hp === undefined ? undefined : integer(node, 'hp', 1));
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
- this.killUnit(node.attributes.unit ?? required(node, 'target'));
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
- for (const child of node.children)
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
- for (const child of node.children)
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
- for (const child of (selected ?? fallback)?.children ?? [])
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 'if':
499
- if (!this.nodeConditionMatches(node))
500
- break;
501
- for (const child of node.children)
502
- this.executeNode(child);
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 (const child of node.children)
506
- this.executeNode(child);
755
+ //reachable at top level for a free-standing [else]; a paired one is run by its [if]
756
+ this.runBlock(this.commandChildren(node));
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
- ...(Number.isFinite(side) ? { side } : {}),
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 variables = this.world.variables;
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 = variables[name];
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.world.variables[reference[1]];
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
- let value = this.world.variables;
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
- const parts = path.split('.');
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 ?? 0}@${x},${y}`;
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) => variableMatches(this.world.variables[condition.attributes.variable], condition.attributes, primitiveVariables(this.world.variables)));
995
+ .every((condition) => {
996
+ if (condition.attributes.condition === 'hook') {
997
+ const hookName = condition.attributes.hook;
998
+ const predicateName = hookName?.startsWith('predicate:')
999
+ ? hookName.slice('predicate:'.length)
1000
+ : undefined;
1001
+ const predicate = predicateName ? this.hooks?.predicate?.[predicateName] : undefined;
1002
+ if (!predicate)
1003
+ throw new Error(`MWL predicate ${predicateName ?? '<missing>'} is not implemented`);
1004
+ const { condition: _condition, hook: _hook, ...context } = condition.attributes;
1005
+ return Boolean(predicate(this.worldView(), context));
1006
+ }
1007
+ return variableMatches(this.variableAt(condition.attributes.variable), condition.attributes, resolveVariable(this.world.variables));
1008
+ });
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.world.variables[name], child.attributes, primitiveVariables(this.world.variables));
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
- conditionMet(node) {
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 = optionalInteger(node, 'side');
1056
+ const side = node.attributes.side ?? defaultSide;
727
1057
  switch (condition) {
728
1058
  case 'units_dead': {
729
- const target = optionalInteger(node, 'side_filter') ?? side;
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
- return (gold !== undefined &&
741
- node.attributes.side !== undefined &&
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 = optionalInteger(node, 'side_filter') ?? side;
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
- if (this.nodes('victory').some((node) => this.conditionMet(node))) {
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(String(side), delta),
1168
+ gold: (side, delta) => this.addGold(side, delta),
795
1169
  setVariable: (name, value) => {
796
- this.world.variables[name] = value;
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: (_side) => {
1174
+ win: (side) => {
1175
+ this.markSideResult(side, 'won');
801
1176
  this.world.status = 'won';
802
1177
  },
803
- lose: (_side) => {
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[command.target] = command.value;
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
- requireUnit(world, command.target).alive = false;
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, variables = {}) {
880
- const resolve = (expected) => expected.startsWith('$') ? variables[expected.slice(1)] : expected;
1381
+ function variableMatches(value, attributes, resolve) {
1382
+ const expected = (text) => {
1383
+ if (!text.startsWith('$'))
1384
+ return text;
1385
+ const resolved = resolve(text.slice(1));
1386
+ return typeof resolved === 'string' || typeof resolved === 'number' || typeof resolved === 'boolean'
1387
+ ? resolved
1388
+ : undefined;
1389
+ };
881
1390
  if (attributes.equals !== undefined)
882
- return sameValue(value, resolve(attributes.equals));
1391
+ return sameValue(value, expected(attributes.equals));
883
1392
  if (attributes.not_equals !== undefined)
884
- return !sameValue(value, resolve(attributes.not_equals));
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), world key (`unit`, or `id` on
916
- * `have_unit`), and coordinates all have to match when present.
1424
+ * comma-list `not_type` exclusion), the unit's own name and role, whether it
1425
+ * can recruit, whether it is its side's leader, world key (`unit`, or `id`
1426
+ * on `have_unit`), and coordinates all have to match when present.
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 === Number(attributes.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();