@datamoc/mw_games 0.7.4 → 0.7.7

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 (288) hide show
  1. package/CHANGELOG.md +287 -2
  2. package/README.md +15 -2
  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/craft.d.ts +35 -14
  7. package/dist/actors/craft.d.ts.map +1 -1
  8. package/dist/actors/craft.js +56 -24
  9. package/dist/actors/craft.js.map +1 -1
  10. package/dist/actors/index.d.ts +1 -1
  11. package/dist/actors/index.d.ts.map +1 -1
  12. package/dist/ai/Heuristics.d.ts +232 -0
  13. package/dist/ai/Heuristics.d.ts.map +1 -0
  14. package/dist/ai/Heuristics.js +298 -0
  15. package/dist/ai/Heuristics.js.map +1 -0
  16. package/dist/ai/index.d.ts +4 -0
  17. package/dist/ai/index.d.ts.map +1 -1
  18. package/dist/ai/index.js +2 -0
  19. package/dist/ai/index.js.map +1 -1
  20. package/dist/ai/score.d.ts +126 -0
  21. package/dist/ai/score.d.ts.map +1 -0
  22. package/dist/ai/score.js +121 -0
  23. package/dist/ai/score.js.map +1 -0
  24. package/dist/assets/loader.d.ts +18 -4
  25. package/dist/assets/loader.d.ts.map +1 -1
  26. package/dist/assets/loader.js +40 -11
  27. package/dist/assets/loader.js.map +1 -1
  28. package/dist/audio/Positional.d.ts +125 -0
  29. package/dist/audio/Positional.d.ts.map +1 -0
  30. package/dist/audio/Positional.js +135 -0
  31. package/dist/audio/Positional.js.map +1 -0
  32. package/dist/audio/Sound.d.ts +7 -1
  33. package/dist/audio/Sound.d.ts.map +1 -1
  34. package/dist/audio/Sound.js +8 -2
  35. package/dist/audio/Sound.js.map +1 -1
  36. package/dist/audio/index.d.ts +2 -0
  37. package/dist/audio/index.d.ts.map +1 -1
  38. package/dist/audio/index.js +1 -0
  39. package/dist/audio/index.js.map +1 -1
  40. package/dist/battle/AttackDialog.d.ts +75 -0
  41. package/dist/battle/AttackDialog.d.ts.map +1 -0
  42. package/dist/battle/AttackDialog.js +115 -0
  43. package/dist/battle/AttackDialog.js.map +1 -0
  44. package/dist/battle/AttackPreview.d.ts +70 -0
  45. package/dist/battle/AttackPreview.d.ts.map +1 -0
  46. package/dist/battle/AttackPreview.js +78 -0
  47. package/dist/battle/AttackPreview.js.map +1 -0
  48. package/dist/battle/BattleStats.d.ts +45 -0
  49. package/dist/battle/BattleStats.d.ts.map +1 -0
  50. package/dist/battle/BattleStats.js +63 -0
  51. package/dist/battle/BattleStats.js.map +1 -0
  52. package/dist/battle/UnitSelector.d.ts +72 -0
  53. package/dist/battle/UnitSelector.d.ts.map +1 -0
  54. package/dist/battle/UnitSelector.js +117 -0
  55. package/dist/battle/UnitSelector.js.map +1 -0
  56. package/dist/battle/Whiteboard.d.ts +50 -0
  57. package/dist/battle/Whiteboard.d.ts.map +1 -0
  58. package/dist/battle/Whiteboard.js +94 -0
  59. package/dist/battle/Whiteboard.js.map +1 -0
  60. package/dist/battle/index.d.ts +10 -0
  61. package/dist/battle/index.d.ts.map +1 -1
  62. package/dist/battle/index.js +5 -0
  63. package/dist/battle/index.js.map +1 -1
  64. package/dist/board/FogOfWar.d.ts +32 -2
  65. package/dist/board/FogOfWar.d.ts.map +1 -1
  66. package/dist/board/FogOfWar.js +70 -6
  67. package/dist/board/FogOfWar.js.map +1 -1
  68. package/dist/core/Achievements.d.ts +40 -7
  69. package/dist/core/Achievements.d.ts.map +1 -1
  70. package/dist/core/Achievements.js +51 -7
  71. package/dist/core/Achievements.js.map +1 -1
  72. package/dist/core/Blob.d.ts +13 -2
  73. package/dist/core/Blob.d.ts.map +1 -1
  74. package/dist/core/Blob.js +16 -2
  75. package/dist/core/Blob.js.map +1 -1
  76. package/dist/core/Hex.d.ts +26 -6
  77. package/dist/core/Hex.d.ts.map +1 -1
  78. package/dist/core/Hex.js +40 -13
  79. package/dist/core/Hex.js.map +1 -1
  80. package/dist/core/Input.d.ts +36 -0
  81. package/dist/core/Input.d.ts.map +1 -1
  82. package/dist/core/Input.js +63 -1
  83. package/dist/core/Input.js.map +1 -1
  84. package/dist/core/Random.d.ts +93 -0
  85. package/dist/core/Random.d.ts.map +1 -1
  86. package/dist/core/Random.js +177 -0
  87. package/dist/core/Random.js.map +1 -1
  88. package/dist/core/SyncGuard.d.ts +57 -0
  89. package/dist/core/SyncGuard.d.ts.map +1 -0
  90. package/dist/core/SyncGuard.js +116 -0
  91. package/dist/core/SyncGuard.js.map +1 -0
  92. package/dist/core/index.d.ts +3 -1
  93. package/dist/core/index.d.ts.map +1 -1
  94. package/dist/core/index.js +2 -1
  95. package/dist/core/index.js.map +1 -1
  96. package/dist/i18n/Po.d.ts +51 -0
  97. package/dist/i18n/Po.d.ts.map +1 -0
  98. package/dist/i18n/Po.js +176 -0
  99. package/dist/i18n/Po.js.map +1 -0
  100. package/dist/i18n/index.d.ts +2 -0
  101. package/dist/i18n/index.d.ts.map +1 -1
  102. package/dist/i18n/index.js +1 -0
  103. package/dist/i18n/index.js.map +1 -1
  104. package/dist/mw_games.global.js +104 -98
  105. package/dist/mwl/MapFile.d.ts +41 -0
  106. package/dist/mwl/MapFile.d.ts.map +1 -0
  107. package/dist/mwl/MapFile.js +43 -0
  108. package/dist/mwl/MapFile.js.map +1 -0
  109. package/dist/mwl/campaign.d.ts +51 -0
  110. package/dist/mwl/campaign.d.ts.map +1 -0
  111. package/dist/mwl/campaign.js +58 -0
  112. package/dist/mwl/campaign.js.map +1 -0
  113. package/dist/mwl/carryover.d.ts +79 -0
  114. package/dist/mwl/carryover.d.ts.map +1 -0
  115. package/dist/mwl/carryover.js +63 -0
  116. package/dist/mwl/carryover.js.map +1 -0
  117. package/dist/mwl/content.d.ts +10 -0
  118. package/dist/mwl/content.d.ts.map +1 -1
  119. package/dist/mwl/content.js +8 -0
  120. package/dist/mwl/content.js.map +1 -1
  121. package/dist/mwl/hooks.d.ts +6 -5
  122. package/dist/mwl/hooks.d.ts.map +1 -1
  123. package/dist/mwl/hooks.js.map +1 -1
  124. package/dist/mwl/index.d.ts +8 -2
  125. package/dist/mwl/index.d.ts.map +1 -1
  126. package/dist/mwl/index.js +4 -1
  127. package/dist/mwl/index.js.map +1 -1
  128. package/dist/mwl/runtime.d.ts +140 -7
  129. package/dist/mwl/runtime.d.ts.map +1 -1
  130. package/dist/mwl/runtime.js +427 -33
  131. package/dist/mwl/runtime.js.map +1 -1
  132. package/dist/mwl/schema.d.ts.map +1 -1
  133. package/dist/mwl/schema.js +97 -18
  134. package/dist/mwl/schema.js.map +1 -1
  135. package/dist/roguelike/MultiTurnBeam.d.ts +50 -12
  136. package/dist/roguelike/MultiTurnBeam.d.ts.map +1 -1
  137. package/dist/roguelike/MultiTurnBeam.js +100 -53
  138. package/dist/roguelike/MultiTurnBeam.js.map +1 -1
  139. package/dist/roguelike/Targeting.d.ts +94 -0
  140. package/dist/roguelike/Targeting.d.ts.map +1 -1
  141. package/dist/roguelike/Targeting.js +123 -0
  142. package/dist/roguelike/Targeting.js.map +1 -1
  143. package/dist/roguelike/index.d.ts +3 -1
  144. package/dist/roguelike/index.d.ts.map +1 -1
  145. package/dist/roguelike/index.js +1 -0
  146. package/dist/roguelike/index.js.map +1 -1
  147. package/dist/simulation/CampaignSave.d.ts +68 -0
  148. package/dist/simulation/CampaignSave.d.ts.map +1 -0
  149. package/dist/simulation/CampaignSave.js +54 -0
  150. package/dist/simulation/CampaignSave.js.map +1 -0
  151. package/dist/simulation/EventPresentation.d.ts +111 -0
  152. package/dist/simulation/EventPresentation.d.ts.map +1 -0
  153. package/dist/simulation/EventPresentation.js +120 -0
  154. package/dist/simulation/EventPresentation.js.map +1 -0
  155. package/dist/simulation/index.d.ts +4 -0
  156. package/dist/simulation/index.d.ts.map +1 -1
  157. package/dist/simulation/index.js +2 -0
  158. package/dist/simulation/index.js.map +1 -1
  159. package/dist/two-d/pixi-interop.d.ts +18 -6
  160. package/dist/two-d/pixi-interop.d.ts.map +1 -1
  161. package/dist/two-d/pixi-interop.js +18 -6
  162. package/dist/two-d/pixi-interop.js.map +1 -1
  163. package/dist/two-d/render/AnimatedSprite.d.ts +74 -7
  164. package/dist/two-d/render/AnimatedSprite.d.ts.map +1 -1
  165. package/dist/two-d/render/AnimatedSprite.js +88 -26
  166. package/dist/two-d/render/AnimatedSprite.js.map +1 -1
  167. package/dist/two-d/render/Camera.d.ts +64 -4
  168. package/dist/two-d/render/Camera.d.ts.map +1 -1
  169. package/dist/two-d/render/Camera.js +132 -11
  170. package/dist/two-d/render/Camera.js.map +1 -1
  171. package/dist/two-d/render/Halo.d.ts +58 -0
  172. package/dist/two-d/render/Halo.d.ts.map +1 -0
  173. package/dist/two-d/render/Halo.js +48 -0
  174. package/dist/two-d/render/Halo.js.map +1 -0
  175. package/dist/two-d/render/ImageModifiers.d.ts +174 -0
  176. package/dist/two-d/render/ImageModifiers.d.ts.map +1 -1
  177. package/dist/two-d/render/ImageModifiers.js +438 -1
  178. package/dist/two-d/render/ImageModifiers.js.map +1 -1
  179. package/dist/two-d/render/LightningArc.d.ts +57 -0
  180. package/dist/two-d/render/LightningArc.d.ts.map +1 -0
  181. package/dist/two-d/render/LightningArc.js +91 -0
  182. package/dist/two-d/render/LightningArc.js.map +1 -0
  183. package/dist/two-d/render/PaletteRemap.d.ts +135 -0
  184. package/dist/two-d/render/PaletteRemap.d.ts.map +1 -0
  185. package/dist/two-d/render/PaletteRemap.js +181 -0
  186. package/dist/two-d/render/PaletteRemap.js.map +1 -0
  187. package/dist/two-d/render/Particles.d.ts +26 -0
  188. package/dist/two-d/render/Particles.d.ts.map +1 -1
  189. package/dist/two-d/render/Particles.js +23 -2
  190. package/dist/two-d/render/Particles.js.map +1 -1
  191. package/dist/two-d/render/SpriteAttachment.d.ts +55 -0
  192. package/dist/two-d/render/SpriteAttachment.d.ts.map +1 -0
  193. package/dist/two-d/render/SpriteAttachment.js +60 -0
  194. package/dist/two-d/render/SpriteAttachment.js.map +1 -0
  195. package/dist/two-d/render/TerrainGraphics.d.ts +142 -0
  196. package/dist/two-d/render/TerrainGraphics.d.ts.map +1 -0
  197. package/dist/two-d/render/TerrainGraphics.js +161 -0
  198. package/dist/two-d/render/TerrainGraphics.js.map +1 -0
  199. package/dist/two-d/render/index.d.ts +13 -3
  200. package/dist/two-d/render/index.d.ts.map +1 -1
  201. package/dist/two-d/render/index.js +6 -1
  202. package/dist/two-d/render/index.js.map +1 -1
  203. package/dist/two-d/stage/StoryScreen.d.ts +110 -0
  204. package/dist/two-d/stage/StoryScreen.d.ts.map +1 -0
  205. package/dist/two-d/stage/StoryScreen.js +178 -0
  206. package/dist/two-d/stage/StoryScreen.js.map +1 -0
  207. package/dist/two-d/stage/index.d.ts +2 -0
  208. package/dist/two-d/stage/index.d.ts.map +1 -1
  209. package/dist/two-d/stage/index.js +1 -0
  210. package/dist/two-d/stage/index.js.map +1 -1
  211. package/dist/two-d/ui/Checkbox.d.ts +44 -0
  212. package/dist/two-d/ui/Checkbox.d.ts.map +1 -0
  213. package/dist/two-d/ui/Checkbox.js +89 -0
  214. package/dist/two-d/ui/Checkbox.js.map +1 -0
  215. package/dist/two-d/ui/DataTable.d.ts +79 -0
  216. package/dist/two-d/ui/DataTable.d.ts.map +1 -0
  217. package/dist/two-d/ui/DataTable.js +164 -0
  218. package/dist/two-d/ui/DataTable.js.map +1 -0
  219. package/dist/two-d/ui/Dropdown.d.ts +72 -0
  220. package/dist/two-d/ui/Dropdown.d.ts.map +1 -0
  221. package/dist/two-d/ui/Dropdown.js +126 -0
  222. package/dist/two-d/ui/Dropdown.js.map +1 -0
  223. package/dist/two-d/ui/FloatingText.d.ts +43 -4
  224. package/dist/two-d/ui/FloatingText.d.ts.map +1 -1
  225. package/dist/two-d/ui/FloatingText.js +52 -5
  226. package/dist/two-d/ui/FloatingText.js.map +1 -1
  227. package/dist/two-d/ui/FloatingTextStack.d.ts +86 -13
  228. package/dist/two-d/ui/FloatingTextStack.d.ts.map +1 -1
  229. package/dist/two-d/ui/FloatingTextStack.js +89 -18
  230. package/dist/two-d/ui/FloatingTextStack.js.map +1 -1
  231. package/dist/two-d/ui/Layout.d.ts +105 -0
  232. package/dist/two-d/ui/Layout.d.ts.map +1 -0
  233. package/dist/two-d/ui/Layout.js +174 -0
  234. package/dist/two-d/ui/Layout.js.map +1 -0
  235. package/dist/two-d/ui/ScrollBox.d.ts +67 -0
  236. package/dist/two-d/ui/ScrollBox.d.ts.map +1 -0
  237. package/dist/two-d/ui/ScrollBox.js +127 -0
  238. package/dist/two-d/ui/ScrollBox.js.map +1 -0
  239. package/dist/two-d/ui/Skins.d.ts +59 -0
  240. package/dist/two-d/ui/Skins.d.ts.map +1 -0
  241. package/dist/two-d/ui/Skins.js +77 -0
  242. package/dist/two-d/ui/Skins.js.map +1 -0
  243. package/dist/two-d/ui/Slider.d.ts +91 -0
  244. package/dist/two-d/ui/Slider.d.ts.map +1 -0
  245. package/dist/two-d/ui/Slider.js +170 -0
  246. package/dist/two-d/ui/Slider.js.map +1 -0
  247. package/dist/two-d/ui/Spinner.d.ts +67 -0
  248. package/dist/two-d/ui/Spinner.d.ts.map +1 -0
  249. package/dist/two-d/ui/Spinner.js +137 -0
  250. package/dist/two-d/ui/Spinner.js.map +1 -0
  251. package/dist/two-d/ui/TabbedList.d.ts +118 -0
  252. package/dist/two-d/ui/TabbedList.d.ts.map +1 -0
  253. package/dist/two-d/ui/TabbedList.js +221 -0
  254. package/dist/two-d/ui/TabbedList.js.map +1 -0
  255. package/dist/two-d/ui/TextModel.d.ts +65 -0
  256. package/dist/two-d/ui/TextModel.d.ts.map +1 -0
  257. package/dist/two-d/ui/TextModel.js +126 -0
  258. package/dist/two-d/ui/TextModel.js.map +1 -0
  259. package/dist/two-d/ui/TreeView.d.ts +68 -0
  260. package/dist/two-d/ui/TreeView.d.ts.map +1 -0
  261. package/dist/two-d/ui/TreeView.js +133 -0
  262. package/dist/two-d/ui/TreeView.js.map +1 -0
  263. package/dist/two-d/ui/Window.d.ts +13 -0
  264. package/dist/two-d/ui/Window.d.ts.map +1 -1
  265. package/dist/two-d/ui/Window.js +23 -0
  266. package/dist/two-d/ui/Window.js.map +1 -1
  267. package/dist/two-d/ui/WindowStack.d.ts.map +1 -1
  268. package/dist/two-d/ui/WindowStack.js +2 -0
  269. package/dist/two-d/ui/WindowStack.js.map +1 -1
  270. package/dist/two-d/ui/index.d.ts +27 -3
  271. package/dist/two-d/ui/index.d.ts.map +1 -1
  272. package/dist/two-d/ui/index.js +14 -2
  273. package/dist/two-d/ui/index.js.map +1 -1
  274. package/dist/two-d/ui/markup.d.ts +127 -0
  275. package/dist/two-d/ui/markup.d.ts.map +1 -0
  276. package/dist/two-d/ui/markup.js +322 -0
  277. package/dist/two-d/ui/markup.js.map +1 -0
  278. package/dist/version.d.ts +1 -1
  279. package/dist/version.js +1 -1
  280. package/dist/world/SideTurns.d.ts +115 -0
  281. package/dist/world/SideTurns.d.ts.map +1 -0
  282. package/dist/world/SideTurns.js +138 -0
  283. package/dist/world/SideTurns.js.map +1 -0
  284. package/dist/world/index.d.ts +2 -0
  285. package/dist/world/index.d.ts.map +1 -1
  286. package/dist/world/index.js +1 -0
  287. package/dist/world/index.js.map +1 -1
  288. package/package.json +8 -3
@@ -1,7 +1,8 @@
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, 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;
7
8
  export function createWorld() {
@@ -13,6 +14,7 @@ export function createWorld() {
13
14
  gold: {},
14
15
  turn: 1,
15
16
  status: 'playing',
17
+ sideStatus: {},
16
18
  map: null,
17
19
  timeOfDay: '',
18
20
  scheduleIndex: 0,
@@ -35,10 +37,11 @@ export function parseTerrain(text) {
35
37
  for (let y = 0; y < height; y++) {
36
38
  for (let x = 0; x < width; x++) {
37
39
  const token = rows[y]?.[x] ?? '_off^_usr';
38
- const keep = /^([0-9]+)\s+(.+)$/.exec(token);
40
+ //`<side> <code>`: the side is the id `[side]` declares, so a named side works here too
41
+ const keep = /^([A-Za-z0-9_][\w.-]*)\s+(\S.*)$/.exec(token);
39
42
  if (keep) {
40
43
  codes.push(keep[2]);
41
- (starts[Number(keep[1])] ??= []).push({ x, y });
44
+ (starts[keep[1]] ??= []).push({ x, y });
42
45
  }
43
46
  else
44
47
  codes.push(token);
@@ -73,6 +76,8 @@ export class MwlRuntime {
73
76
  this.loadInitialContent();
74
77
  this.loadInitialUnits();
75
78
  this.loadLeaders();
79
+ //roles filter over the units, so this runs once they and the leaders are in the world
80
+ this.loadScenarioExtras();
76
81
  }
77
82
  run(trigger) {
78
83
  for (const event of this.nodes('event')) {
@@ -118,7 +123,7 @@ export class MwlRuntime {
118
123
  continue;
119
124
  if (event.attributes.unit !== undefined && event.attributes.unit !== id)
120
125
  continue;
121
- if (event.attributes.side !== undefined && integer(event, 'side', Number.NaN) !== unit.side)
126
+ if (event.attributes.side !== undefined && event.attributes.side !== unit.side)
122
127
  continue;
123
128
  if (!this.eventFiltersMatch(event))
124
129
  continue;
@@ -188,12 +193,13 @@ export class MwlRuntime {
188
193
  for (const child of node.children) {
189
194
  if (child.tag === 'message') {
190
195
  this.showMessage(child.attributes);
191
- const side = Number.parseInt(child.attributes.side ?? '', 10);
192
196
  last = {
193
197
  text: child.attributes.text ?? child.attributes.value ?? '',
194
198
  ...(child.attributes.speaker === undefined ? {} : { speaker: child.attributes.speaker }),
195
199
  ...(child.attributes.portrait === undefined ? {} : { portrait: child.attributes.portrait }),
196
- ...(Number.isFinite(side) ? { side } : {}),
200
+ ...(child.attributes.side === undefined || child.attributes.side === ''
201
+ ? {}
202
+ : { side: child.attributes.side }),
197
203
  };
198
204
  }
199
205
  else if (child.tag === 'say') {
@@ -294,11 +300,19 @@ export class MwlRuntime {
294
300
  Object.assign(this.world.gold, restored.gold ?? {});
295
301
  this.world.turn = restored.turn;
296
302
  this.world.status = restored.status;
303
+ this.world.sideStatus = { ...(restored.sideStatus ?? {}) };
297
304
  this.world.map = restored.map ?? null;
298
305
  this.world.timeOfDay = restored.timeOfDay ?? this.schedule[0] ?? '';
299
306
  this.world.scheduleIndex = restored.scheduleIndex ?? 0;
300
307
  // One-shot events stay spent across a load.
301
308
  this.world.firedEvents = [...(restored.firedEvents ?? [])];
309
+ // Scenario data a command may have changed, restored wholesale rather than merged: a load
310
+ // is that save's world, not this scenario's with a few fields over it.
311
+ this.world.villages = restored.villages ? structuredClone(restored.villages) : undefined;
312
+ this.world.clearedShroud = restored.clearedShroud ? structuredClone(restored.clearedShroud) : undefined;
313
+ this.world.roles = restored.roles ? structuredClone(restored.roles) : undefined;
314
+ this.world.objects = restored.objects ? structuredClone(restored.objects) : undefined;
315
+ this.world.story = restored.story ? structuredClone(restored.story) : undefined;
302
316
  this.world.pendingDialogue = restored.pendingDialogue;
303
317
  this.pendingDialogue = restored.pendingDialogue
304
318
  ? { id: restored.pendingDialogue.id, choices: [...restored.pendingDialogue.choices] }
@@ -337,6 +351,22 @@ export class MwlRuntime {
337
351
  entry.controller = side.attributes.controller;
338
352
  if (side.attributes.recruit !== undefined)
339
353
  entry.recruit = side.attributes.recruit;
354
+ //only what was written, so a world stays as small as the content that made it
355
+ if (side.attributes.team_name !== undefined)
356
+ entry.teamName = side.attributes.team_name;
357
+ if (side.attributes.user_team_name !== undefined)
358
+ entry.userTeamName = side.attributes.user_team_name;
359
+ if (side.attributes.share_vision !== undefined)
360
+ entry.shareVision = side.attributes.share_vision;
361
+ if (side.attributes.flag !== undefined)
362
+ entry.flag = side.attributes.flag;
363
+ if (side.attributes.village_gold !== undefined)
364
+ entry.villageGold = integer(side, 'village_gold', 0);
365
+ for (const key of ['heal', 'fog', 'shroud', 'hidden']) {
366
+ const written = side.attributes[key];
367
+ if (written !== undefined)
368
+ entry[key] = written === 'yes' || written === 'true';
369
+ }
340
370
  this.world.sides[required(side, 'id')] = entry;
341
371
  }
342
372
  for (const map of this.nodes('map')) {
@@ -346,6 +376,54 @@ export class MwlRuntime {
346
376
  this.world.map = this.buildMap(id, map);
347
377
  }
348
378
  }
379
+ /** Direct children of `[game]` with a tag, as opposed to `nodes` which searches the whole tree. */
380
+ topLevelNodes(tag) {
381
+ const game = this.game.roots.find((root) => root.tag === 'game');
382
+ return (game?.children ?? this.game.roots).filter((root) => root.tag === tag);
383
+ }
384
+ /**
385
+ * Scenario-level data that is not a side, a map or a unit: `[object]` placements, the `[story]`
386
+ * beats, and the `[role]` table. Kept as plain data on the world - the framework sequences and
387
+ * serialises it, and the game decides what an object does, what a story beat looks like, and
388
+ * what a role is for.
389
+ */
390
+ loadScenarioExtras() {
391
+ for (const object of this.topLevelNodes('object')) {
392
+ const x = integer(object, 'x', 0);
393
+ const y = integer(object, 'y', 0);
394
+ const entry = { x, y };
395
+ if (object.attributes.id !== undefined)
396
+ entry.id = object.attributes.id;
397
+ if (object.attributes.name !== undefined)
398
+ entry.name = object.attributes.name;
399
+ if (object.attributes.image !== undefined)
400
+ entry.image = object.attributes.image;
401
+ if (object.attributes.side !== undefined)
402
+ entry.side = object.attributes.side;
403
+ (this.world.objects ??= []).push(entry);
404
+ }
405
+ for (const story of this.topLevelNodes('story')) {
406
+ const text = story.attributes.text ?? story.attributes.value;
407
+ if (text === undefined)
408
+ continue;
409
+ const entry = { text };
410
+ if (story.attributes.title !== undefined)
411
+ entry.title = story.attributes.title;
412
+ if (story.attributes.image !== undefined)
413
+ entry.image = story.attributes.image;
414
+ if (story.attributes.music !== undefined)
415
+ entry.music = story.attributes.music;
416
+ (this.world.story ??= []).push(entry);
417
+ }
418
+ for (const role of this.topLevelNodes('role')) {
419
+ const name = role.attributes.role ?? role.attributes.name;
420
+ if (name === undefined)
421
+ continue;
422
+ (this.world.roles ??= {})[name] = Object.entries(this.world.units)
423
+ .filter(([id, unit]) => unit.alive && unitMatchesFilter(unit, id, role.attributes))
424
+ .map(([id]) => id);
425
+ }
426
+ }
349
427
  buildMap(id, node) {
350
428
  let text = node.attributes.terrain;
351
429
  if (!text && node.attributes.file && this.resolveMap)
@@ -356,7 +434,7 @@ export class MwlRuntime {
356
434
  for (const start of node.children) {
357
435
  if (start.tag !== 'start')
358
436
  continue;
359
- const side = optionalInteger(start, 'side');
437
+ const side = start.attributes.side;
360
438
  const x = optionalInteger(start, 'x');
361
439
  const y = optionalInteger(start, 'y');
362
440
  if (side !== undefined && x !== undefined && y !== undefined)
@@ -380,7 +458,7 @@ export class MwlRuntime {
380
458
  entry.moves = stats.movement;
381
459
  }
382
460
  if (unit.attributes.side !== undefined)
383
- entry.side = integer(unit, 'side', 0);
461
+ entry.side = unit.attributes.side;
384
462
  this.world.units[id] = entry;
385
463
  }
386
464
  }
@@ -389,24 +467,24 @@ export class MwlRuntime {
389
467
  const leader = side.attributes.leader;
390
468
  if (!leader)
391
469
  continue;
392
- const sideNumber = optionalInteger(side, 'id');
393
- if (sideNumber === undefined)
470
+ const sideId = side.attributes.id;
471
+ if (!sideId)
394
472
  continue;
395
- const starts = this.world.map?.starts[sideNumber];
473
+ const starts = this.world.map?.starts[sideId];
396
474
  if (!starts?.length)
397
475
  continue;
398
476
  const stats = this.unitTypes.get(leader);
399
477
  for (const start of starts) {
400
478
  if (this.unitAt(start.x, start.y))
401
479
  continue;
402
- const id = `${leader} ${sideNumber} (${start.x},${start.y})`;
480
+ const id = `${leader} ${sideId} (${start.x},${start.y})`;
403
481
  this.world.units[id] = {
404
482
  hp: stats?.hitpoints ?? 1,
405
483
  x: start.x,
406
484
  y: start.y,
407
485
  alive: true,
408
486
  type: leader,
409
- side: sideNumber,
487
+ side: sideId,
410
488
  moves: stats?.movement ?? 0,
411
489
  };
412
490
  }
@@ -425,14 +503,104 @@ export class MwlRuntime {
425
503
  break;
426
504
  }
427
505
  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));
506
+ 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
507
  break;
430
508
  case 'move':
431
509
  this.applyMove(node.attributes.unit ?? required(node, 'target'), integer(node, 'x', 0), integer(node, 'y', 0));
432
510
  break;
433
- case 'kill':
434
- this.killUnit(node.attributes.unit ?? required(node, 'target'));
511
+ case 'kill': {
512
+ //a named unit that is not there is a content error; a filter that matches nobody is not,
513
+ //which is what WML's own `[kill]` does. An empty filter matches every unit, there as
514
+ //here, which is worth knowing before writing one.
515
+ const named = node.attributes.unit ?? node.attributes.target;
516
+ if (named === undefined)
517
+ killMatching(this.world, node.attributes);
518
+ else
519
+ this.killUnit(named);
435
520
  break;
521
+ }
522
+ case 'fire_event': {
523
+ const id = node.attributes.id ?? node.attributes.name;
524
+ if (!id)
525
+ throw new Error('[fire_event] requires id');
526
+ this.fireEvent(id);
527
+ break;
528
+ }
529
+ case 'store_unit': {
530
+ const variable = node.attributes.variable ?? node.attributes.name ?? required(node, 'variable');
531
+ this.setVariableAt(variable, this.matchingUnits(node).map(([id, unit]) => ({ id, ...unitSnapshot(unit) })));
532
+ break;
533
+ }
534
+ case 'unstore_unit': {
535
+ const variable = required(node, 'variable');
536
+ for (const entry of this.storedUnits(variable))
537
+ this.restoreUnit(entry, {});
538
+ break;
539
+ }
540
+ case 'recall': {
541
+ const stored = this.storedUnits(required(node, 'variable'));
542
+ const wanted = node.attributes.id ?? node.attributes.unit;
543
+ const entry = wanted === undefined ? stored[0] : stored.find((candidate) => candidate.id === wanted);
544
+ if (!entry)
545
+ throw new Error(`[recall] found no stored unit${wanted === undefined ? '' : ` named ${wanted}`}`);
546
+ const placement = {};
547
+ if (node.attributes.x !== undefined)
548
+ placement.x = integer(node, 'x', 0);
549
+ if (node.attributes.y !== undefined)
550
+ placement.y = integer(node, 'y', 0);
551
+ if (node.attributes.side !== undefined)
552
+ placement.side = node.attributes.side;
553
+ this.restoreUnit(entry, placement);
554
+ break;
555
+ }
556
+ case 'modify_unit': {
557
+ //WML separates which units (`[filter]`) from what changes (`[set]`); a bare
558
+ //`[modify_unit] hp=5` with no `[set]` changes every unit it matches
559
+ const changes = node.children.find((child) => child.tag === 'set')?.attributes ?? node.attributes;
560
+ for (const [, unit] of this.matchingUnits(node))
561
+ applyUnitChanges(unit, changes);
562
+ break;
563
+ }
564
+ case 'heal_unit': {
565
+ const amount = optionalInteger(node, 'amount');
566
+ const absolute = optionalInteger(node, 'hp');
567
+ if (amount === undefined && absolute === undefined)
568
+ throw new Error('[heal_unit] requires amount or hp');
569
+ for (const [, unit] of this.matchingUnits(node))
570
+ unit.hp = absolute ?? unit.hp + (amount ?? 0);
571
+ break;
572
+ }
573
+ case 'set_terrain':
574
+ this.setTerrain(integer(node, 'x', 0), integer(node, 'y', 0), required(node, 'terrain'));
575
+ break;
576
+ case 'capture_village': {
577
+ const x = integer(node, 'x', 0);
578
+ const y = integer(node, 'y', 0);
579
+ const entry = { x, y, side: required(node, 'side') };
580
+ if (node.attributes.name !== undefined)
581
+ entry.name = node.attributes.name;
582
+ (this.world.villages ??= {})[`${x},${y}`] = entry;
583
+ break;
584
+ }
585
+ case 'clear_shroud': {
586
+ const side = required(node, 'side');
587
+ const x = integer(node, 'x', 0);
588
+ const y = integer(node, 'y', 0);
589
+ const radius = integer(node, 'radius', 1);
590
+ const cleared = new Set(this.world.clearedShroud?.[side] ?? []);
591
+ for (let dy = -radius; dy <= radius; dy++)
592
+ for (let dx = -radius; dx <= radius; dx++)
593
+ cleared.add(`${x + dx},${y + dy}`);
594
+ (this.world.clearedShroud ??= {})[side] = [...cleared];
595
+ break;
596
+ }
597
+ case 'role': {
598
+ const name = node.attributes.role ?? node.attributes.name;
599
+ if (!name)
600
+ throw new Error('[role] requires role');
601
+ (this.world.roles ??= {})[name] = this.matchingUnits(node).map(([id]) => id);
602
+ break;
603
+ }
436
604
  case 'attack':
437
605
  this.attack(node.attributes.defender ?? node.attributes.target ?? required(node, 'target'), integer(node, 'amount', 0));
438
606
  break;
@@ -490,11 +658,25 @@ export class MwlRuntime {
490
658
  this.endTurn();
491
659
  break;
492
660
  case 'win':
661
+ this.markSideResult(node.attributes.side, 'won');
493
662
  this.world.status = 'won';
494
663
  break;
495
664
  case 'lose':
665
+ this.markSideResult(node.attributes.side, 'lost');
496
666
  this.world.status = 'lost';
497
667
  break;
668
+ case 'endlevel': {
669
+ const result = node.attributes.result === 'defeat' ? 'defeat' : 'victory';
670
+ this.world.status = result === 'defeat' ? 'lost' : 'won';
671
+ this.world.carryover = endLevelCarryover(this.world, endLevelSide(this.world, node.attributes.side), {
672
+ result,
673
+ bonus: integerAttribute(node, 'bonus'),
674
+ carryoverPercentage: numberAttribute(node, 'carryover_percentage'),
675
+ carryoverAdd: booleanAttribute(node, 'carryover_add'),
676
+ nextScenario: node.attributes.next_scenario ?? null,
677
+ });
678
+ break;
679
+ }
498
680
  case 'if':
499
681
  if (!this.nodeConditionMatches(node))
500
682
  break;
@@ -545,12 +727,11 @@ export class MwlRuntime {
545
727
  showMessage(attributes) {
546
728
  if (!this.onMessage)
547
729
  return;
548
- const side = Number.parseInt(attributes.side ?? '', 10);
549
730
  const message = {
550
731
  text: this.interpolate(attributes.text ?? attributes.value ?? ''),
551
732
  ...(attributes.speaker === undefined ? {} : { speaker: attributes.speaker }),
552
733
  ...(attributes.portrait === undefined ? {} : { portrait: attributes.portrait }),
553
- ...(Number.isFinite(side) ? { side } : {}),
734
+ ...(attributes.side === undefined || attributes.side === '' ? {} : { side: attributes.side }),
554
735
  };
555
736
  this.onMessage(message);
556
737
  }
@@ -625,7 +806,7 @@ export class MwlRuntime {
625
806
  }
626
807
  spawnUnit(type, side, x, y, id, hp) {
627
808
  const stats = type ? this.unitTypes.get(type) : undefined;
628
- const key = id ?? `${type || 'unit'}#${side ?? 0}@${x},${y}`;
809
+ const key = id ?? `${type || 'unit'}#${side ?? ''}@${x},${y}`;
629
810
  this.world.units[key] = {
630
811
  hp: hp ?? stats?.hitpoints ?? 1,
631
812
  x,
@@ -642,6 +823,54 @@ export class MwlRuntime {
642
823
  throw new Error(`MWL unit is not alive: ${id}`);
643
824
  unit.alive = false;
644
825
  }
826
+ /**
827
+ * Live units a command's filter selects: its `[filter]` child when it has one, otherwise its
828
+ * own attributes, read by the same `unitMatchesFilter` an event filter and `[kill]` use. An
829
+ * empty filter matches every live unit, the rule `[kill]` already documents.
830
+ */
831
+ matchingUnits(node) {
832
+ const filter = node.children.find((child) => child.tag === 'filter');
833
+ const attributes = filter?.attributes ?? node.attributes;
834
+ return Object.entries(this.world.units).filter(([id, unit]) => unit.alive && unitMatchesFilter(unit, id, attributes));
835
+ }
836
+ /** The unit snapshots `[store_unit]` wrote into a world variable, or a named content error. */
837
+ storedUnits(variable) {
838
+ const stored = this.world.variables[variable];
839
+ if (!Array.isArray(stored))
840
+ throw new Error(`MWL variable ${variable} is not a stored unit list; store one first`);
841
+ return stored.filter((entry) => !!entry && typeof entry === 'object' && !Array.isArray(entry));
842
+ }
843
+ /** Writes a stored snapshot back into the world, applying any placement overrides. */
844
+ restoreUnit(entry, placement) {
845
+ const id = typeof entry.id === 'string' ? entry.id : undefined;
846
+ if (!id)
847
+ throw new Error('a stored unit snapshot is missing its id');
848
+ const unit = {
849
+ hp: typeof entry.hp === 'number' ? entry.hp : 1,
850
+ x: placement.x ?? (typeof entry.x === 'number' ? entry.x : 0),
851
+ y: placement.y ?? (typeof entry.y === 'number' ? entry.y : 0),
852
+ alive: entry.alive !== false,
853
+ };
854
+ if (typeof entry.type === 'string')
855
+ unit.type = entry.type;
856
+ const side = placement.side ?? (typeof entry.side === 'string' ? entry.side : undefined);
857
+ if (side !== undefined)
858
+ unit.side = side;
859
+ if (typeof entry.moves === 'number')
860
+ unit.moves = entry.moves;
861
+ this.world.units[id] = unit;
862
+ }
863
+ /** Changes one cell of the primary map, keeping the parsed grid and its size in step. */
864
+ setTerrain(x, y, terrain) {
865
+ const map = this.world.map;
866
+ if (!map)
867
+ throw new Error('[set_terrain] needs a loaded map');
868
+ if (x < 0 || y < 0 || x >= map.width || y >= map.height)
869
+ throw new Error(`[set_terrain] is outside the map: ${x},${y}`);
870
+ const codes = [...map.codes];
871
+ codes[y * map.width + x] = terrain;
872
+ this.world.map = { ...map, codes };
873
+ }
645
874
  addGold(side, delta) {
646
875
  this.world.gold[side] = (this.world.gold[side] ?? 0) + delta;
647
876
  }
@@ -650,6 +879,22 @@ export class MwlRuntime {
650
879
  this.advanceSchedule();
651
880
  this.resetMoves();
652
881
  this.run('turn');
882
+ this.checkTimeOver();
883
+ }
884
+ /**
885
+ * Wesnoth's "time over", as a framework native: once the scenario's `turn_limit` has passed,
886
+ * `time_over` becomes a variable content can test and an event trigger it can answer. The
887
+ * framework reports it rather than ending anything, because a turn limit is usually a defeat and
888
+ * occasionally the whole point of the scenario ("hold out until then"), so what it means is
889
+ * content's business - `[endlevel]` is right there for it. Fired once, and derived from the turn
890
+ * counter rather than kept in step with it.
891
+ */
892
+ checkTimeOver() {
893
+ const limit = integerAttribute(this.game.roots[0], 'turn_limit');
894
+ if (limit === undefined || this.world.turn <= limit || this.world.variables.time_over === 'yes')
895
+ return;
896
+ this.world.variables.time_over = 'yes';
897
+ this.run('time_over');
653
898
  }
654
899
  advanceSchedule() {
655
900
  if (!this.schedule.length)
@@ -721,12 +966,17 @@ export class MwlRuntime {
721
966
  return true;
722
967
  });
723
968
  }
724
- conditionMet(node) {
969
+ /**
970
+ * Whether one objective's condition holds. `defaultSide` is the side whose own
971
+ * `[victory]`/`[defeat]` node this is, so a condition that names no side (a side losing
972
+ * when its own units are gone) reads as that side rather than as nobody.
973
+ */
974
+ conditionMet(node, defaultSide) {
725
975
  const condition = node.attributes.condition ?? '';
726
- const side = optionalInteger(node, 'side');
976
+ const side = node.attributes.side ?? defaultSide;
727
977
  switch (condition) {
728
978
  case 'units_dead': {
729
- const target = optionalInteger(node, 'side_filter') ?? side;
979
+ const target = node.attributes.side_filter ?? side;
730
980
  if (target === undefined)
731
981
  return false;
732
982
  return !Object.values(this.world.units).some((unit) => unit.alive && unit.side === target);
@@ -737,14 +987,13 @@ export class MwlRuntime {
737
987
  }
738
988
  case 'gold_at_least': {
739
989
  const gold = optionalInteger(node, 'gold');
740
- return (gold !== undefined &&
741
- node.attributes.side !== undefined &&
742
- (this.world.gold[node.attributes.side] ?? 0) >= gold);
990
+ const goldSide = node.attributes.side ?? defaultSide;
991
+ return gold !== undefined && goldSide !== undefined && (this.world.gold[goldSide] ?? 0) >= gold;
743
992
  }
744
993
  case 'unit_at': {
745
994
  const x = optionalInteger(node, 'x');
746
995
  const y = optionalInteger(node, 'y');
747
- const targetSide = optionalInteger(node, 'side_filter') ?? side;
996
+ const targetSide = node.attributes.side_filter ?? side;
748
997
  return Object.values(this.world.units).some((unit) => unit.alive &&
749
998
  (targetSide === undefined || unit.side === targetSide) &&
750
999
  (node.attributes.type === undefined || unit.type === node.attributes.type) &&
@@ -765,15 +1014,60 @@ export class MwlRuntime {
765
1014
  return false;
766
1015
  }
767
1016
  }
1017
+ /**
1018
+ * Evaluates the scenario-wide `[objectives]` conditions first, exactly as before, then each
1019
+ * side's own `[victory]`/`[defeat]` children. The scenario ends on the first side whose own
1020
+ * condition fires, recording that side in `world.sideStatus`; a side's victory outranks
1021
+ * another side's defeat when both fire in the same evaluation, which is the same precedence
1022
+ * the scenario-wide pair has always had. A side condition with no `side`/`side_filter` reads
1023
+ * as that side's own, so "this side loses when it has no units left" is written without
1024
+ * repeating the id.
1025
+ */
768
1026
  checkObjectives() {
769
1027
  if (this.world.status !== 'playing')
770
1028
  return;
771
- if (this.nodes('victory').some((node) => this.conditionMet(node))) {
1029
+ // a `[victory]`/`[defeat]` under a `[side]` is that side's own, never the scenario's
1030
+ const sideConditions = new Set();
1031
+ const sides = this.nodes('side');
1032
+ for (const side of sides) {
1033
+ for (const child of side.children) {
1034
+ if (child.tag === 'victory' || child.tag === 'defeat')
1035
+ sideConditions.add(child);
1036
+ }
1037
+ }
1038
+ if (this.nodes('victory').some((node) => !sideConditions.has(node) && this.conditionMet(node))) {
772
1039
  this.world.status = 'won';
773
1040
  return;
774
1041
  }
775
- if (this.nodes('defeat').some((node) => this.conditionMet(node)))
1042
+ if (this.nodes('defeat').some((node) => !sideConditions.has(node) && this.conditionMet(node))) {
776
1043
  this.world.status = 'lost';
1044
+ return;
1045
+ }
1046
+ let sideWon = false;
1047
+ let sideLost = false;
1048
+ for (const side of sides) {
1049
+ const id = side.attributes.id;
1050
+ if (!id || (this.world.sideStatus?.[id] ?? 'playing') !== 'playing')
1051
+ continue;
1052
+ if (side.children.some((child) => child.tag === 'victory' && this.conditionMet(child, id))) {
1053
+ this.markSideResult(id, 'won');
1054
+ sideWon = true;
1055
+ }
1056
+ else if (side.children.some((child) => child.tag === 'defeat' && this.conditionMet(child, id))) {
1057
+ this.markSideResult(id, 'lost');
1058
+ sideLost = true;
1059
+ }
1060
+ }
1061
+ if (sideWon)
1062
+ this.world.status = 'won';
1063
+ else if (sideLost)
1064
+ this.world.status = 'lost';
1065
+ }
1066
+ /** Records one side's own result; the scenario status is set by whichever side fired first. */
1067
+ markSideResult(side, result) {
1068
+ if (side === undefined || side === '')
1069
+ return;
1070
+ (this.world.sideStatus ??= {})[side] = result;
777
1071
  }
778
1072
  worldView() {
779
1073
  return {
@@ -791,16 +1085,18 @@ export class MwlRuntime {
791
1085
  },
792
1086
  spawn: (type, side, x, y) => this.spawnUnit(type, side, x, y),
793
1087
  kill: (unit) => this.killUnit(unit),
794
- gold: (side, delta) => this.addGold(String(side), delta),
1088
+ gold: (side, delta) => this.addGold(side, delta),
795
1089
  setVariable: (name, value) => {
796
1090
  this.world.variables[name] = value;
797
1091
  },
798
1092
  message: (speaker, text) => this.onMessage?.({ text, ...(speaker ? { speaker } : {}) }),
799
1093
  endTurn: () => this.endTurn(),
800
- win: (_side) => {
1094
+ win: (side) => {
1095
+ this.markSideResult(side, 'won');
801
1096
  this.world.status = 'won';
802
1097
  },
803
- lose: (_side) => {
1098
+ lose: (side) => {
1099
+ this.markSideResult(side, 'lost');
804
1100
  this.world.status = 'lost';
805
1101
  },
806
1102
  };
@@ -844,25 +1140,123 @@ export function execute(world, command) {
844
1140
  world.units[command.target] = { hp: command.hp, x: command.x, y: command.y, alive: true };
845
1141
  break;
846
1142
  case 'kill':
847
- requireUnit(world, command.target).alive = false;
1143
+ if (command.target !== undefined)
1144
+ requireUnit(world, command.target).alive = false;
1145
+ else
1146
+ killMatching(world, command.filter ?? {});
848
1147
  break;
849
1148
  case 'end_turn':
850
1149
  world.turn++;
851
1150
  break;
852
1151
  case 'win':
1152
+ if (command.side !== undefined && command.side !== '')
1153
+ (world.sideStatus ??= {})[command.side] = 'won';
853
1154
  world.status = 'won';
854
1155
  break;
855
1156
  case 'lose':
1157
+ if (command.side !== undefined && command.side !== '')
1158
+ (world.sideStatus ??= {})[command.side] = 'lost';
856
1159
  world.status = 'lost';
857
1160
  break;
1161
+ case 'endlevel':
1162
+ world.status = command.result === 'defeat' ? 'lost' : 'won';
1163
+ world.carryover = endLevelCarryover(world, command.side ?? null, command);
1164
+ break;
858
1165
  }
859
1166
  }
1167
+ /**
1168
+ * Which side an `[endlevel]` carries: the one it names, or the human-controlled side when it names
1169
+ * none. `null` when there is no such side, which is not an error - a scenario can end with nobody
1170
+ * to carry.
1171
+ */
1172
+ function endLevelSide(world, named) {
1173
+ const id = named ?? Object.entries(world.sides).find(([, side]) => side.controller === 'human')?.[0];
1174
+ if (!id || !(id in world.sides))
1175
+ return null;
1176
+ return { id };
1177
+ }
1178
+ /**
1179
+ * The sides a scenario's content says see together, as groups ready for `FactionFog.share`.
1180
+ *
1181
+ * Wesnoth groups sides into teams with `[side] team_name`, and `share_vision` says whether a side
1182
+ * shares what it sees with its team. `none` does not; `all` and `shroud` are treated alike here
1183
+ * because `FactionFog` shares sight and memory as one thing rather than keeping them apart, and a
1184
+ * fog that separated them would be the place to tell the two apart. A side with no `team_name`
1185
+ * shares with nobody, and a team of one is not returned, since sharing with nobody is what it
1186
+ * already does.
1187
+ *
1188
+ * @example
1189
+ * ```ts
1190
+ * import { sideVisionGroups, type MwlWorld } from '@datamoc/mw_games/mwl';
1191
+ *
1192
+ * declare const world: MwlWorld;
1193
+ *
1194
+ * // sides 1 and 2 both wrote team_name=north, and side 3 asked for share_vision=none
1195
+ * console.log(sideVisionGroups(world)); // [['1', '2']]
1196
+ * ```
1197
+ */
1198
+ export function sideVisionGroups(world) {
1199
+ const teams = new Map();
1200
+ for (const [id, side] of Object.entries(world.sides)) {
1201
+ if (!side.teamName || side.shareVision === 'none')
1202
+ continue;
1203
+ const group = teams.get(side.teamName);
1204
+ if (group)
1205
+ group.push(id);
1206
+ else
1207
+ teams.set(side.teamName, [id]);
1208
+ }
1209
+ return [...teams.values()].filter((group) => group.length > 1);
1210
+ }
860
1211
  function requireUnit(world, id) {
861
1212
  const unit = world.units[id];
862
1213
  if (!unit || !unit.alive)
863
1214
  throw new Error(`MWL unit is not alive: ${id}`);
864
1215
  return unit;
865
1216
  }
1217
+ /** Kills every alive unit a `[kill]` filter matches, and says how many it killed. */
1218
+ function killMatching(world, attributes) {
1219
+ let killed = 0;
1220
+ for (const [id, unit] of Object.entries(world.units)) {
1221
+ if (!unit.alive || !unitMatchesFilter(unit, id, attributes))
1222
+ continue;
1223
+ unit.alive = false;
1224
+ killed++;
1225
+ }
1226
+ return killed;
1227
+ }
1228
+ /** One unit as `[store_unit]` writes it: only the fields it has, so a variable stays small. */
1229
+ function unitSnapshot(unit) {
1230
+ const snapshot = { hp: unit.hp, x: unit.x, y: unit.y, alive: unit.alive };
1231
+ if (unit.type !== undefined)
1232
+ snapshot.type = unit.type;
1233
+ if (unit.side !== undefined)
1234
+ snapshot.side = unit.side;
1235
+ if (unit.moves !== undefined)
1236
+ snapshot.moves = unit.moves;
1237
+ return snapshot;
1238
+ }
1239
+ /** Applies `[modify_unit]`'s chosen attributes to one unit, ignoring anything it did not name. */
1240
+ function applyUnitChanges(unit, changes) {
1241
+ const hp = finiteNumber(changes.hp);
1242
+ if (hp !== undefined)
1243
+ unit.hp = hp;
1244
+ const moves = finiteNumber(changes.moves);
1245
+ if (moves !== undefined)
1246
+ unit.moves = moves;
1247
+ if (changes.type !== undefined)
1248
+ unit.type = changes.type;
1249
+ if (changes.side !== undefined)
1250
+ unit.side = changes.side;
1251
+ if (changes.alive !== undefined)
1252
+ unit.alive = changes.alive === 'yes' || changes.alive === 'true';
1253
+ }
1254
+ function finiteNumber(value) {
1255
+ if (value === undefined || value === '')
1256
+ return undefined;
1257
+ const parsed = Number(value);
1258
+ return Number.isFinite(parsed) ? parsed : undefined;
1259
+ }
866
1260
  function optionalInteger(node, attribute) {
867
1261
  const value = node.attributes[attribute];
868
1262
  if (value === undefined || value === '')
@@ -922,7 +1316,7 @@ function unitMatchesFilter(unit, id, attributes) {
922
1316
  .filter(Boolean);
923
1317
  const key = attributes.unit ?? attributes.id;
924
1318
  return ((key === undefined || id === key) &&
925
- (attributes.side === undefined || unit.side === Number(attributes.side)) &&
1319
+ (attributes.side === undefined || unit.side === attributes.side) &&
926
1320
  (attributes.type === undefined || unit.type === attributes.type) &&
927
1321
  (excluded.length === 0 || !excluded.includes(unit.type ?? '')) &&
928
1322
  (attributes.x === undefined || unit.x === Number(attributes.x)) &&