azeroth-shell 0.0.0-stage → 0.1.1

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 (1163) hide show
  1. package/README.md +138 -2
  2. package/bin/azsh.mjs +19 -0
  3. package/docs/aifid/eventai-texts.json +4414 -0
  4. package/docs/dungeons/atlas/atlas-rows-dungeons.json +8989 -0
  5. package/docs/dungeons/survey/pack-id-migration.json +2418 -0
  6. package/package.json +35 -4
  7. package/src/autopilot/bossSkip.ts +26 -0
  8. package/src/autopilot/combatRotation.ts +824 -0
  9. package/src/autopilot/config.ts +533 -0
  10. package/src/autopilot/consumables.ts +58 -0
  11. package/src/autopilot/encounterStand.ts +121 -0
  12. package/src/autopilot/objectGatePlan.ts +53 -0
  13. package/src/autopilot/pathEngage.ts +229 -0
  14. package/src/autopilot/plan.ts +1643 -0
  15. package/src/autopilot/questPolicy.ts +274 -0
  16. package/src/autopilot/routeStep.ts +69 -0
  17. package/src/autopilot/sessionReport.ts +88 -0
  18. package/src/autopilot/topOffPower.ts +26 -0
  19. package/src/autopilot/view.ts +476 -0
  20. package/src/cli/bootCheck.mjs +34 -0
  21. package/src/cli/bootData.mjs +57 -0
  22. package/src/cli/combatLogFilter.ts +153 -0
  23. package/src/cli/combatLogFormat.ts +613 -0
  24. package/src/cli/fileRepository.mjs +105 -0
  25. package/src/cli/gzLoader.mjs +31 -0
  26. package/src/cli/liveGauntletEscort.ts +978 -0
  27. package/src/cli/liveStratUndead.ts +305 -0
  28. package/src/cli/macroHostCombat.ts +545 -0
  29. package/src/cli/macroHostWorld.ts +330 -0
  30. package/src/cli/prefs.mjs +31 -0
  31. package/src/cli/register.mjs +4 -0
  32. package/src/cli/repl.mjs +693 -0
  33. package/src/cli/rotationText.mjs +226 -0
  34. package/src/cli/squad.mjs +105 -0
  35. package/src/cli/term.mjs +144 -0
  36. package/src/cli/text.mjs +122 -0
  37. package/src/cli/tui.mjs +626 -0
  38. package/src/cli/tuiArena.mjs +109 -0
  39. package/src/cli/tuiAutopilotSettings.mjs +93 -0
  40. package/src/cli/tuiBags.mjs +180 -0
  41. package/src/cli/tuiBars.mjs +133 -0
  42. package/src/cli/tuiBg.mjs +243 -0
  43. package/src/cli/tuiBulk.mjs +162 -0
  44. package/src/cli/tuiCheck.mjs +1882 -0
  45. package/src/cli/tuiCity.mjs +396 -0
  46. package/src/cli/tuiCompanionKit.mjs +300 -0
  47. package/src/cli/tuiCompanions.mjs +471 -0
  48. package/src/cli/tuiDev.mjs +62 -0
  49. package/src/cli/tuiDungeon.mjs +606 -0
  50. package/src/cli/tuiEnchantTarget.mjs +120 -0
  51. package/src/cli/tuiFight.mjs +577 -0
  52. package/src/cli/tuiFightPanes.mjs +228 -0
  53. package/src/cli/tuiFloor.mjs +506 -0
  54. package/src/cli/tuiGear.mjs +158 -0
  55. package/src/cli/tuiHonor.mjs +74 -0
  56. package/src/cli/tuiLoot.mjs +97 -0
  57. package/src/cli/tuiMacros.mjs +144 -0
  58. package/src/cli/tuiOutfitter.mjs +115 -0
  59. package/src/cli/tuiPet.mjs +116 -0
  60. package/src/cli/tuiProfessions.mjs +800 -0
  61. package/src/cli/tuiRaid.mjs +127 -0
  62. package/src/cli/tuiRep.mjs +65 -0
  63. package/src/cli/tuiSaves.mjs +69 -0
  64. package/src/cli/tuiSearch.mjs +363 -0
  65. package/src/cli/tuiSettings.mjs +151 -0
  66. package/src/cli/tuiSimLab.mjs +80 -0
  67. package/src/cli/tuiTalents.mjs +60 -0
  68. package/src/cli/tuiViews.mjs +1178 -0
  69. package/src/cli/tuiWiki.mjs +181 -0
  70. package/src/cli/tuiWorld.mjs +361 -0
  71. package/src/cli/wikiIndex.ts +259 -0
  72. package/src/cli/wikiSources.ts +355 -0
  73. package/src/data/alteracValley.ts +176 -0
  74. package/src/data/appearanceOptions.ts +534 -0
  75. package/src/data/applyLootChanceSanity.ts +21 -0
  76. package/src/data/createFlavor.ts +96 -0
  77. package/src/data/customItems.ts +33 -0
  78. package/src/data/dataStore.ts +1904 -0
  79. package/src/data/dungeonGameObjects.ts +84 -0
  80. package/src/data/dungeonPlateCover.ts +66 -0
  81. package/src/data/dungeonSpawns.ts +294 -0
  82. package/src/data/dungeons/alterac-valley.ts +135 -0
  83. package/src/data/dungeons/apply-dungeon-rooms.ts +29 -0
  84. package/src/data/dungeons/apply-encounter-outdoors.ts +72 -0
  85. package/src/data/dungeons/apply-instance-geometry.ts +65 -0
  86. package/src/data/dungeons/apply-spawn-placement.ts +171 -0
  87. package/src/data/dungeons/blackfathom-deeps.ts +539 -0
  88. package/src/data/dungeons/blackrock-depths.ts +1410 -0
  89. package/src/data/dungeons/blackwing-lair.ts +129 -0
  90. package/src/data/dungeons/boss-eventai.ts +8 -0
  91. package/src/data/dungeons/deadmines.ts +410 -0
  92. package/src/data/dungeons/dire-maul-east.ts +980 -0
  93. package/src/data/dungeons/dire-maul-north.ts +661 -0
  94. package/src/data/dungeons/dire-maul-west.ts +785 -0
  95. package/src/data/dungeons/dungeon-rooms.json +5479 -0
  96. package/src/data/dungeons/encounter-outdoors.json +359 -0
  97. package/src/data/dungeons/eventai/blackfathom-deeps.ts +308 -0
  98. package/src/data/dungeons/eventai/blackrock-depths.ts +1257 -0
  99. package/src/data/dungeons/eventai/deadmines.ts +264 -0
  100. package/src/data/dungeons/eventai/dire-maul-east.ts +320 -0
  101. package/src/data/dungeons/eventai/gnomeregan.ts +213 -0
  102. package/src/data/dungeons/eventai/helpers.ts +79 -0
  103. package/src/data/dungeons/eventai/index.ts +133 -0
  104. package/src/data/dungeons/eventai/lbrs.ts +372 -0
  105. package/src/data/dungeons/eventai/maraudon.ts +323 -0
  106. package/src/data/dungeons/eventai/ragefire-chasm.ts +108 -0
  107. package/src/data/dungeons/eventai/razorfen-downs.ts +207 -0
  108. package/src/data/dungeons/eventai/razorfen-kraul.ts +326 -0
  109. package/src/data/dungeons/eventai/scholomance.ts +680 -0
  110. package/src/data/dungeons/eventai/shadowfang-keep.ts +230 -0
  111. package/src/data/dungeons/eventai/sm-cathedral.ts +75 -0
  112. package/src/data/dungeons/eventai/sm-graveyard.ts +150 -0
  113. package/src/data/dungeons/eventai/sm-library.ts +55 -0
  114. package/src/data/dungeons/eventai/stratholme-undead.ts +127 -0
  115. package/src/data/dungeons/eventai/sunken-temple.ts +582 -0
  116. package/src/data/dungeons/eventai/the-stockade.ts +228 -0
  117. package/src/data/dungeons/eventai/ubrs.ts +225 -0
  118. package/src/data/dungeons/eventai/uldaman.ts +355 -0
  119. package/src/data/dungeons/eventai/wailing-caverns.ts +396 -0
  120. package/src/data/dungeons/eventai/zul-farrak.ts +591 -0
  121. package/src/data/dungeons/gnomeregan.ts +909 -0
  122. package/src/data/dungeons/index.ts +126 -0
  123. package/src/data/dungeons/instance-geometry.json +5233 -0
  124. package/src/data/dungeons/key-sources.json +169 -0
  125. package/src/data/dungeons/lbrs.ts +996 -0
  126. package/src/data/dungeons/maraudon.ts +786 -0
  127. package/src/data/dungeons/molten-core.ts +194 -0
  128. package/src/data/dungeons/naxxramas.ts +173 -0
  129. package/src/data/dungeons/onyxias-lair.ts +71 -0
  130. package/src/data/dungeons/pack-from-clusters.ts +1256 -0
  131. package/src/data/dungeons/ragefire-chasm.ts +238 -0
  132. package/src/data/dungeons/razorfen-downs.ts +698 -0
  133. package/src/data/dungeons/razorfen-kraul.ts +766 -0
  134. package/src/data/dungeons/ruins-of-ahnqiraj.ts +121 -0
  135. package/src/data/dungeons/scholomance.ts +1076 -0
  136. package/src/data/dungeons/shadowfang-keep.ts +740 -0
  137. package/src/data/dungeons/sm-armory.ts +266 -0
  138. package/src/data/dungeons/sm-cathedral.ts +430 -0
  139. package/src/data/dungeons/sm-graveyard.ts +482 -0
  140. package/src/data/dungeons/sm-library.ts +337 -0
  141. package/src/data/dungeons/stratholme-live.ts +808 -0
  142. package/src/data/dungeons/stratholme-undead.ts +1164 -0
  143. package/src/data/dungeons/sunken-temple.ts +931 -0
  144. package/src/data/dungeons/temple-of-ahnqiraj.ts +119 -0
  145. package/src/data/dungeons/the-stockade.ts +282 -0
  146. package/src/data/dungeons/ubrs.ts +1286 -0
  147. package/src/data/dungeons/uldaman.ts +685 -0
  148. package/src/data/dungeons/wailing-caverns.ts +356 -0
  149. package/src/data/dungeons/zul-farrak.ts +686 -0
  150. package/src/data/dungeons/zul-gurub.ts +142 -0
  151. package/src/data/dungeons.ts +42 -0
  152. package/src/data/fixtures.ts +446 -0
  153. package/src/data/gameobject-data.json.gz +0 -0
  154. package/src/data/item-data.json.gz +0 -0
  155. package/src/data/loot-data.json.gz +0 -0
  156. package/src/data/maps/atlas-approach-maps.json +1425 -0
  157. package/src/data/maps/atlas-landmarks.json +1711 -0
  158. package/src/data/maps/dungeon-viewports.json +392 -0
  159. package/src/data/materializeModDamagePercentTaken.ts +70 -0
  160. package/src/data/materializeModHealthRegen.ts +89 -0
  161. package/src/data/materializeSpellReputation.ts +26 -0
  162. package/src/data/pageTexts.ts +19 -0
  163. package/src/data/profession-data.json.gz +0 -0
  164. package/src/data/raid-plans/blackfathom-deeps.json +86 -0
  165. package/src/data/raid-plans/blackrock-depths.json +419 -0
  166. package/src/data/raid-plans/blackwing-lair.json +328 -0
  167. package/src/data/raid-plans/deadmines.json +55 -0
  168. package/src/data/raid-plans/dire-maul-east.json +94 -0
  169. package/src/data/raid-plans/dire-maul-north.json +70 -0
  170. package/src/data/raid-plans/dire-maul-west.json +75 -0
  171. package/src/data/raid-plans/gnomeregan.json +131 -0
  172. package/src/data/raid-plans/lbrs.json +176 -0
  173. package/src/data/raid-plans/maraudon.json +102 -0
  174. package/src/data/raid-plans/molten-core.json +169 -0
  175. package/src/data/raid-plans/naxxramas.json +354 -0
  176. package/src/data/raid-plans/onyxias-lair.json +18 -0
  177. package/src/data/raid-plans/ragefire-chasm.json +30 -0
  178. package/src/data/raid-plans/razorfen-downs.json +75 -0
  179. package/src/data/raid-plans/razorfen-kraul.json +53 -0
  180. package/src/data/raid-plans/ruins-of-ahnqiraj.json +110 -0
  181. package/src/data/raid-plans/scholomance.json +170 -0
  182. package/src/data/raid-plans/shadowfang-keep.json +87 -0
  183. package/src/data/raid-plans/sm-armory.json +53 -0
  184. package/src/data/raid-plans/sm-cathedral.json +72 -0
  185. package/src/data/raid-plans/sm-graveyard.json +63 -0
  186. package/src/data/raid-plans/sm-library.json +23 -0
  187. package/src/data/raid-plans/stratholme-live.json +129 -0
  188. package/src/data/raid-plans/stratholme-undead.json +73 -0
  189. package/src/data/raid-plans/sunken-temple.json +159 -0
  190. package/src/data/raid-plans/temple-of-ahnqiraj.json +163 -0
  191. package/src/data/raid-plans/the-stockade.json +42 -0
  192. package/src/data/raid-plans/ubrs.json +53 -0
  193. package/src/data/raid-plans/uldaman.json +131 -0
  194. package/src/data/raid-plans/wailing-caverns.json +90 -0
  195. package/src/data/raid-plans/zul-farrak.json +156 -0
  196. package/src/data/raid-plans/zul-gurub.json +307 -0
  197. package/src/data/raidInstanceCreatures.mjs +473 -0
  198. package/src/data/rotations/index.ts +152 -0
  199. package/src/data/rotations/layout.mjs +72 -0
  200. package/src/data/rotations/pve/druid/balance.json +754 -0
  201. package/src/data/rotations/pve/druid/feral-tank.json +2448 -0
  202. package/src/data/rotations/pve/druid/feral.json +2660 -0
  203. package/src/data/rotations/pve/druid/restoration.json +424 -0
  204. package/src/data/rotations/pve/hunter/beast-mastery.json +1288 -0
  205. package/src/data/rotations/pve/hunter/marksmanship.json +1260 -0
  206. package/src/data/rotations/pve/hunter/survival.json +1373 -0
  207. package/src/data/rotations/pve/mage/arcane-leveling.json +1798 -0
  208. package/src/data/rotations/pve/mage/fire-leveling.json +2003 -0
  209. package/src/data/rotations/pve/mage/frost-leveling.json +1912 -0
  210. package/src/data/rotations/pve/paladin/holy.json +755 -0
  211. package/src/data/rotations/pve/paladin/protection.json +962 -0
  212. package/src/data/rotations/pve/paladin/retribution.json +924 -0
  213. package/src/data/rotations/pve/priest/discipline.json +772 -0
  214. package/src/data/rotations/pve/priest/holy.json +707 -0
  215. package/src/data/rotations/pve/priest/shadow.json +787 -0
  216. package/src/data/rotations/pve/rogue/assassination.json +2477 -0
  217. package/src/data/rotations/pve/rogue/combat.json +2483 -0
  218. package/src/data/rotations/pve/rogue/subtlety.json +2523 -0
  219. package/src/data/rotations/pve/shaman/elemental.json +1157 -0
  220. package/src/data/rotations/pve/shaman/enhancement.json +1085 -0
  221. package/src/data/rotations/pve/shaman/restoration.json +592 -0
  222. package/src/data/rotations/pve/warlock/affliction.json +1185 -0
  223. package/src/data/rotations/pve/warlock/demonology.json +1165 -0
  224. package/src/data/rotations/pve/warlock/destruction.json +1177 -0
  225. package/src/data/rotations/pve/warrior/arms.json +3020 -0
  226. package/src/data/rotations/pve/warrior/fury.json +2996 -0
  227. package/src/data/rotations/pve/warrior/protection.json +2509 -0
  228. package/src/data/rotations/pvp/druid/balance.json +593 -0
  229. package/src/data/rotations/pvp/druid/feral.json +3276 -0
  230. package/src/data/rotations/pvp/druid/restoration.json +662 -0
  231. package/src/data/rotations/pvp/hunter/beast-mastery.json +665 -0
  232. package/src/data/rotations/pvp/hunter/marksmanship.json +2406 -0
  233. package/src/data/rotations/pvp/hunter/survival.json +635 -0
  234. package/src/data/rotations/pvp/mage/arcane.json +574 -0
  235. package/src/data/rotations/pvp/mage/fire.json +570 -0
  236. package/src/data/rotations/pvp/mage/frost.json +4048 -0
  237. package/src/data/rotations/pvp/paladin/holy.json +1498 -0
  238. package/src/data/rotations/pvp/paladin/protection.json +567 -0
  239. package/src/data/rotations/pvp/paladin/retribution.json +1110 -0
  240. package/src/data/rotations/pvp/priest/discipline.json +1848 -0
  241. package/src/data/rotations/pvp/priest/holy.json +6340 -0
  242. package/src/data/rotations/pvp/priest/shadow.json +2329 -0
  243. package/src/data/rotations/pvp/rogue/assassination.json +1322 -0
  244. package/src/data/rotations/pvp/rogue/combat.json +5258 -0
  245. package/src/data/rotations/pvp/rogue/subtlety.json +586 -0
  246. package/src/data/rotations/pvp/shaman/elemental.json +1404 -0
  247. package/src/data/rotations/pvp/shaman/enhancement.json +1277 -0
  248. package/src/data/rotations/pvp/shaman/restoration.json +2490 -0
  249. package/src/data/rotations/pvp/warlock/affliction.json +1017 -0
  250. package/src/data/rotations/pvp/warlock/demonology.json +1503 -0
  251. package/src/data/rotations/pvp/warlock/destruction.json +638 -0
  252. package/src/data/rotations/pvp/warrior/arms.json +5665 -0
  253. package/src/data/rotations/pvp/warrior/fury.json +850 -0
  254. package/src/data/rotations/pvp/warrior/protection.json +777 -0
  255. package/src/data/schema/bundle.ts +511 -0
  256. package/src/data/schema/constants.ts +49 -0
  257. package/src/data/schema/creature.ts +454 -0
  258. package/src/data/schema/encounter.ts +155 -0
  259. package/src/data/schema/faction.ts +48 -0
  260. package/src/data/schema/item.ts +297 -0
  261. package/src/data/schema/itemEnchant.ts +105 -0
  262. package/src/data/schema/npc.ts +44 -0
  263. package/src/data/schema/primitives.ts +295 -0
  264. package/src/data/schema/profession.ts +226 -0
  265. package/src/data/schema/quest.ts +154 -0
  266. package/src/data/schema/spell.ts +1149 -0
  267. package/src/data/schema/talent.ts +74 -0
  268. package/src/data/schema/world.ts +124 -0
  269. package/src/data/schema.ts +19 -0
  270. package/src/data/skinNodeGapFill.ts +68 -0
  271. package/src/data/spell-data.json.gz +0 -0
  272. package/src/data/spell-script-targets.json +1 -0
  273. package/src/data/tables/ahItemValues.json.gz +0 -0
  274. package/src/data/tables/alteracValley.json.gz +0 -0
  275. package/src/data/tables/areatriggerTeleports.json.gz +0 -0
  276. package/src/data/tables/botNames.json.gz +0 -0
  277. package/src/data/tables/botPopulation.json.gz +0 -0
  278. package/src/data/tables/botSpecs.json.gz +0 -0
  279. package/src/data/tables/classLevelStats.json.gz +0 -0
  280. package/src/data/tables/combatConditions.json.gz +0 -0
  281. package/src/data/tables/combatSkillMeta.json.gz +0 -0
  282. package/src/data/tables/conditions.json.gz +0 -0
  283. package/src/data/tables/createInfo.json.gz +0 -0
  284. package/src/data/tables/creatureAggroGroups.json.gz +0 -0
  285. package/src/data/tables/creatureClassLevelStats.json.gz +0 -0
  286. package/src/data/tables/creaturePatrolPacks.json.gz +0 -0
  287. package/src/data/tables/creatureWeapons.json.gz +0 -0
  288. package/src/data/tables/creatures.json.gz +0 -0
  289. package/src/data/tables/dbScripts.json.gz +0 -0
  290. package/src/data/tables/dungeonAuthoredRoutes.json.gz +0 -0
  291. package/src/data/tables/dungeonCensus.json.gz +0 -0
  292. package/src/data/tables/dungeonGameObjects.json.gz +0 -0
  293. package/src/data/tables/dungeonPacks.json.gz +0 -0
  294. package/src/data/tables/dungeonPlateCover.json.gz +0 -0
  295. package/src/data/tables/dungeonSpawns.json.gz +0 -0
  296. package/src/data/tables/dungeonSurvey.json.gz +0 -0
  297. package/src/data/tables/durabilityCosts.json.gz +0 -0
  298. package/src/data/tables/durabilityQuality.json.gz +0 -0
  299. package/src/data/tables/enchantApplicators.json.gz +0 -0
  300. package/src/data/tables/entranceYards.json.gz +0 -0
  301. package/src/data/tables/explorationBasexp.json.gz +0 -0
  302. package/src/data/tables/factionTemplates.json.gz +0 -0
  303. package/src/data/tables/factions.json.gz +0 -0
  304. package/src/data/tables/gameEvents.json.gz +0 -0
  305. package/src/data/tables/gameObjects.json.gz +0 -0
  306. package/src/data/tables/gossipMenus.json.gz +0 -0
  307. package/src/data/tables/graveyards.json.gz +0 -0
  308. package/src/data/tables/instanceMaps.json.gz +0 -0
  309. package/src/data/tables/itemEnchantmentTemplates.json.gz +0 -0
  310. package/src/data/tables/itemEnchants.json.gz +0 -0
  311. package/src/data/tables/itemRandomProperties.json.gz +0 -0
  312. package/src/data/tables/itemSets.json.gz +0 -0
  313. package/src/data/tables/items.json.gz +0 -0
  314. package/src/data/tables/levelStats.json.gz +0 -0
  315. package/src/data/tables/locks.json.gz +0 -0
  316. package/src/data/tables/map-pin-overrides.json.gz +0 -0
  317. package/src/data/tables/nonCombatTrash.json.gz +0 -0
  318. package/src/data/tables/npcTexts.json.gz +0 -0
  319. package/src/data/tables/pageTexts.json.gz +0 -0
  320. package/src/data/tables/petAbilityTP.json.gz +0 -0
  321. package/src/data/tables/petGenericSpells.json.gz +0 -0
  322. package/src/data/tables/petLevelStats.json.gz +0 -0
  323. package/src/data/tables/playerbot-weightscales.json.gz +0 -0
  324. package/src/data/tables/pools.json.gz +0 -0
  325. package/src/data/tables/quests.json.gz +0 -0
  326. package/src/data/tables/races.json.gz +0 -0
  327. package/src/data/tables/reputationRanks.json.gz +0 -0
  328. package/src/data/tables/reputationRates.json.gz +0 -0
  329. package/src/data/tables/reputationSpillover.json.gz +0 -0
  330. package/src/data/tables/ridingSpells.json.gz +0 -0
  331. package/src/data/tables/scriptTexts.json.gz +0 -0
  332. package/src/data/tables/soundClipVolume.json.gz +0 -0
  333. package/src/data/tables/spellChain.json.gz +0 -0
  334. package/src/data/tables/spellTeleports.json.gz +0 -0
  335. package/src/data/tables/spells.json.gz +0 -0
  336. package/src/data/tables/talents.json.gz +0 -0
  337. package/src/data/tables/tavernTriggers.json.gz +0 -0
  338. package/src/data/tables/taxiNodes.json.gz +0 -0
  339. package/src/data/tables/taxiRoutes.json.gz +0 -0
  340. package/src/data/tables/trainers.json.gz +0 -0
  341. package/src/data/tables/transports.json.gz +0 -0
  342. package/src/data/tables/travelAnchors.json.gz +0 -0
  343. package/src/data/tables/travelEdges.json.gz +0 -0
  344. package/src/data/tables/unitConditions.json.gz +0 -0
  345. package/src/data/tables/vendors.json.gz +0 -0
  346. package/src/data/tables/weaponEnchantProcs.json.gz +0 -0
  347. package/src/data/tables/xp.json.gz +0 -0
  348. package/src/data/tables/zoneWeather.json.gz +0 -0
  349. package/src/data/tables/zones.json.gz +0 -0
  350. package/src/data/world-data.ts +56 -0
  351. package/src/data/zone-music-sets.json +2751 -0
  352. package/src/dev/etl/lib/loot-chance-sanity.mjs +180 -0
  353. package/src/domain/audioQuality.mjs +145 -0
  354. package/src/domain/bg/ab.ts +433 -0
  355. package/src/domain/bg/arenaComps.ts +644 -0
  356. package/src/domain/bg/arenaLab.ts +695 -0
  357. package/src/domain/bg/arenaPace.ts +31 -0
  358. package/src/domain/bg/arenaRating.ts +110 -0
  359. package/src/domain/bg/av.ts +758 -0
  360. package/src/domain/bg/avEnemyPush.ts +219 -0
  361. package/src/domain/bg/avField.ts +283 -0
  362. package/src/domain/bg/avInstanceNodes.ts +197 -0
  363. package/src/domain/bg/avInstanceScore.ts +123 -0
  364. package/src/domain/bg/avQuestItems.ts +55 -0
  365. package/src/domain/bg/avQuests.ts +210 -0
  366. package/src/domain/bg/avSession.ts +196 -0
  367. package/src/domain/bg/bgConsumes.ts +269 -0
  368. package/src/domain/bg/bgPrebuff.ts +153 -0
  369. package/src/domain/bg/bgRest.ts +91 -0
  370. package/src/domain/bg/bgRoster.ts +305 -0
  371. package/src/domain/bg/bgTier.ts +61 -0
  372. package/src/domain/bg/bgWorld.ts +862 -0
  373. package/src/domain/bg/match.ts +215 -0
  374. package/src/domain/bg/strategies.ts +552 -0
  375. package/src/domain/bg/types.ts +291 -0
  376. package/src/domain/bg/wsg.ts +264 -0
  377. package/src/domain/bots/actionProviderSpec.ts +38 -0
  378. package/src/domain/bots/autoConsumables.ts +285 -0
  379. package/src/domain/bots/betweenPullRest.ts +239 -0
  380. package/src/domain/bots/botGear.ts +248 -0
  381. package/src/domain/bots/botInventory.ts +486 -0
  382. package/src/domain/bots/botPet.ts +535 -0
  383. package/src/domain/bots/botPool.ts +291 -0
  384. package/src/domain/bots/botProfessions.ts +334 -0
  385. package/src/domain/bots/companionActionBar.ts +209 -0
  386. package/src/domain/bots/companionHireCost.ts +236 -0
  387. package/src/domain/bots/companionMissions.ts +582 -0
  388. package/src/domain/bots/companionPrep.ts +254 -0
  389. package/src/domain/bots/companionQuests.ts +270 -0
  390. package/src/domain/bots/companionRep.ts +75 -0
  391. package/src/domain/bots/companionRotation.ts +708 -0
  392. package/src/domain/bots/companionXp.ts +66 -0
  393. package/src/domain/bots/consumableShare.ts +811 -0
  394. package/src/domain/bots/dpsMate.ts +1072 -0
  395. package/src/domain/bots/groupCompReport.ts +132 -0
  396. package/src/domain/bots/healthstoneSupply.ts +198 -0
  397. package/src/domain/bots/outfitter.ts +1056 -0
  398. package/src/domain/bots/paladinPrePull.ts +231 -0
  399. package/src/domain/bots/partyRole.ts +40 -0
  400. package/src/domain/bots/playerbotEquipCache.ts +412 -0
  401. package/src/domain/bots/playerbotItemEligibility.ts +60 -0
  402. package/src/domain/bots/playerbotStatWeight.ts +853 -0
  403. package/src/domain/bots/playerbotUnavailableItems.ts +147 -0
  404. package/src/domain/bots/prePullBuffs.ts +219 -0
  405. package/src/domain/bots/prePullStealth.ts +82 -0
  406. package/src/domain/bots/runSupply.ts +251 -0
  407. package/src/domain/bots/runSupplyPrefs.ts +28 -0
  408. package/src/domain/bots/sendTradeGoods.ts +394 -0
  409. package/src/domain/bots/soulShardCap.ts +35 -0
  410. package/src/domain/bots/squad.ts +842 -0
  411. package/src/domain/bots/squadRun.ts +81 -0
  412. package/src/domain/character/actionbar.ts +346 -0
  413. package/src/domain/character/appearance.ts +165 -0
  414. package/src/domain/character/buildCombatant.ts +1726 -0
  415. package/src/domain/character/character.ts +356 -0
  416. package/src/domain/character/characterName.ts +43 -0
  417. package/src/domain/character/characterRatings.ts +424 -0
  418. package/src/domain/character/characterSpells.ts +282 -0
  419. package/src/domain/character/characterStats.ts +1559 -0
  420. package/src/domain/character/characterTalents.ts +398 -0
  421. package/src/domain/character/combatSkills.ts +994 -0
  422. package/src/domain/character/displayForm.ts +72 -0
  423. package/src/domain/character/formTalentAuras.ts +171 -0
  424. package/src/domain/character/forms.ts +499 -0
  425. package/src/domain/character/races.ts +40 -0
  426. package/src/domain/character/resurrectionSickness.ts +49 -0
  427. package/src/domain/character/spellbook.ts +169 -0
  428. package/src/domain/character/stanceBar.ts +354 -0
  429. package/src/domain/character/stancePrereq.ts +70 -0
  430. package/src/domain/character/talentBuilds.ts +63 -0
  431. package/src/domain/character/talentImport.ts +130 -0
  432. package/src/domain/character/talentLink.ts +123 -0
  433. package/src/domain/character/talents.ts +201 -0
  434. package/src/domain/core/conditionSnapshot.ts +92 -0
  435. package/src/domain/core/hover.ts +14 -0
  436. package/src/domain/core/ids.ts +58 -0
  437. package/src/domain/core/internalNameTags.ts +5 -0
  438. package/src/domain/core/runtimeDataPatches.ts +357 -0
  439. package/src/domain/creature/aiTargeting.ts +588 -0
  440. package/src/domain/creature/combatCondition.ts +157 -0
  441. package/src/domain/creature/creatureAggro.ts +804 -0
  442. package/src/domain/creature/creatureAi.ts +206 -0
  443. package/src/domain/creature/creatureAiConstants.ts +661 -0
  444. package/src/domain/creature/creatureCastList.ts +1007 -0
  445. package/src/domain/creature/creatureEventAi.ts +1409 -0
  446. package/src/domain/creature/creatureGroups.ts +144 -0
  447. package/src/domain/creature/creatureLinking.ts +333 -0
  448. package/src/domain/creature/creatureScaling.ts +320 -0
  449. package/src/domain/creature/creatureTrigger.ts +53 -0
  450. package/src/domain/creature/creatureWander.ts +74 -0
  451. package/src/domain/creature/eventAiTexts.ts +145 -0
  452. package/src/domain/creature/faction.ts +287 -0
  453. package/src/domain/creature/forcedReaction.ts +48 -0
  454. package/src/domain/creature/outdoorHuntable.ts +74 -0
  455. package/src/domain/creature/patrolPacks.ts +210 -0
  456. package/src/domain/creature/reputation.ts +967 -0
  457. package/src/domain/creature/unitFlags.ts +74 -0
  458. package/src/domain/creature/worldBosses.ts +393 -0
  459. package/src/domain/dungeonMusic.mjs +48 -0
  460. package/src/domain/economy/auction.ts +282 -0
  461. package/src/domain/economy/auctionBot.ts +1060 -0
  462. package/src/domain/economy/honorPrice.ts +100 -0
  463. package/src/domain/economy/mail.ts +958 -0
  464. package/src/domain/economy/money.ts +42 -0
  465. package/src/domain/economy/syntheticClassTrainers.ts +62 -0
  466. package/src/domain/economy/trading.ts +296 -0
  467. package/src/domain/economy/vendorSellAll.ts +252 -0
  468. package/src/domain/economy/vendorStock.ts +146 -0
  469. package/src/domain/items/ammo.ts +817 -0
  470. package/src/domain/items/bank.ts +162 -0
  471. package/src/domain/items/consumableFamily.ts +205 -0
  472. package/src/domain/items/craftUpgrades.ts +83 -0
  473. package/src/domain/items/createItem.ts +93 -0
  474. package/src/domain/items/durability.ts +581 -0
  475. package/src/domain/items/equipment.ts +441 -0
  476. package/src/domain/items/equipmentHelper.ts +767 -0
  477. package/src/domain/items/inventory.ts +561 -0
  478. package/src/domain/items/itemBonding.ts +93 -0
  479. package/src/domain/items/itemContainer.ts +245 -0
  480. package/src/domain/items/itemDisplayName.ts +33 -0
  481. package/src/domain/items/itemInstance.ts +181 -0
  482. package/src/domain/items/itemRandomEnchantOutcomes.ts +315 -0
  483. package/src/domain/items/itemRandomProps.ts +77 -0
  484. package/src/domain/items/itemRestrictions.ts +329 -0
  485. package/src/domain/items/itemTeaches.ts +82 -0
  486. package/src/domain/items/itemUseCooldown.ts +52 -0
  487. package/src/domain/items/itemUseTarget.ts +90 -0
  488. package/src/domain/items/junkCategories.ts +174 -0
  489. package/src/domain/items/junkFilter.ts +368 -0
  490. package/src/domain/items/keyring.ts +131 -0
  491. package/src/domain/items/loot.ts +221 -0
  492. package/src/domain/items/mounts.ts +190 -0
  493. package/src/domain/items/onUsePayload.ts +102 -0
  494. package/src/domain/items/pendingLoot.ts +107 -0
  495. package/src/domain/items/relic.ts +105 -0
  496. package/src/domain/items/rocketBoots.ts +36 -0
  497. package/src/domain/items/sortContainer.ts +139 -0
  498. package/src/domain/items/spareContainers.ts +71 -0
  499. package/src/domain/items/spellReagentIndex.ts +131 -0
  500. package/src/domain/items/staleConsumables.ts +66 -0
  501. package/src/domain/items/weaponSpender.ts +110 -0
  502. package/src/domain/listDungeonIds.mjs +28 -0
  503. package/src/domain/macro/compose.ts +239 -0
  504. package/src/domain/macro/conditions.ts +260 -0
  505. package/src/domain/macro/creatureTypes.ts +40 -0
  506. package/src/domain/macro/describe.ts +169 -0
  507. package/src/domain/macro/icon.ts +60 -0
  508. package/src/domain/macro/index.ts +20 -0
  509. package/src/domain/macro/normalize.ts +112 -0
  510. package/src/domain/macro/parse.ts +316 -0
  511. package/src/domain/macro/plan.ts +288 -0
  512. package/src/domain/macro/rotation.ts +590 -0
  513. package/src/domain/macro/rotationEnv.ts +173 -0
  514. package/src/domain/macro/slots.ts +101 -0
  515. package/src/domain/macro/snapshot.ts +114 -0
  516. package/src/domain/macro/templates.ts +446 -0
  517. package/src/domain/macro/types.ts +70 -0
  518. package/src/domain/macro/units.ts +93 -0
  519. package/src/domain/macro/weaponTypes.ts +83 -0
  520. package/src/domain/nav/cityTeleports.ts +10 -0
  521. package/src/domain/nav/mounting.ts +113 -0
  522. package/src/domain/nav/nav.ts +43 -0
  523. package/src/domain/nav/navCollision.ts +606 -0
  524. package/src/domain/nav/navOutdoors.ts +91 -0
  525. package/src/domain/nav/navRegistry.ts +179 -0
  526. package/src/domain/nav/navRoomGraph.ts +302 -0
  527. package/src/domain/nav/navTypes.ts +43 -0
  528. package/src/domain/nav/navmesh.ts +1715 -0
  529. package/src/domain/nav/speed.ts +200 -0
  530. package/src/domain/nav/storey.ts +10 -0
  531. package/src/domain/nav/travel.ts +725 -0
  532. package/src/domain/nav/travelTypes.ts +75 -0
  533. package/src/domain/pet/pet.ts +1150 -0
  534. package/src/domain/pet/petActionBar.ts +180 -0
  535. package/src/domain/pet/petSpellbook.ts +39 -0
  536. package/src/domain/pet/petStable.ts +81 -0
  537. package/src/domain/professions/autoDisenchant.ts +236 -0
  538. package/src/domain/professions/characterEnchants.ts +176 -0
  539. package/src/domain/professions/craftCooldown.ts +52 -0
  540. package/src/domain/professions/enchantApply.ts +62 -0
  541. package/src/domain/professions/enchantTargets.ts +114 -0
  542. package/src/domain/professions/enchanting.ts +236 -0
  543. package/src/domain/professions/gatherFilter.ts +149 -0
  544. package/src/domain/professions/gearEnchant.ts +137 -0
  545. package/src/domain/professions/heldTools.ts +29 -0
  546. package/src/domain/professions/learnAllRecipes.ts +146 -0
  547. package/src/domain/professions/miningHarvest.ts +69 -0
  548. package/src/domain/professions/partyDisenchant.ts +294 -0
  549. package/src/domain/professions/partyWork.ts +83 -0
  550. package/src/domain/professions/pickpocket.ts +172 -0
  551. package/src/domain/professions/poisons.ts +240 -0
  552. package/src/domain/professions/professions.ts +677 -0
  553. package/src/domain/professions/rareNodes.ts +45 -0
  554. package/src/domain/professions/skinLoot.ts +27 -0
  555. package/src/domain/professions/vendorReagents.ts +152 -0
  556. package/src/domain/professions/weaponImbueHand.ts +171 -0
  557. package/src/domain/questFarmFilter.mjs +42 -0
  558. package/src/domain/quests/doQuest.ts +1204 -0
  559. package/src/domain/quests/gossip.ts +308 -0
  560. package/src/domain/quests/gossipTalk.ts +298 -0
  561. package/src/domain/quests/interpreter/canComplete.ts +150 -0
  562. package/src/domain/quests/interpreter/claim.ts +137 -0
  563. package/src/domain/quests/interpreter/dbscript/handlers.ts +1371 -0
  564. package/src/domain/quests/interpreter/dbscript/handlersBatch.ts +1251 -0
  565. package/src/domain/quests/interpreter/dbscript/hooks.ts +331 -0
  566. package/src/domain/quests/interpreter/dbscript/index.ts +212 -0
  567. package/src/domain/quests/interpreter/dbscript/opcodes.ts +257 -0
  568. package/src/domain/quests/interpreter/dbscript/registry.ts +1194 -0
  569. package/src/domain/quests/interpreter/dbscript/registryAreatriggerScripts.ts +539 -0
  570. package/src/domain/quests/interpreter/dbscript/registryAshenvale.ts +553 -0
  571. package/src/domain/quests/interpreter/dbscript/registryAzshara.ts +272 -0
  572. package/src/domain/quests/interpreter/dbscript/registryDarkshore.ts +637 -0
  573. package/src/domain/quests/interpreter/dbscript/registryDesolace.ts +501 -0
  574. package/src/domain/quests/interpreter/dbscript/registryDustwallowMarsh.ts +735 -0
  575. package/src/domain/quests/interpreter/dbscript/registryFelwood.ts +493 -0
  576. package/src/domain/quests/interpreter/dbscript/registryFeralas.ts +485 -0
  577. package/src/domain/quests/interpreter/dbscript/registryGoScripts.ts +203 -0
  578. package/src/domain/quests/interpreter/dbscript/registryMoonglade.ts +288 -0
  579. package/src/domain/quests/interpreter/dbscript/registryNpcProfessions.ts +230 -0
  580. package/src/domain/quests/interpreter/dbscript/registryNpcsSpecial.ts +545 -0
  581. package/src/domain/quests/interpreter/dbscript/registryRazorfenDowns.ts +293 -0
  582. package/src/domain/quests/interpreter/dbscript/registryRazorfenKraul.ts +221 -0
  583. package/src/domain/quests/interpreter/dbscript/registryRedridge.ts +112 -0
  584. package/src/domain/quests/interpreter/dbscript/registrySearingGorge.ts +125 -0
  585. package/src/domain/quests/interpreter/dbscript/registrySiblings.ts +51 -0
  586. package/src/domain/quests/interpreter/dbscript/registrySilverpineForest.ts +172 -0
  587. package/src/domain/quests/interpreter/dbscript/registryStonetalonMountains.ts +180 -0
  588. package/src/domain/quests/interpreter/dbscript/registryStormwindCity.ts +369 -0
  589. package/src/domain/quests/interpreter/dbscript/registryStranglethornVale.ts +115 -0
  590. package/src/domain/quests/interpreter/dbscript/registryStratholme.ts +146 -0
  591. package/src/domain/quests/interpreter/dbscript/registrySunkenTemple.ts +149 -0
  592. package/src/domain/quests/interpreter/dbscript/registrySwampOfSorrows.ts +163 -0
  593. package/src/domain/quests/interpreter/dbscript/registryTanaris.ts +318 -0
  594. package/src/domain/quests/interpreter/dbscript/registryTeldrassil.ts +120 -0
  595. package/src/domain/quests/interpreter/dbscript/registryTheBarrens.ts +603 -0
  596. package/src/domain/quests/interpreter/dbscript/registryThousandNeedles.ts +499 -0
  597. package/src/domain/quests/interpreter/dbscript/registryTirisfalGlades.ts +222 -0
  598. package/src/domain/quests/interpreter/dbscript/registryUngoroCrater.ts +291 -0
  599. package/src/domain/quests/interpreter/dbscript/registryWarEffort.ts +773 -0
  600. package/src/domain/quests/interpreter/dbscript/registryWesternPlaguelands.ts +206 -0
  601. package/src/domain/quests/interpreter/dbscript/registryWestfall.ts +190 -0
  602. package/src/domain/quests/interpreter/dbscript/registryWetlands.ts +186 -0
  603. package/src/domain/quests/interpreter/dbscript/registryWinterspring.ts +235 -0
  604. package/src/domain/quests/interpreter/dbscript/types.ts +817 -0
  605. package/src/domain/quests/interpreter/dbscript/vm.ts +171 -0
  606. package/src/domain/quests/interpreter/diff.ts +349 -0
  607. package/src/domain/quests/interpreter/fixtures/oracle-questlog-qi2.json +2417 -0
  608. package/src/domain/quests/interpreter/flags.ts +101 -0
  609. package/src/domain/quests/interpreter/index.ts +113 -0
  610. package/src/domain/quests/interpreter/live.ts +178 -0
  611. package/src/domain/quests/interpreter/types.ts +81 -0
  612. package/src/domain/quests/interpreter/updaters.ts +412 -0
  613. package/src/domain/quests/questCorpseUse.ts +155 -0
  614. package/src/domain/quests/questDialog.ts +43 -0
  615. package/src/domain/quests/questFight.ts +34 -0
  616. package/src/domain/quests/questObjectiveLabel.ts +39 -0
  617. package/src/domain/quests/questRewardChoice.ts +85 -0
  618. package/src/domain/quests/questScriptCredit.ts +26 -0
  619. package/src/domain/quests/questScriptItemUse.ts +74 -0
  620. package/src/domain/quests/questTameRods.ts +79 -0
  621. package/src/domain/quests/questlog.ts +1545 -0
  622. package/src/domain/quests/talkDeliver.ts +170 -0
  623. package/src/domain/raid/globalActionBar.ts +195 -0
  624. package/src/domain/raid/instanceRoster.ts +194 -0
  625. package/src/domain/raid/partySummon.ts +198 -0
  626. package/src/domain/raid/partyVault.ts +320 -0
  627. package/src/domain/raid/raidBossAbilities.ts +268 -0
  628. package/src/domain/raid/raidComp.ts +335 -0
  629. package/src/domain/raid/raidCustomRows.ts +453 -0
  630. package/src/domain/raid/raidEncounterGear.ts +298 -0
  631. package/src/domain/raid/raidEncounterTactics.ts +412 -0
  632. package/src/domain/raid/raidPlan.ts +509 -0
  633. package/src/domain/raid/raidPlanEdits.ts +98 -0
  634. package/src/domain/raid/raidPlanPacks.ts +480 -0
  635. package/src/domain/raid/raidRecovery.ts +183 -0
  636. package/src/domain/raid/raidRosterTemplate.ts +138 -0
  637. package/src/domain/rotation/auraSide.ts +68 -0
  638. package/src/domain/rotation/build.ts +3031 -0
  639. package/src/domain/rotation/classes/druid.ts +58 -0
  640. package/src/domain/rotation/classes/hooks.ts +59 -0
  641. package/src/domain/rotation/classes/hunter.ts +156 -0
  642. package/src/domain/rotation/classes/index.ts +104 -0
  643. package/src/domain/rotation/classes/mage.ts +99 -0
  644. package/src/domain/rotation/classes/paladin.ts +296 -0
  645. package/src/domain/rotation/classes/priest.ts +94 -0
  646. package/src/domain/rotation/classes/pullOpener.ts +92 -0
  647. package/src/domain/rotation/classes/rogue.ts +69 -0
  648. package/src/domain/rotation/classes/shaman.ts +29 -0
  649. package/src/domain/rotation/classes/supportSpec.ts +94 -0
  650. package/src/domain/rotation/classes/warlock.ts +81 -0
  651. package/src/domain/rotation/classes/warrior.ts +402 -0
  652. package/src/domain/rotation/classify.ts +1381 -0
  653. package/src/domain/rotation/condMetricPicker.ts +379 -0
  654. package/src/domain/rotation/consumables.ts +242 -0
  655. package/src/domain/rotation/evaluate.ts +1115 -0
  656. package/src/domain/rotation/finisherLadder.ts +145 -0
  657. package/src/domain/rotation/invent.ts +938 -0
  658. package/src/domain/rotation/killOrder.ts +30 -0
  659. package/src/domain/rotation/openerForm.ts +22 -0
  660. package/src/domain/rotation/paladinBuffs.ts +568 -0
  661. package/src/domain/rotation/prebuff.ts +283 -0
  662. package/src/domain/rotation/raidRowTuning.ts +34 -0
  663. package/src/domain/rotation/rotation.ts +11 -0
  664. package/src/domain/rotation/rotationProfiles.ts +180 -0
  665. package/src/domain/rotation/rotationSuggestions.ts +851 -0
  666. package/src/domain/rotation/ruleAfterChain.ts +49 -0
  667. package/src/domain/rotation/spec.ts +70 -0
  668. package/src/domain/rotation/spellPicker.ts +140 -0
  669. package/src/domain/rotation/stand.ts +143 -0
  670. package/src/domain/rotation/types.ts +718 -0
  671. package/src/domain/run/battlegroundRun.ts +111 -0
  672. package/src/domain/run/canEnterContext.ts +32 -0
  673. package/src/domain/run/death.ts +561 -0
  674. package/src/domain/run/deathRecovery.ts +289 -0
  675. package/src/domain/run/duel.ts +141 -0
  676. package/src/domain/run/dungeonRun.ts +344 -0
  677. package/src/domain/run/floorRest.ts +159 -0
  678. package/src/domain/run/honor.ts +557 -0
  679. package/src/domain/run/honorWeek.ts +180 -0
  680. package/src/domain/run/whenMap.ts +977 -0
  681. package/src/domain/scriptdev/arathiHighlands.ts +445 -0
  682. package/src/domain/scriptdev/ashenvale.ts +635 -0
  683. package/src/domain/scriptdev/ashenvaleHelpers.ts +1215 -0
  684. package/src/domain/scriptdev/azshara.ts +892 -0
  685. package/src/domain/scriptdev/blackfathomDeeps.ts +212 -0
  686. package/src/domain/scriptdev/blackrockDepths.ts +1434 -0
  687. package/src/domain/scriptdev/blackrockSpire.ts +730 -0
  688. package/src/domain/scriptdev/blackwingLair.ts +521 -0
  689. package/src/domain/scriptdev/burningSteppes.ts +62 -0
  690. package/src/domain/scriptdev/classFilter.ts +43 -0
  691. package/src/domain/scriptdev/darkshore.ts +817 -0
  692. package/src/domain/scriptdev/darkshoreHelpers.ts +853 -0
  693. package/src/domain/scriptdev/deadmines.ts +331 -0
  694. package/src/domain/scriptdev/desolace.ts +1239 -0
  695. package/src/domain/scriptdev/direMaul.ts +859 -0
  696. package/src/domain/scriptdev/durotar.ts +532 -0
  697. package/src/domain/scriptdev/dustwallowMarsh.ts +758 -0
  698. package/src/domain/scriptdev/dustwallowMarshHelpers.ts +994 -0
  699. package/src/domain/scriptdev/easternPlaguelands.ts +21 -0
  700. package/src/domain/scriptdev/felwood.ts +1055 -0
  701. package/src/domain/scriptdev/feralas.ts +571 -0
  702. package/src/domain/scriptdev/feralasHelpers.ts +838 -0
  703. package/src/domain/scriptdev/gnomeregan.ts +466 -0
  704. package/src/domain/scriptdev/guards.ts +616 -0
  705. package/src/domain/scriptdev/hinterlands.ts +693 -0
  706. package/src/domain/scriptdev/host.ts +1134 -0
  707. package/src/domain/scriptdev/index.ts +123 -0
  708. package/src/domain/scriptdev/instanceRaids.ts +136 -0
  709. package/src/domain/scriptdev/itemScripts.ts +37 -0
  710. package/src/domain/scriptdev/lochModan.ts +283 -0
  711. package/src/domain/scriptdev/maraudon.ts +232 -0
  712. package/src/domain/scriptdev/moltenCore.ts +831 -0
  713. package/src/domain/scriptdev/moonglade.ts +659 -0
  714. package/src/domain/scriptdev/moongladeHelpers.ts +933 -0
  715. package/src/domain/scriptdev/naxxramas.ts +577 -0
  716. package/src/domain/scriptdev/naxxramasConstruct.ts +364 -0
  717. package/src/domain/scriptdev/naxxramasFrostwyrm.ts +378 -0
  718. package/src/domain/scriptdev/naxxramasMilitary.ts +276 -0
  719. package/src/domain/scriptdev/npcProfessions.ts +1025 -0
  720. package/src/domain/scriptdev/npcsSpecial.ts +1379 -0
  721. package/src/domain/scriptdev/onyxiasLair.ts +313 -0
  722. package/src/domain/scriptdev/orgrimmar.ts +147 -0
  723. package/src/domain/scriptdev/professionSpecConsumer.ts +481 -0
  724. package/src/domain/scriptdev/razorfenDowns.ts +468 -0
  725. package/src/domain/scriptdev/razorfenKraul.ts +410 -0
  726. package/src/domain/scriptdev/redridgeMountains.ts +163 -0
  727. package/src/domain/scriptdev/registry.ts +945 -0
  728. package/src/domain/scriptdev/ruinsOfAhnqiraj.ts +356 -0
  729. package/src/domain/scriptdev/scarletMonastery.ts +713 -0
  730. package/src/domain/scriptdev/scholomance.ts +682 -0
  731. package/src/domain/scriptdev/searingGorge.ts +295 -0
  732. package/src/domain/scriptdev/shadowfangKeep.ts +181 -0
  733. package/src/domain/scriptdev/silithus.ts +71 -0
  734. package/src/domain/scriptdev/silverpineForest.ts +662 -0
  735. package/src/domain/scriptdev/stonetalonMountains.ts +493 -0
  736. package/src/domain/scriptdev/stormwindCity.ts +1192 -0
  737. package/src/domain/scriptdev/stranglethornVale.ts +402 -0
  738. package/src/domain/scriptdev/stratholme.ts +650 -0
  739. package/src/domain/scriptdev/stratholmeScripts.ts +505 -0
  740. package/src/domain/scriptdev/sunkenTemple.ts +736 -0
  741. package/src/domain/scriptdev/swampOfSorrows.ts +22 -0
  742. package/src/domain/scriptdev/tanaris.ts +391 -0
  743. package/src/domain/scriptdev/tanarisHelpers.ts +661 -0
  744. package/src/domain/scriptdev/teldrassil.ts +143 -0
  745. package/src/domain/scriptdev/templeOfAhnqiraj.ts +1013 -0
  746. package/src/domain/scriptdev/theBarrens.ts +709 -0
  747. package/src/domain/scriptdev/theBarrensHelpers.ts +1026 -0
  748. package/src/domain/scriptdev/thousandNeedles.ts +426 -0
  749. package/src/domain/scriptdev/thousandNeedlesHelpers.ts +566 -0
  750. package/src/domain/scriptdev/tirisfalGlades.ts +533 -0
  751. package/src/domain/scriptdev/types.ts +1026 -0
  752. package/src/domain/scriptdev/uldaman.ts +437 -0
  753. package/src/domain/scriptdev/ungoroCrater.ts +549 -0
  754. package/src/domain/scriptdev/ungoroCraterHelpers.ts +1006 -0
  755. package/src/domain/scriptdev/wailingCaverns.ts +700 -0
  756. package/src/domain/scriptdev/warEffort.ts +371 -0
  757. package/src/domain/scriptdev/westernPlaguelands.ts +952 -0
  758. package/src/domain/scriptdev/westfall.ts +767 -0
  759. package/src/domain/scriptdev/wetlands.ts +660 -0
  760. package/src/domain/scriptdev/winterspring.ts +482 -0
  761. package/src/domain/scriptdev/winterspringHelpers.ts +801 -0
  762. package/src/domain/scriptdev/worldBosses.ts +605 -0
  763. package/src/domain/scriptdev/zulfarrak.ts +478 -0
  764. package/src/domain/scriptdev/zulgurub.ts +1152 -0
  765. package/src/domain/sim/dummySim.ts +408 -0
  766. package/src/domain/sim/targetDummy.ts +29 -0
  767. package/src/domain/social/guild.ts +148 -0
  768. package/src/domain/spells/areaAuraMembership.ts +93 -0
  769. package/src/domain/spells/breakableStunCc.ts +166 -0
  770. package/src/domain/spells/cancelAura.ts +80 -0
  771. package/src/domain/spells/diminishing.ts +167 -0
  772. package/src/domain/spells/dispel.ts +149 -0
  773. package/src/domain/spells/dispelMechanic.ts +105 -0
  774. package/src/domain/spells/equipSpells.ts +247 -0
  775. package/src/domain/spells/learnOnTargets.ts +260 -0
  776. package/src/domain/spells/mergeSchoolImmunityFromRaw.ts +66 -0
  777. package/src/domain/spells/resurrectSpell.ts +124 -0
  778. package/src/domain/spells/spellPushback.ts +39 -0
  779. package/src/domain/spells/spellReflect.ts +43 -0
  780. package/src/domain/spells/stealth.ts +183 -0
  781. package/src/domain/spells/totem.ts +394 -0
  782. package/src/domain/spells/worldCast.ts +187 -0
  783. package/src/domain/world/areaTriggerLocks.ts +177 -0
  784. package/src/domain/world/dungeonMusic.ts +108 -0
  785. package/src/domain/world/gameEvents.ts +119 -0
  786. package/src/domain/world/gameObjects.ts +1030 -0
  787. package/src/domain/world/goEventFire.ts +234 -0
  788. package/src/domain/world/goMoTransport.ts +74 -0
  789. package/src/domain/world/goTransport.ts +81 -0
  790. package/src/domain/world/hazards.ts +459 -0
  791. package/src/domain/world/openLock.ts +398 -0
  792. package/src/domain/world/pools.ts +470 -0
  793. package/src/domain/world/restArea.ts +37 -0
  794. package/src/domain/world/weather.ts +201 -0
  795. package/src/domain/zoneAmbience.mjs +87 -0
  796. package/src/domain/zoneMusic.mjs +62 -0
  797. package/src/domain/zoneMusicQueue.mjs +7 -0
  798. package/src/engine/combat/activeSeal.ts +39 -0
  799. package/src/engine/combat/aiTargetResolve.ts +64 -0
  800. package/src/engine/combat/aoeCast.ts +289 -0
  801. package/src/engine/combat/applyConsumeAura.ts +74 -0
  802. package/src/engine/combat/applyDemonicSacrifice.ts +70 -0
  803. package/src/engine/combat/applyDispelMechanic.ts +30 -0
  804. package/src/engine/combat/applyHealEffect.ts +301 -0
  805. package/src/engine/combat/applySchoolAbsorb.ts +114 -0
  806. package/src/engine/combat/applySpell.ts +3169 -0
  807. package/src/engine/combat/applySpellLate.ts +2026 -0
  808. package/src/engine/combat/applySpellLateBag.ts +144 -0
  809. package/src/engine/combat/areaAura.ts +283 -0
  810. package/src/engine/combat/attackTable.ts +508 -0
  811. package/src/engine/combat/battleRez.ts +87 -0
  812. package/src/engine/combat/castAction.ts +36 -0
  813. package/src/engine/combat/castTargetGates.ts +75 -0
  814. package/src/engine/combat/castableKit.ts +68 -0
  815. package/src/engine/combat/ccMirrorUpgrade.ts +39 -0
  816. package/src/engine/combat/chainFan.ts +210 -0
  817. package/src/engine/combat/channel.ts +423 -0
  818. package/src/engine/combat/chaseCloseness.ts +95 -0
  819. package/src/engine/combat/cmangosCastGates.ts +129 -0
  820. package/src/engine/combat/combatFlag.ts +77 -0
  821. package/src/engine/combat/combatGeometry.ts +1329 -0
  822. package/src/engine/combat/combustion.ts +68 -0
  823. package/src/engine/combat/context.ts +730 -0
  824. package/src/engine/combat/createSession.ts +1165 -0
  825. package/src/engine/combat/creatureLeash.ts +38 -0
  826. package/src/engine/combat/crowdControl.ts +1259 -0
  827. package/src/engine/combat/dispelDuty.ts +54 -0
  828. package/src/engine/combat/dispelReflect.ts +609 -0
  829. package/src/engine/combat/displaceFloor.ts +113 -0
  830. package/src/engine/combat/duelBounds.ts +47 -0
  831. package/src/engine/combat/equippedItemGate.ts +297 -0
  832. package/src/engine/combat/evadeLifecycle.ts +63 -0
  833. package/src/engine/combat/eventAi.ts +2220 -0
  834. package/src/engine/combat/facing.ts +74 -0
  835. package/src/engine/combat/fearFlee.ts +137 -0
  836. package/src/engine/combat/fighterFactory.ts +1144 -0
  837. package/src/engine/combat/forceDespawnAction.ts +51 -0
  838. package/src/engine/combat/formulas.ts +807 -0
  839. package/src/engine/combat/friendlyFan.ts +55 -0
  840. package/src/engine/combat/groundPoolTick.ts +210 -0
  841. package/src/engine/combat/hazards.ts +114 -0
  842. package/src/engine/combat/healTargets.ts +226 -0
  843. package/src/engine/combat/heartbeatResist.ts +49 -0
  844. package/src/engine/combat/hostileItemUse.ts +75 -0
  845. package/src/engine/combat/interruptAuras.ts +66 -0
  846. package/src/engine/combat/itemRescue.ts +49 -0
  847. package/src/engine/combat/itemUse.ts +260 -0
  848. package/src/engine/combat/kitById.ts +23 -0
  849. package/src/engine/combat/knockBack.ts +201 -0
  850. package/src/engine/combat/leap.ts +89 -0
  851. package/src/engine/combat/mitigation.ts +587 -0
  852. package/src/engine/combat/modTaunt.ts +43 -0
  853. package/src/engine/combat/motionClaim.ts +268 -0
  854. package/src/engine/combat/motionGate.ts +150 -0
  855. package/src/engine/combat/motionGenerator.ts +27 -0
  856. package/src/engine/combat/navSight.ts +138 -0
  857. package/src/engine/combat/perfSink.ts +28 -0
  858. package/src/engine/combat/periodicDue.ts +55 -0
  859. package/src/engine/combat/petAuras.ts +86 -0
  860. package/src/engine/combat/pickAction.ts +3449 -0
  861. package/src/engine/combat/powerForm.ts +735 -0
  862. package/src/engine/combat/powerPools.ts +49 -0
  863. package/src/engine/combat/predicates.ts +538 -0
  864. package/src/engine/combat/procsDamage.ts +1823 -0
  865. package/src/engine/combat/reactiveGates.ts +135 -0
  866. package/src/engine/combat/roles.ts +389 -0
  867. package/src/engine/combat/rotationSubject.ts +78 -0
  868. package/src/engine/combat/ruleAfter.ts +106 -0
  869. package/src/engine/combat/seedDisplayAuras.ts +203 -0
  870. package/src/engine/combat/seedOocEffects.ts +317 -0
  871. package/src/engine/combat/seedSchoolImmunities.ts +45 -0
  872. package/src/engine/combat/session-types.ts +458 -0
  873. package/src/engine/combat/sessionApi.ts +1785 -0
  874. package/src/engine/combat/sessionCharge.ts +124 -0
  875. package/src/engine/combat/sessionChase.ts +869 -0
  876. package/src/engine/combat/sessionDeaths.ts +469 -0
  877. package/src/engine/combat/sessionFocus.ts +506 -0
  878. package/src/engine/combat/sessionFollow.ts +167 -0
  879. package/src/engine/combat/sessionGeometry.ts +188 -0
  880. package/src/engine/combat/sessionHome.ts +75 -0
  881. package/src/engine/combat/sessionInject.ts +719 -0
  882. package/src/engine/combat/sessionMechanicMove.ts +555 -0
  883. package/src/engine/combat/sessionMotion.ts +1553 -0
  884. package/src/engine/combat/sessionMotionKit.ts +247 -0
  885. package/src/engine/combat/sessionMoveIntent.ts +49 -0
  886. package/src/engine/combat/sessionMoveOrder.ts +200 -0
  887. package/src/engine/combat/sessionPathTo.ts +657 -0
  888. package/src/engine/combat/sessionPools.ts +688 -0
  889. package/src/engine/combat/sessionSteer.ts +144 -0
  890. package/src/engine/combat/sessionStepBack.ts +151 -0
  891. package/src/engine/combat/sessionStepWrap.ts +116 -0
  892. package/src/engine/combat/sessionView.ts +442 -0
  893. package/src/engine/combat/simEffects.ts +32 -0
  894. package/src/engine/combat/simFacade.ts +620 -0
  895. package/src/engine/combat/simPump.ts +264 -0
  896. package/src/engine/combat/simSnapshot.ts +165 -0
  897. package/src/engine/combat/simThreadPool.ts +35 -0
  898. package/src/engine/combat/skeramImages.ts +57 -0
  899. package/src/engine/combat/slowUpdate.ts +178 -0
  900. package/src/engine/combat/snapToFloor.ts +42 -0
  901. package/src/engine/combat/speedAura.ts +29 -0
  902. package/src/engine/combat/spellReagents.ts +71 -0
  903. package/src/engine/combat/stealthDetect.ts +93 -0
  904. package/src/engine/combat/stepBackMotion.ts +171 -0
  905. package/src/engine/combat/stepSession.ts +3102 -0
  906. package/src/engine/combat/summonCast.ts +243 -0
  907. package/src/engine/combat/thaddiusPolarity.ts +97 -0
  908. package/src/engine/combat/threat.ts +388 -0
  909. package/src/engine/combat/threatOps.ts +272 -0
  910. package/src/engine/combat/tickWorkerBody.ts +211 -0
  911. package/src/engine/combat/totemAuras.ts +122 -0
  912. package/src/engine/combat/triggerPayload.ts +42 -0
  913. package/src/engine/combat/triggeredCompanion.ts +63 -0
  914. package/src/engine/combat/types.ts +1897 -0
  915. package/src/engine/combat/useObjectTrap.ts +140 -0
  916. package/src/engine/combat/walkSurface.ts +54 -0
  917. package/src/engine/combat/weapon.ts +1248 -0
  918. package/src/engine/combat/worldFrame.ts +43 -0
  919. package/src/engine/combat.solo-golden.json +2242 -0
  920. package/src/engine/combat.ts +84 -0
  921. package/src/engine/core/detMath.ts +289 -0
  922. package/src/engine/core/events.ts +357 -0
  923. package/src/engine/core/rng.ts +36 -0
  924. package/src/engine/game/actionBars.ts +124 -0
  925. package/src/engine/game/activateObjectFromSpell.ts +320 -0
  926. package/src/engine/game/applyActiveObjectFromDbScript.ts +98 -0
  927. package/src/engine/game/applyDeathAndSpellScripts.ts +268 -0
  928. package/src/engine/game/applyDoorFromDbScript.ts +388 -0
  929. package/src/engine/game/applyEscortFromDbScript.ts +290 -0
  930. package/src/engine/game/applyGoSpellcasterCast.ts +40 -0
  931. package/src/engine/game/applyHoverFromDbScript.ts +110 -0
  932. package/src/engine/game/applyOocResurrect.ts +71 -0
  933. package/src/engine/game/applySpellDurability.ts +115 -0
  934. package/src/engine/game/applyTailFromDbScript.ts +478 -0
  935. package/src/engine/game/applyTerminateFromDbScript.ts +95 -0
  936. package/src/engine/game/auctionMail.ts +269 -0
  937. package/src/engine/game/bagOverflow.ts +130 -0
  938. package/src/engine/game/bank.ts +110 -0
  939. package/src/engine/game/barAutofill.ts +26 -0
  940. package/src/engine/game/battleground.ts +369 -0
  941. package/src/engine/game/combatEnemySpec.ts +216 -0
  942. package/src/engine/game/combatObjectTraps.ts +167 -0
  943. package/src/engine/game/combatSeed.ts +320 -0
  944. package/src/engine/game/combatSession.ts +3241 -0
  945. package/src/engine/game/combatSessionClock.ts +62 -0
  946. package/src/engine/game/companionQuestActions.ts +242 -0
  947. package/src/engine/game/companionTraining.ts +40 -0
  948. package/src/engine/game/consumables.ts +624 -0
  949. package/src/engine/game/context.ts +53 -0
  950. package/src/engine/game/createHealthstone.ts +45 -0
  951. package/src/engine/game/deathFlow.ts +127 -0
  952. package/src/engine/game/devGrant.ts +117 -0
  953. package/src/engine/game/dynamicObject.ts +365 -0
  954. package/src/engine/game/enchantItem.ts +177 -0
  955. package/src/engine/game/equipmentHelper.ts +806 -0
  956. package/src/engine/game/factionState.ts +108 -0
  957. package/src/engine/game/garmentsOoc.ts +69 -0
  958. package/src/engine/game/globalActionBar.ts +179 -0
  959. package/src/engine/game/goInteractQuestCredit.ts +51 -0
  960. package/src/engine/game/goMoTransportUse.ts +61 -0
  961. package/src/engine/game/goTransportUse.ts +60 -0
  962. package/src/engine/game/healthstoneUpkeep.ts +59 -0
  963. package/src/engine/game/killSkin.ts +124 -0
  964. package/src/engine/game/learnFromItem.ts +118 -0
  965. package/src/engine/game/loadReconcile.ts +551 -0
  966. package/src/engine/game/loot.ts +294 -0
  967. package/src/engine/game/macros.ts +125 -0
  968. package/src/engine/game/mail.ts +293 -0
  969. package/src/engine/game/meetingStoneQueue.ts +75 -0
  970. package/src/engine/game/meetingStoneUse.ts +54 -0
  971. package/src/engine/game/newGameState.ts +363 -0
  972. package/src/engine/game/oocAuraInterrupt.ts +92 -0
  973. package/src/engine/game/oocCast.ts +241 -0
  974. package/src/engine/game/oocCastEffects.ts +803 -0
  975. package/src/engine/game/oocCastMethods.ts +1077 -0
  976. package/src/engine/game/openLockFromSpell.ts +174 -0
  977. package/src/engine/game/outdoorPvp.ts +187 -0
  978. package/src/engine/game/outfitter.ts +644 -0
  979. package/src/engine/game/paladinBoard.ts +393 -0
  980. package/src/engine/game/partyAuras.ts +121 -0
  981. package/src/engine/game/persistCombatAuras.ts +91 -0
  982. package/src/engine/game/pets.ts +459 -0
  983. package/src/engine/game/prePullHealthstones.ts +154 -0
  984. package/src/engine/game/professions.ts +2227 -0
  985. package/src/engine/game/pruneStaleRuntime.ts +39 -0
  986. package/src/engine/game/questFightCredit.ts +36 -0
  987. package/src/engine/game/questItemOnCorpse.ts +166 -0
  988. package/src/engine/game/questItemOnCreature.ts +103 -0
  989. package/src/engine/game/questLog.ts +879 -0
  990. package/src/engine/game/questSpellComplete.ts +21 -0
  991. package/src/engine/game/questTurnIn.ts +598 -0
  992. package/src/engine/game/raidParty.ts +32 -0
  993. package/src/engine/game/reagentBuyIn.ts +39 -0
  994. package/src/engine/game/regenRest.ts +186 -0
  995. package/src/engine/game/rotation.ts +425 -0
  996. package/src/engine/game/runFullDungeon.ts +611 -0
  997. package/src/engine/game/runSupply.ts +80 -0
  998. package/src/engine/game/sanitizeDriftedItems.ts +135 -0
  999. package/src/engine/game/scriptDevGoUse.ts +397 -0
  1000. package/src/engine/game/scriptDevInnkeeper.ts +150 -0
  1001. package/src/engine/game/scriptDevProfessionSpec.ts +99 -0
  1002. package/src/engine/game/sendEventFromSpell.ts +87 -0
  1003. package/src/engine/game/sortContainers.ts +85 -0
  1004. package/src/engine/game/spellReputation.ts +69 -0
  1005. package/src/engine/game/squad/squadDungeonSeating.ts +197 -0
  1006. package/src/engine/game/squadDungeon.ts +4222 -0
  1007. package/src/engine/game/squadQuestShare.ts +260 -0
  1008. package/src/engine/game/summonObjectSlot.ts +69 -0
  1009. package/src/engine/game/summonObjectWild.ts +73 -0
  1010. package/src/engine/game/summoningRitualUse.ts +141 -0
  1011. package/src/engine/game/talents.ts +356 -0
  1012. package/src/engine/game/tamingRodUse.ts +66 -0
  1013. package/src/engine/game/tempSpawnCreature.ts +136 -0
  1014. package/src/engine/game/tickDungeonPatrolScripts.ts +205 -0
  1015. package/src/engine/game/tickTimedQuests.ts +28 -0
  1016. package/src/engine/game/trade.ts +586 -0
  1017. package/src/engine/game/travel.ts +1155 -0
  1018. package/src/engine/game/types.ts +1855 -0
  1019. package/src/engine/game/vendorOffers.ts +244 -0
  1020. package/src/engine/game/weaponImbueItem.ts +281 -0
  1021. package/src/engine/game/worldSession.ts +183 -0
  1022. package/src/engine/game/worldState.ts +166 -0
  1023. package/src/engine/game.ts +968 -0
  1024. package/src/engine/spells/GAP.md +67 -0
  1025. package/src/engine/spells/absorbs.ts +376 -0
  1026. package/src/engine/spells/addTargetTrigger.ts +53 -0
  1027. package/src/engine/spells/auraPerSecondCost.ts +50 -0
  1028. package/src/engine/spells/auraStacking.ts +497 -0
  1029. package/src/engine/spells/cmangos-special-cases-combustion.ts +13 -0
  1030. package/src/engine/spells/cmangos-special-cases-s18.ts +445 -0
  1031. package/src/engine/spells/cmangos-special-cases-s19.ts +324 -0
  1032. package/src/engine/spells/cmangos-special-cases-s20.ts +385 -0
  1033. package/src/engine/spells/cmangos-special-cases-s21.ts +116 -0
  1034. package/src/engine/spells/cmangos-special-cases-s22.ts +24 -0
  1035. package/src/engine/spells/cmangos-special-cases-s23.ts +52 -0
  1036. package/src/engine/spells/cmangos-special-cases.ts +1570 -0
  1037. package/src/engine/spells/conditions.ts +718 -0
  1038. package/src/engine/spells/effects.ts +203 -0
  1039. package/src/engine/spells/fixtures/oracle-effects-lock9a.json +1 -0
  1040. package/src/engine/spells/immunity.ts +942 -0
  1041. package/src/engine/spells/intentEffects.ts +351 -0
  1042. package/src/engine/spells/interpreter/GAPS.md +21 -0
  1043. package/src/engine/spells/interpreter/amount.ts +109 -0
  1044. package/src/engine/spells/interpreter/class-scripts-kit.ts +478 -0
  1045. package/src/engine/spells/interpreter/diff.ts +246 -0
  1046. package/src/engine/spells/interpreter/duration.ts +90 -0
  1047. package/src/engine/spells/interpreter/form-attrs.json +1 -0
  1048. package/src/engine/spells/interpreter/form-attrs.ts +95 -0
  1049. package/src/engine/spells/interpreter/handlers/aura-remove.ts +113 -0
  1050. package/src/engine/spells/interpreter/handlers/auras-control.ts +1547 -0
  1051. package/src/engine/spells/interpreter/handlers/auras-core.ts +1583 -0
  1052. package/src/engine/spells/interpreter/handlers/auras-misc.ts +1774 -0
  1053. package/src/engine/spells/interpreter/handlers/effect-disenchant.ts +17 -0
  1054. package/src/engine/spells/interpreter/handlers/effect-skinning.ts +20 -0
  1055. package/src/engine/spells/interpreter/handlers/effects-action-utility.ts +1455 -0
  1056. package/src/engine/spells/interpreter/handlers/effects-core.ts +432 -0
  1057. package/src/engine/spells/interpreter/handlers/effects-remainder.ts +105 -0
  1058. package/src/engine/spells/interpreter/handlers/effects-summon-item.ts +687 -0
  1059. package/src/engine/spells/interpreter/handlers/mod-increase-energy-percent.ts +36 -0
  1060. package/src/engine/spells/interpreter/handlers/mod-increase-energy.ts +36 -0
  1061. package/src/engine/spells/interpreter/handlers/periodic-trigger-zero.ts +327 -0
  1062. package/src/engine/spells/interpreter/handlers/procs.ts +131 -0
  1063. package/src/engine/spells/interpreter/handlers/registry.ts +975 -0
  1064. package/src/engine/spells/interpreter/handlers/shared.ts +667 -0
  1065. package/src/engine/spells/interpreter/handlers.ts +9 -0
  1066. package/src/engine/spells/interpreter/hunter-kit.ts +202 -0
  1067. package/src/engine/spells/interpreter/index.ts +525 -0
  1068. package/src/engine/spells/interpreter/item-triggers.ts +1121 -0
  1069. package/src/engine/spells/interpreter/lift-consume-energy.ts +37 -0
  1070. package/src/engine/spells/interpreter/mage-portals.ts +37 -0
  1071. package/src/engine/spells/interpreter/materialize-charm.ts +88 -0
  1072. package/src/engine/spells/interpreter/materialize-dispel-mechanic.ts +25 -0
  1073. package/src/engine/spells/interpreter/materialize-rating-buffs.ts +81 -0
  1074. package/src/engine/spells/interpreter/materialize-speed.ts +109 -0
  1075. package/src/engine/spells/interpreter/materialize-spell-magnet.ts +22 -0
  1076. package/src/engine/spells/interpreter/opcodes.ts +789 -0
  1077. package/src/engine/spells/interpreter/paladin-kit.ts +95 -0
  1078. package/src/engine/spells/interpreter/paladin-seals.ts +499 -0
  1079. package/src/engine/spells/interpreter/priest-warlock-kit.ts +135 -0
  1080. package/src/engine/spells/interpreter/proc.ts +443 -0
  1081. package/src/engine/spells/interpreter/registry/helpers.ts +16 -0
  1082. package/src/engine/spells/interpreter/registry/index.ts +216 -0
  1083. package/src/engine/spells/interpreter/registry/packs/class-abilities.ts +304 -0
  1084. package/src/engine/spells/interpreter/registry/packs/dire-maul.ts +44 -0
  1085. package/src/engine/spells/interpreter/registry/packs/dummy-script-tail.ts +1065 -0
  1086. package/src/engine/spells/interpreter/registry/packs/ex-wave-m.ts +70 -0
  1087. package/src/engine/spells/interpreter/registry/packs/life-tap.ts +107 -0
  1088. package/src/engine/spells/interpreter/registry/packs/paladin.ts +246 -0
  1089. package/src/engine/spells/interpreter/registry/packs/raid-script-summons.ts +26 -0
  1090. package/src/engine/spells/interpreter/registry/packs/raid-threat-wipe.ts +22 -0
  1091. package/src/engine/spells/interpreter/registry/packs/shaman.ts +214 -0
  1092. package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-a.ts +269 -0
  1093. package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-b.ts +370 -0
  1094. package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-c.ts +172 -0
  1095. package/src/engine/spells/interpreter/registry/packs/si4-dummy-hunter.ts +60 -0
  1096. package/src/engine/spells/interpreter/registry/packs/si4-dummy-warrior.ts +31 -0
  1097. package/src/engine/spells/interpreter/registry/packs/si4-script-generic-a.ts +114 -0
  1098. package/src/engine/spells/interpreter/registry/packs/si4-script-generic-b.ts +249 -0
  1099. package/src/engine/spells/interpreter/registry/packs/si4-script-generic-c.ts +233 -0
  1100. package/src/engine/spells/interpreter/registry/types.ts +48 -0
  1101. package/src/engine/spells/interpreter/talent-scripted.ts +329 -0
  1102. package/src/engine/spells/interpreter/targeting.ts +90 -0
  1103. package/src/engine/spells/interpreter/types-intent.ts +1501 -0
  1104. package/src/engine/spells/interpreter/types.ts +419 -0
  1105. package/src/engine/spells/interpreter/warrior-druid-kit.ts +314 -0
  1106. package/src/engine/spells/interpreter/weapon-imbue-kit.ts +384 -0
  1107. package/src/engine/spells/interrupt.ts +217 -0
  1108. package/src/engine/spells/meleeAttackReset.ts +60 -0
  1109. package/src/engine/spells/oocTriggerSpell.ts +49 -0
  1110. package/src/engine/spells/overrideClassScripts.ts +319 -0
  1111. package/src/engine/spells/parryHaste.ts +20 -0
  1112. package/src/engine/spells/procs.ts +300 -0
  1113. package/src/engine/spells/recovery.ts +203 -0
  1114. package/src/engine/spells/scriptTargetDest.ts +76 -0
  1115. package/src/engine/spells/scriptTargets.ts +85 -0
  1116. package/src/engine/spells/singleTargetAura.ts +82 -0
  1117. package/src/engine/spells/specialCasePayloads.ts +57 -0
  1118. package/src/engine/spells/spellAttributes.ts +249 -0
  1119. package/src/engine/spells/spellBonus.ts +143 -0
  1120. package/src/engine/spells/spellModIntents.ts +419 -0
  1121. package/src/engine/spells/spellMods.ts +283 -0
  1122. package/src/engine/spells/spellRadius.ts +15 -0
  1123. package/src/engine/spells/trapObjects.ts +26 -0
  1124. package/src/json.d.ts +7 -0
  1125. package/src/persistence/memoryRepository.ts +23 -0
  1126. package/src/persistence/saveBackups.ts +162 -0
  1127. package/src/persistence/saveFile.ts +68 -0
  1128. package/src/persistence/saveRepository.ts +11 -0
  1129. package/src/persistence/saveShape.ts +668 -0
  1130. package/src/progression/leveling.ts +89 -0
  1131. package/src/progression/rest.ts +1434 -0
  1132. package/src/progression/training.ts +226 -0
  1133. package/src/progression/xpGain.ts +369 -0
  1134. package/src/quests/quests.ts +1580 -0
  1135. package/src/world/aoePull.ts +198 -0
  1136. package/src/world/areaTableDbc.ts +39 -0
  1137. package/src/world/areaTableMapIdPairs.ts +141 -0
  1138. package/src/world/bakeSlug.ts +43 -0
  1139. package/src/world/continuousDungeon.ts +1250 -0
  1140. package/src/world/dungeonBakeProjection.ts +190 -0
  1141. package/src/world/dungeonMapDensity.ts +342 -0
  1142. package/src/world/dungeonMapProjection.ts +135 -0
  1143. package/src/world/dungeonNav.ts +904 -0
  1144. package/src/world/dungeonNpcSim.ts +147 -0
  1145. package/src/world/dungeonObjectives.ts +234 -0
  1146. package/src/world/dungeonPreset.ts +35 -0
  1147. package/src/world/dungeonRoute.ts +311 -0
  1148. package/src/world/dungeonRouteGeometry.ts +732 -0
  1149. package/src/world/dungeonRouteOrder.ts +1037 -0
  1150. package/src/world/dungeonWingRooms.ts +426 -0
  1151. package/src/world/escort.ts +296 -0
  1152. package/src/world/goPoolRoll.ts +75 -0
  1153. package/src/world/instanceObjects.ts +785 -0
  1154. package/src/world/instances.ts +1572 -0
  1155. package/src/world/meetingStones.ts +56 -0
  1156. package/src/world/navmeshFloors.ts +145 -0
  1157. package/src/world/nearGrid.ts +167 -0
  1158. package/src/world/packBudget.ts +613 -0
  1159. package/src/world/rareRoll.ts +335 -0
  1160. package/src/world/routeAuthoring.ts +132 -0
  1161. package/src/world/routeEdit.ts +209 -0
  1162. package/src/world/world.ts +812 -0
  1163. package/src/world/zoneHazards.ts +47 -0
@@ -0,0 +1,3102 @@
1
+ // Peeled from createSession (Wave 4 hub: stepSession). Pattern (D). Extracted modules must not import
2
+ // combat.ts. NOTE: imports may need expanding when first run against seam tests.
3
+
4
+ import { createItemRescue } from './itemRescue.ts';
5
+ import { clearCombustion } from './combustion.ts';
6
+ import { COMBUSTION_SPELL_ID, COMBUSTION_STACK_SPELL_ID } from '../spells/cmangos-special-cases-combustion.ts';
7
+ import { CURSE_OF_IDIOCY_SPELL_ID, curseOfIdiocyMayRetriggerFromBuffs } from '../spells/cmangos-special-cases.ts';
8
+ import type { CombatContext, EnemyCastState } from './context.ts';
9
+ import type { Fighter, ActiveEffect, CombatAction } from './types.ts';
10
+
11
+ // True when this unit still holds another live aura row for the SAME spell. `buffActive` is keyed
12
+ // by spell id, so an expiring row must not clear the id out from under a sibling that is still up.
13
+ function hasOtherLiveAuraRow(
14
+ effects: readonly ActiveEffect[],
15
+ expiring: ActiveEffect,
16
+ ): boolean {
17
+ return effects.some((o) => (
18
+ o !== expiring
19
+ && o.spellId === expiring.spellId
20
+ && o.target === expiring.target
21
+ && o.expiresAt > expiring.expiresAt
22
+ ));
23
+ }
24
+ import type { QueuedCast } from './session-types.ts';
25
+ import { channelInterruptCtx, interruptChannel, maybeCancelChannelOnAction } from './channel.ts';
26
+ import { dropFeignDeath, expireCombatFlag, fighterIsInCombat, maybeAggroZonePulse, touchCombatFlag } from './combatFlag.ts';
27
+ import { emptySlowDiag, hardCcMustDecide, nextDecideWakeAt, slowEngaged, wakeForDecision } from './slowUpdate.ts';
28
+ import { installStepWrap } from './sessionStepWrap.ts';
29
+ import { combatPerf, combatPerfEnter, combatPerfLeave } from './perfSink.ts';
30
+ import type { SpellDef, PowerType } from '../../data/schema.ts';
31
+ import type { Side, EnemySide, CombatEvent } from '../core/events.ts';
32
+ import { isEnemySide, isAllySide, isPlayerCharacterSide, isTotemSide } from '../core/events.ts';
33
+ import {
34
+ TUNING, rollBetween, BASE_ATTACK_TIME_MS, spellCritChance, scaleTime,
35
+ critMultiplierWithVersus,
36
+ } from './formulas.ts';
37
+ import {
38
+ fighterSpellCrit,
39
+ reverseBuffMod,
40
+ applyResistMod,
41
+ reverseMechanicResist,
42
+ canPayPower,
43
+ } from './fighterFactory.ts';
44
+ import { roleOf, nextCastModApplies } from './roles.ts';
45
+ import { maxPowerOfType, powerOfType, powerTypeName, setPowerOfType } from './powerPools.ts';
46
+ import { POWER_MANA } from '../spells/effects.ts';
47
+ import {
48
+ curseOfAgonyTickAmount, isCurseOfAgonySpellId, isHealingWayTalentSpellId,
49
+ } from '../spells/interpreter/class-scripts-kit.ts';
50
+ import {
51
+ applyOwnedSpellMod, stripOwnedSpellMods,
52
+ SPELLMOD_COST, SPELLMOD_CASTING_TIME, SPELLMOD_COOLDOWN,
53
+ } from '../spells/spellModIntents.ts';
54
+ import { SPELL_ATTR_EX2, SPELL_ATTR_EX3, hasAttrEx2, hasAttrEx3 } from '../spells/spellAttributes.ts';
55
+ import {
56
+ isHostileSpellDef,
57
+ spellTargetTypeAllowed,
58
+ isCreatureRangedMainAttack,
59
+ spellNeedsRangeGate,
60
+ spellGateRangeMaxYd,
61
+ spellGateRangeMinYd,
62
+ spellFoeDirectedOpaque,
63
+ spellFoeDirectedGeometry,
64
+ spellNeedsFriendlyLosGate,
65
+ spellNeedsLosGate,
66
+ isSelfCenteredAoe,
67
+ isCasterOriginAoe,
68
+ focusImmunityBlocksSpell,
69
+ aoeTotalTargetCap,
70
+ nextSwingFanCap,
71
+ nextSwingFanRadiusYd,
72
+ rangedAutoMinYards,
73
+ wantsChainFan,
74
+ isNonCastableTalentProc,
75
+ isRangedAutoRepeatToggle,
76
+ rangedAutoSpellOf,
77
+ } from './predicates.ts';
78
+ import { selectHostileChainTargets, CHAIN_SPELL_JUMP_RADIUS_YD } from './chainFan.ts';
79
+ import { isFriendlyFannedEffect } from './friendlyFan.ts';
80
+ import { dispelOwnerThisTick } from './dispelDuty.ts';
81
+ import { playerMissingRequiredEquippedItem } from './equippedItemGate.ts';
82
+ import { canPaySpellReagents, takeSpellReagents } from './spellReagents.ts';
83
+ import { pickBattleRezTarget } from './battleRez.ts';
84
+ import { haltDpsCoversEntry, haltDpsRefusesSchool } from '../../domain/rotation/evaluate.ts';
85
+ import { reactiveCastGate, consumeReactiveWindow } from './reactiveGates.ts';
86
+ import { targetCastGate } from './castTargetGates.ts';
87
+ import { spellFromKit } from './kitById.ts';
88
+ import { payloadAtCasterLevel } from './triggerPayload.ts';
89
+
90
+ import { applySpellDamageBonusIfPresent } from '../spells/spellBonus.ts';
91
+ import { removeImmunityAura } from '../spells/immunity.ts';
92
+ import { resolveSpellBinary, SPELL_DAMAGE_CLASS } from './mitigation.ts';
93
+ import { resolvePeriodicPctOfMax } from '../spells/effects.ts';
94
+ import {
95
+ FRENZIED_REGEN_SPELL_IDS,
96
+ frenziedRegenLifePerRage,
97
+ frenziedRegenTick,
98
+ } from '../spells/interpreter/warrior-druid-kit.ts';
99
+ import {
100
+ NITROUS_BOOST_SPELL_ID,
101
+ NITROUS_MANA_COST_PER_TICK,
102
+ } from '../spells/interpreter/handlers/periodic-trigger-zero.ts';
103
+ import {
104
+ categoryCooldownMs, gcdIsHasted, isNextMeleeSwingSpell, personalCooldownMs, recoveryReadyAts,
105
+ cooldownStartsOnAuraFade, PERMANENT_COOLDOWN_READY_AT,
106
+ } from '../spells/recovery.ts';
107
+ import { maybeResetMeleeAttackTimersOnCast } from '../spells/meleeAttackReset.ts'; // Spell.cpp:3344
108
+ import { isPureDispelSpell, firstDispelEffect, dispelClaimKey } from '../../domain/spells/dispel.ts';
109
+ import { AURA_INTERRUPT, isStealthSpell, spellCancelsAutoAttack } from '../../domain/spells/stealth.ts';
110
+ import { navSightClear } from './navSight.ts';
111
+ import { installStealthDetect } from './stealthDetect.ts';
112
+ import { isFormSpell, isStealthShapeshift, resolveFormCast } from '../../domain/character/forms.ts';
113
+ import { tickGroundPools } from './groundPoolTick.ts';
114
+ import {
115
+ isWithinRadiusYd, isInSpellRange, combatDistanceYd, BASE_CHARGE_SPEED, missileTravelMs,
116
+ MELEE_CONTACT_YD, meleeContactYd, isWithinConeYd, coneArcOf, type CombatPosYd,
117
+ } from './combatGeometry.ts';
118
+ import { isItemCombatAction, combatActionSpellId, combatActionDest } from './castAction.ts';
119
+ import { isGroundTargetedMulti, isDispelSpell, isBattleRezSpell } from '../../domain/rotation/classify.ts';
120
+ import {
121
+ isCastInterruptible, isPacifyPrevented, isSilencePrevented, spellHasInterruptCast,
122
+ } from '../spells/interrupt.ts';
123
+ import { TAUNT_STATE, type CombatantId } from './threat.ts';
124
+ import { passesConditionGate } from '../spells/conditions.ts';
125
+ import {
126
+ CAST_FLAG,
127
+ markCreatureCastRowUsed,
128
+ countCreatureCastFire,
129
+ paceMainSpellSlot,
130
+ eventPhaseBlocked,
131
+ MIN_AGGRO_DISTANCE,
132
+ } from '../../domain/creature/creatureAi.ts';
133
+ import { SPELL_LIST_TARGET, TARGET_T } from '../../domain/creature/aiTargeting.ts';
134
+
135
+ // The spell-list selectors whose pool can be genuinely EMPTY, where the C++ then casts nothing.
136
+ // Cite: CreatureSpellList.h:34-44; UnitAI.cpp:1305-1330.
137
+ const SPELL_LIST_FRIENDLY_TARGETS = new Set<number>([
138
+ SPELL_LIST_TARGET.DISPELLABLE_FRIENDLY,
139
+ SPELL_LIST_TARGET.DISPELLABLE_FRIENDLY_NO_SELF,
140
+ SPELL_LIST_TARGET.FRIENDLY_MISSING_BUFF,
141
+ SPELL_LIST_TARGET.FRIENDLY_MISSING_BUFF_NO_SELF,
142
+ ]);
143
+ import { ALLY_HEAL_THRESHOLD } from './types.ts';
144
+ import { applyRuleAfter, clearStepBack, commitRuleAfter, isStepBackWalking } from './ruleAfter.ts';
145
+ import { isNovaKiteSpell, NOVA_KITE_NEAR_YD, NOVA_KITE_STEP_BACK_YD } from '../../domain/rotation/classify.ts';
146
+ import { installSessionMotion } from './sessionMotion.ts';
147
+ import { createDuelBounds } from './duelBounds.ts';
148
+ import { hasAttrEx, SPELL_ATTR_EX } from '../spells/spellAttributes.ts';
149
+
150
+ /** Locals from createSession that step still closes over (not already on ctx). */
151
+ export type installStepSessionDeps = {
152
+ dead: (...args: any[]) => any;
153
+ sanitizeHp: (...args: any[]) => any;
154
+ spend: (...args: any[]) => any;
155
+ gainRage: (...args: any[]) => any;
156
+ addComboPoints: (...args: any[]) => any;
157
+ clearComboPoints: (...args: any[]) => any;
158
+ comboOn: (...args: any[]) => any;
159
+ foeOf: (...args: any[]) => any;
160
+ ensurePlayerFocus: (...args: any[]) => any;
161
+ hostileTargetOf: (...args: any[]) => any;
162
+ isWeaponAction: (...args: any[]) => any;
163
+ isBreaker: (...args: any[]) => any;
164
+ spellCost: (...args: any[]) => any;
165
+ allySnapshot: (...args: any[]) => any;
166
+ castOnTargets: (...args: any[]) => any;
167
+ useItem: (...args: any[]) => any;
168
+ enterForm: (...args: any[]) => any;
169
+ applyStealth: (...args: any[]) => any;
170
+ maybeBreakStealthOn: (...args: any[]) => any;
171
+ checkDeaths: (...args: any[]) => any;
172
+ finalize: (...args: any[]) => any;
173
+ unsummonTotem: (...args: any[]) => any;
174
+ preferFocusHostile: (...args: any[]) => any;
175
+ allEnemiesDead: (...args: any[]) => any;
176
+ regenRage: (...args: any[]) => any;
177
+ regenEnergy: (...args: any[]) => any;
178
+ regenMana: (...args: any[]) => any;
179
+ regenFocus: (...args: any[]) => any;
180
+ regenHealth: (...args: any[]) => any;
181
+ };
182
+
183
+ export function installStepSession(ctx: CombatContext, deps: installStepSessionDeps): void {
184
+ // E3.6. Earliest tick a cast bar in this session can land. 0 seeds it OPEN, so the first tick walks and
185
+ // stamps the real value rather than trusting an assumption at construction.
186
+ let castFinishDue = 0;
187
+ // Hubs defined in OTHER peels (not re-declared as const in this module).
188
+ const dealDamage = (...args: any[]) => (ctx.dealDamage as Function)(...args);
189
+ const applySpell = (...args: any[]) => (ctx.applySpell as Function)(...args);
190
+ const pickAction = (...args: any[]) => (ctx.pickAction as Function)(...args);
191
+ // startOrCast / step / runMotionPhase / creatureRanged* are DEFINED below — do not alias.
192
+ const onDamaged = (...args: any[]) => (ctx.onDamaged as Function)(...args);
193
+ const breakControl = (...args: any[]) => (ctx.breakControl as Function)(...args);
194
+ const breakCharm = (...args: any[]) => (ctx.breakCharm as Function)(...args);
195
+ const leaveForm = (...args: any[]) => (ctx.leaveForm as Function)(...args);
196
+ const breakStealth = (...args: any[]) => (ctx.breakStealth as Function)(...args);
197
+ const applyEventAiIntent = (...args: any[]) => (ctx.applyEventAiIntent as Function)(...args);
198
+ const resolveEventAiCastTarget = (...args: any[]) => (ctx.resolveEventAiCastTarget as Function)(...args);
199
+ // Wave 4 fix-up: createSession/pickAction closures the peel left as bare identifiers.
200
+ const fighterByThreatId = (...args: any[]) => (ctx.fighterByThreatId as Function)(...args);
201
+ const selectAoeSecondaries = (...args: any[]) => (ctx.selectAoeSecondaries as Function)(...args);
202
+ const aoeUnitInRadius = (...args: any[]) => (ctx.aoeUnitInRadius as Function)(...args);
203
+ const conditionSnapshotFor = (...args: any[]) => (ctx.conditionSnapshotFor as Function)(...args);
204
+ const runEnemyEventAi = (...args: any[]) => (ctx.runEnemyEventAi as Function)(...args);
205
+ const runEnemyDeathEventAi = (...args: any[]) => (ctx.runEnemyDeathEventAi as Function)(...args);
206
+ const applyProc = (...args: any[]) => (ctx.applyProc as Function)(...args);
207
+ const triggerProcs = (...args: any[]) => (ctx.triggerProcs as Function)(...args);
208
+ const rollGearProcs = (...args: any[]) => (ctx.rollGearProcs as Function)(...args);
209
+ const fireDamageShields = (...args: any[]) => (ctx.fireDamageShields as Function)(...args);
210
+ const fireDealMeleeAura42 = (...args: any[]) => (ctx.fireDealMeleeAura42 as Function)(...args);
211
+ const weaponStrike = (...args: any[]) => (ctx.weaponStrike as Function)(...args);
212
+ const rangedStrike = (...args: any[]) => (ctx.rangedStrike as Function)(...args);
213
+ const fighterDualWield = (...args: any[]) => (ctx.fighterDualWield as Function)(...args);
214
+ const tryMeleeSkillGains = (...args: any[]) => (ctx.tryMeleeSkillGains as Function)(...args);
215
+ const isPlayerOrPetSide = (...args: any[]) => (ctx.isPlayerOrPetSide as Function)(...args);
216
+ const onCooldown = (...args: any[]) => (ctx.onCooldown as Function)(...args);
217
+ const canCastSpell = (...args: any[]) => (ctx.canCastSpell as Function)(...args);
218
+ const pickNextSwingArm = (...args: any[]) =>
219
+ (ctx.pickNextSwingArm as Function | undefined)?.(...args) as SpellDef | null | undefined;
220
+ const schoolLocked = (...args: any[]) => (ctx.schoolLocked as Function)(...args);
221
+ const spellIncursGcd = (...args: any[]) => (ctx.spellIncursGcd as Function)(...args);
222
+ const getGcdMs = (...args: any[]) => (ctx.getGcdMs as Function)(...args);
223
+ // Main-hand swing interval. A disarmed unit swings at the flat base time: haste applies to the
224
+ // weapon's own delay, not to that substitute. Cite: SpellAuras.cpp:2443-2448.
225
+ const mainHandSwingMs = (f: Fighter): number => (
226
+ f.disarmedUntil > ctx.t
227
+ ? BASE_ATTACK_TIME_MS
228
+ : scaleTime(f.spec.weapon.speedMs, f.haste, 0)
229
+ );
230
+ // GCD expiry. Haste only shortens the narrow case AddGCD hastens; everything else is the flat
231
+ // recovery time. The clamp matters because Curse of Tongues is negative haste. Cite: Player.cpp:20080.
232
+ const gcdExpiryFor = (f: Fighter, spell: SpellDef, baseMs: number): number => (
233
+ gcdIsHasted(spell, isPlayerCharacterSide(f.side))
234
+ ? ctx.t + Math.min(TUNING.gcdMs, scaleTime(baseMs, f.castHaste, TUNING.minGcdMs))
235
+ : ctx.t + baseMs
236
+ );
237
+ const stealthPosError = (...args: any[]) => (ctx.stealthPosError as Function)(...args);
238
+ const chargeCastOk = (...args: any[]) => (ctx.chargeCastOk as Function)(...args);
239
+ const isInMeleeOf = (...args: any[]) => (ctx.isInMeleeOf as Function)(...args);
240
+ const isChargeRole = (...args: any[]) => (ctx.isChargeRole as Function)(...args);
241
+ const controlActive = (...args: any[]) => (ctx.controlActive as Function)(...args);
242
+ const isControlled = (...args: any[]) => (ctx.isControlled as Function)(...args);
243
+ const isControlledHard = (...args: any[]) => (ctx.isControlledHard as Function)(...args);
244
+ const activeControl = (...args: any[]) => (ctx.activeControl as Function)(...args);
245
+ const isEngagedMelee = (...args: any[]) => (ctx.isEngagedMelee as Function)(...args);
246
+ const requiresLivingPetTarget = (...args: any[]) => (ctx.requiresLivingPetTarget as Function)(...args);
247
+ const isTriggeredCompanion = (...args: any[]) => (ctx.isTriggeredCompanion as Function)(...args);
248
+ const livingPetOf = (...args: any[]) => (ctx.livingPetOf as Function)(...args);
249
+ const find = (...args: any[]) => (ctx.find as Function)(...args);
250
+ const resolveDispelTarget = (...args: any[]) => (ctx.resolveDispelTarget as Function)(...args);
251
+ const canAfford = (...args: any[]) => (ctx.canAfford as Function)(...args);
252
+ const allySnapshot = (...args: any[]) => (ctx.allySnapshot as Function)(...args);
253
+ const push = (e: any) => ctx.push(e);
254
+ const enemyCastBySide = ctx.enemyCastBySide;
255
+ const player = ctx.player;
256
+ const enemy = ctx.enemy;
257
+ const pet = ctx.pet;
258
+ // Friendly seats that drive off an ordered cast list: player, pet and companion, with no third rule.
259
+ // Excluding the player queued every Pre-buff row into a hole, and the player is that order's caster.
260
+ const usesHeldQueue = (f: Fighter): boolean =>
261
+ f === player || f.side === 'pet' || isAllySide(f.side);
262
+ /** Every seat's cast list. Read lazily: createSession hangs it on ctx after this installs. */
263
+ const allCastOrders = (): Map<Side, QueuedCast[]> | undefined =>
264
+ (ctx as { unitCastOrders?: Map<Side, QueuedCast[]> }).unitCastOrders;
265
+ /** This seat's ordered cast list, or undefined when it owns none. */
266
+ const castOrdersOf = (f: Fighter): QueuedCast[] | undefined =>
267
+ (usesHeldQueue(f) ? allCastOrders()?.get(f.side) : undefined);
268
+ /** What the cast list yielded this tick: the spell to cast, plus its one-shot target. */
269
+ interface HeldPick { spell: SpellDef | null; castTgt: Fighter | null }
270
+ /** Spell.cpp TakeReagents → itemsSpent / bagItemCounts. */
271
+ const takeReagentsFor = (f: Fighter, spell: SpellDef): boolean =>
272
+ takeSpellReagents(f, spell, ctx.itemsSpent, { unitItemsSpent: ctx.unitItemsSpent });
273
+ const totems = ctx.totems;
274
+ const effects = ctx.effects;
275
+ const events = ctx.events;
276
+ const invalidTargetReported = ctx.invalidTargetReported;
277
+ const rng = ctx.rng;
278
+ const tickMs = ctx.tickMs;
279
+ const maxMs = ctx.maxMs;
280
+ const cfg = ctx.cfg;
281
+ const distanceBetween = ctx.distanceBetween;
282
+ // White Auto Shot / wand / Throw: SpellEntry.speed is real (75=40, 5019=20, 2764=20) but the
283
+ // swing never went through deliverOrQueueMissile. Cite: Spell.cpp GetSpellSpeed; Unit.cpp:4192.
284
+ const fireRangedAuto = (f: Fighter, tgt: Fighter): boolean => {
285
+ const shot = rangedAutoSpellOf(f);
286
+ const speed = shot?.speed ?? 0;
287
+ if (!ctx.unitPositionsMutable || !shot || !(speed > 0)) {
288
+ return rangedStrike(f, tgt, null);
289
+ }
290
+ const dist = distanceBetween(f, tgt);
291
+ const travelMs = dist != null ? missileTravelMs(dist, speed) : 0;
292
+ if (!(travelMs > 0)) return rangedStrike(f, tgt, null);
293
+ ctx.pendingMissiles.push({
294
+ launchAt: ctx.t, landAt: ctx.t + travelMs,
295
+ casterSide: f.side, targetSide: tgt.side, spell: shot,
296
+ castCritAdd: 0, castEffectPct: 0,
297
+ whiteRanged: true,
298
+ ...(f.spec.ranged?.school ? { school: f.spec.ranged.school } : {}),
299
+ });
300
+ return true;
301
+ };
302
+ const livingEnemies = ctx.livingEnemies;
303
+ const livingFriendlies = ctx.livingFriendlies;
304
+ const bySide = ctx.bySide;
305
+ const duelBounds = createDuelBounds(ctx);
306
+ const fightersOrder = ctx.fightersOrder;
307
+ /**
308
+ * The ACTION walk only. `fightersOrder()` is `[player, pet, allies, totems, enemies]` and nine
309
+ * bookkeeping loops depend on that shape, so it is left alone — but resolving ACTIONS in it
310
+ * hands the same side every tie, and the opener is a tie: `Spell::cast:3223`
311
+ * SetInCombatWithVictim puts both units in combat, and Charge is `notInCombatOnly`, so whoever
312
+ * resolves first denies the other. Measured in a warrior 1v1 mirror: Charge 20/0, and reversing
313
+ * the order flips it to 0/20 and takes the mirror from 5/15 to 10/10.
314
+ *
315
+ * An ARENA rotates this walk per fight so neither side owns the opener. Derived from the bodies'
316
+ * own names, NOT from the session RNG: consuming a roll here would move every existing fight.
317
+ * `enemiesAreATeam` gates it, so every dungeon and raid pull keeps the canonical order.
318
+ */
319
+ let actionRotation = -1;
320
+ const actionOrder = (): Fighter[] => {
321
+ const order = fightersOrder();
322
+ if (cfg.enemiesAreATeam !== true || order.length < 2) return order;
323
+ if (actionRotation < 0) {
324
+ let h = 0;
325
+ for (const f of order) {
326
+ const n = f.spec.name ?? '';
327
+ for (let i = 0; i < n.length; i++) h = (h * 31 + n.charCodeAt(i)) | 0;
328
+ }
329
+ actionRotation = Math.abs(h) % order.length;
330
+ }
331
+ if (actionRotation === 0) return order;
332
+ return [...order.slice(actionRotation), ...order.slice(0, actionRotation)];
333
+ };
334
+ const isCharmed = ctx.isCharmed;
335
+ const posOf = ctx.posOf;
336
+ const setPos = ctx.setPos;
337
+ const applyDamageThreat = (...args: any[]) => (ctx.applyDamageThreat as Function)(...args);
338
+ const applyHealThreat = (...args: any[]) => (ctx.applyHealThreat as Function)(...args);
339
+ const threatOf = (owner: Fighter) => (ctx.threatOf as Function)(owner);
340
+ const dead = deps.dead;
341
+ const sanitizeHp = deps.sanitizeHp;
342
+ const spend = deps.spend;
343
+ const gainRage = deps.gainRage;
344
+ const addComboPoints = deps.addComboPoints;
345
+ const clearComboPoints = deps.clearComboPoints;
346
+ const comboOn = deps.comboOn;
347
+ const foeOf = deps.foeOf;
348
+ const ensurePlayerFocus = deps.ensurePlayerFocus;
349
+ const hostileTargetOf = deps.hostileTargetOf;
350
+ // isControlled / isControlledHard already bound from ctx hubs above
351
+ const isWeaponAction = deps.isWeaponAction;
352
+ const isBreaker = deps.isBreaker;
353
+ // onCooldown/canCastSpell/schoolLocked/weaponStrike/etc. already bound from ctx hubs above.
354
+ const spellCost = deps.spellCost;
355
+ const castOnTargets = deps.castOnTargets;
356
+ const useItem = deps.useItem;
357
+ /** This unit's own kit + item-cooldown map (player / ally / BG enemy bot), or null. */
358
+ const { bagAndCdOf, itemBreaksControl, tryItemRescue } = createItemRescue(ctx, useItem);
359
+ const enterForm = deps.enterForm;
360
+ const applyStealth = deps.applyStealth;
361
+ const maybeBreakStealthOn = deps.maybeBreakStealthOn;
362
+ const breakFeignDeath = (f: Fighter): void => {
363
+ const sid = dropFeignDeath(f);
364
+ if (!sid) return;
365
+ f.buffActive.delete(sid);
366
+ let name = 'Feign Death';
367
+ for (const e of ctx.effectsOn(f.side)) {
368
+ if (e.spellId === sid) {
369
+ name = e.name;
370
+ e.expiresAt = Math.min(e.expiresAt, ctx.t);
371
+ ctx.touchEffect(e);
372
+ }
373
+ }
374
+ push({ t: ctx.t, kind: 'aura', actor: f.side, name, applied: false, spellId: sid });
375
+ };
376
+ const checkDeaths = deps.checkDeaths;
377
+ const finalize = deps.finalize;
378
+ const unsummonTotem = deps.unsummonTotem;
379
+ // runEnemyDeathEventAi already from ctx hub above
380
+ const preferFocusHostile = deps.preferFocusHostile;
381
+ const allEnemiesDead = deps.allEnemiesDead;
382
+ const regenRage = deps.regenRage;
383
+ const regenEnergy = deps.regenEnergy;
384
+ const regenMana = deps.regenMana;
385
+ const regenFocus = deps.regenFocus;
386
+ const regenHealth = deps.regenHealth;
387
+
388
+ const creatureRangedMainReady = (cs: EnemyCastState, f: Fighter): boolean => {
389
+ if (cs.eventAi.rangedMode) return true;
390
+ const phase = cs.eventAi.phase ?? 0;
391
+ const foe = hostileTargetOf(f);
392
+ for (const slot of cs.slots) {
393
+ if (slot.exhausted || ctx.t < slot.readyAt) continue;
394
+ if (eventPhaseBlocked(slot.entry.eventInversePhaseMask, phase)) continue;
395
+ const flags = slot.entry.castFlags ?? 0;
396
+ if (!isCreatureRangedMainAttack(slot.spell, flags)) continue;
397
+ if (onCooldown(f, slot.spell)) continue;
398
+ const forceCast =
399
+ (flags & (CAST_FLAG.FORCE_CAST | CAST_FLAG.FORCE_TARGET_SELF)) !== 0;
400
+ const triggered = (flags & CAST_FLAG.TRIGGERED) !== 0;
401
+ if (!forceCast && !triggered && !canAfford(f, slot.spell)) continue;
402
+ if (
403
+ !forceCast
404
+ && (spellNeedsRangeGate(slot.spell) || spellFoeDirectedGeometry(slot.spell))
405
+ && !isInSpellRange(
406
+ distanceBetween(f, foe),
407
+ spellGateRangeMaxYd(slot.spell), spellGateRangeMinYd(slot.spell),
408
+ ctx.isMoving(f.side) && ctx.isMoving(foe.side),
409
+ f.spec.combatReachYd, foe.spec.combatReachYd,
410
+ )
411
+ ) {
412
+ continue;
413
+ }
414
+ return true;
415
+ }
416
+ return false;
417
+ };
418
+
419
+ /** Drop an armed next-cast modifier (consumed by a matching cast, or replaced by a new one). */
420
+ const consumePendingSpellMod = (f: Fighter): void => {
421
+ const m = f.pendingSpellMod;
422
+ if (!m) return;
423
+ f.pendingSpellMod = null;
424
+ f.buffActive.delete(m.spellId);
425
+ for (let i = effects.length - 1; i >= 0; i--) {
426
+ const e = effects[i]!;
427
+ if (e.kind === 'buff' && e.spellId === m.spellId && e.target === f.side) {
428
+ effects.splice(i, 1);
429
+ push({ t: ctx.t, kind: 'aura', actor: f.side, name: e.name, applied: false });
430
+ }
431
+ }
432
+ };
433
+ ctx.consumePendingSpellMod = consumePendingSpellMod;
434
+
435
+ // Charge travel, gap-close, area-aura maintain — see sessionMotion.ts.
436
+ const {
437
+ finishChargeArrival,
438
+ advanceChargeMove,
439
+ enemyEngaged,
440
+ runMotionPhase: motionCore,
441
+ areaAuraMaintainPhase: areaAuraCore,
442
+ } = installSessionMotion({
443
+ ctx,
444
+ dead,
445
+ fighterByThreatId,
446
+ hostileTargetOf,
447
+ foeOf,
448
+ isChargeRole,
449
+ onCooldown,
450
+ canAfford,
451
+ weaponStrike,
452
+ castOnTargets,
453
+ posOf,
454
+ setPos,
455
+ fightersOrder,
456
+ bySide,
457
+ effects,
458
+ tickMs,
459
+ });
460
+
461
+ // Phases split out of combat.sim, which is otherwise the whole tick under one bucket name.
462
+ const runMotionPhase = (): void => combatPerf('sim.motion', motionCore);
463
+ const areaAuraMaintainPhase = (): void => combatPerf('sim.areaAuras', areaAuraCore);
464
+ const eventAiCore = (): void => {
465
+ for (const ef of ctx.livingEnemies()) runEnemyEventAi(ef);
466
+ // Ally-seat summons have no EventAI pass of their own. Cite: SpellEffects.cpp:2765.
467
+ ctx.expireFriendlySummons?.();
468
+ };
469
+ const eventAiPhase = (): void => combatPerf('sim.eventAi', eventAiCore);
470
+ // Stealth detect on the core's own timer (BG 500 ms / world 2000 ms) — self-gated.
471
+ installStealthDetect(ctx, { dead });
472
+
473
+ const nearestHostileYd = (f: Fighter): number | null => {
474
+ const from = ctx.posOf(f.side);
475
+ if (!from) return null;
476
+ let best = Infinity;
477
+ for (const h of ctx.livingHostilesFor(f)) {
478
+ if (dead(h) || h === f) continue;
479
+ const d = combatDistanceYd(from, ctx.posOf(h.side));
480
+ if (d != null && d < best) best = d;
481
+ }
482
+ return Number.isFinite(best) ? best : null;
483
+ };
484
+
485
+ const novaPileNear = (f: Fighter, spell: SpellDef): boolean => {
486
+ const d = nearestHostileYd(f);
487
+ const cap = (spell.radiusYd != null && spell.radiusYd > 0)
488
+ ? spell.radiusYd + 2
489
+ : NOVA_KITE_NEAR_YD;
490
+ return d != null && d <= cap;
491
+ };
492
+
493
+ const commitAfterCast = (f: Fighter, spell: SpellDef): void => {
494
+ if (isEnemySide(f.side)) return;
495
+ const kite = isNovaKiteSpell(spell);
496
+ if (commitRuleAfter(f, spell.id, ctx.t)) {
497
+ if (kite && !novaPileNear(f, spell)) clearStepBack(f);
498
+ return;
499
+ }
500
+ if (kite && !isStepBackWalking(f, ctx.t) && novaPileNear(f, spell)) {
501
+ applyRuleAfter(f, { stepBackYd: NOVA_KITE_STEP_BACK_YD }, ctx.t);
502
+ }
503
+ };
504
+
505
+ const startOrCast = (f: Fighter, foe: Fighter, spell: SpellDef): void => {
506
+ // Hard reactive gate so a force-cast cannot freecast Mongoose Bite. Do NOT consume the window
507
+ // here — a later early-return on range, form or school must leave it open.
508
+ if (reactiveCastGate(f, foe, spell, ctx.t) != null) return;
509
+ if (targetCastGate(f, foe, spell, ctx.t) != null) return;
510
+ // OOC stand-down / no live foe: never arm a hostile into the caster (or a corpse).
511
+ // pickAction gates this; EventAI force paths and mid-cast finish still hit deliver below.
512
+ const destRow = ctx.castDestOverride?.get(f.side);
513
+ const dest = destRow && destRow.spellId === spell.id ? destRow.dest : undefined;
514
+ if (isHostileSpellDef(spell) && (foe === f || dead(foe)) && !isCasterOriginAoe(spell) && !dest) return;
515
+ // Player EquippedItem full slot refuse before spend (creatures skip — Spell.cpp:6542).
516
+ // Cite: Spell.cpp:6539–6543; Player.cpp:18572; equippedItemGate.ts.
517
+ if (isPlayerCharacterSide(f.side) && playerMissingRequiredEquippedItem(spell, f.spec)) return;
518
+ // Spell.cpp CheckItems — Soul Shard on Shadowburn etc. before GCD/spend.
519
+ // Cite: Spell.cpp:6560–6593; KI-804.
520
+ if (!canPaySpellReagents(f, spell)) return;
521
+ // Classic pure-DISPEL CheckCast: no matching aura → SPELL_FAILED_NOTHING_TO_DISPEL
522
+ // (no mana / no GCD). Cite: Spell.cpp:4890–4919; KI-610 Purge combat mana sink.
523
+ if (isPureDispelSpell(spell)) {
524
+ const de = firstDispelEffect(spell);
525
+ if (de && resolveDispelTarget(f, foe, de.dispelType) == null) return;
526
+ }
527
+ // Attack is the melee auto-attack toggle, off the GCD and free. Stealth enter cancels auto-attack,
528
+ // and pressing Attack is the interrupt flag that re-enables swings. Cite: Unit.cpp Attack.
529
+ if (roleOf(spell) === 'attack') {
530
+ f.mode = 'melee';
531
+ f.autoAttackEnabled = true;
532
+ if (f.stealthed) maybeBreakStealthOn(f, AURA_INTERRUPT.ATTACKING, 'attacking');
533
+ if (f.feigningUntil > ctx.t) breakFeignDeath(f);
534
+ f.autoAttackEnabled = true;
535
+ if (f.swingReadyAt > ctx.t + 60_000) f.swingReadyAt = ctx.t;
536
+ return;
537
+ }
538
+ // EventAI castFlags for the armed creature slot (DoCastSpellIfCan).
539
+ // Cite: UnitAI.cpp DoCastSpellIfCan ~160–223; UnitAI.h CastFlags ~64–79.
540
+ let castFlags = 0;
541
+ if (isEnemySide(f.side)) {
542
+ const cs = enemyCastBySide.get(f.side as EnemySide);
543
+ if (cs?.pendingSlot && cs.pendingSlot.spell.id === spell.id) {
544
+ castFlags = cs.pendingSlot.entry.castFlags ?? 0;
545
+ }
546
+ }
547
+ // Clip on commit. A different instant replaces CURRENT_GENERIC_SPELL.
548
+ // Cite: Spell.cpp Prepare ~2921; Unit.cpp:4203-4212 SetCurrentCastedSpell.
549
+ const clipInFlightCast = (): void => {
550
+ if (
551
+ f.castingSpell
552
+ && (
553
+ (castFlags & CAST_FLAG.INTERRUPT_PREVIOUS) !== 0
554
+ || (!isEnemySide(f.side) && spellHasInterruptCast(spell) && (spell.castTimeMs ?? 0) <= 0)
555
+ || (f.side === 'player' && !ctx.aiPlayer && (spell.castTimeMs ?? 0) <= 0 && !isNextMeleeSwingSpell(spell))
556
+ || (
557
+ cfg.enemiesAreATeam === true
558
+ && !isEnemySide(f.side)
559
+ && (spell.castTimeMs ?? 0) <= 0
560
+ && !isNextMeleeSwingSpell(spell)
561
+ && f.castingSpell.id !== spell.id
562
+ )
563
+ )
564
+ ) {
565
+ f.castingSpell = null;
566
+ f.castFinishAt = 0;
567
+ f.castStartedAt = 0;
568
+ f.castBaseMs = 0;
569
+ f.castPushbacks = 0;
570
+ f.castCost = 0;
571
+ f.castCostPower = undefined;
572
+ }
573
+ };
574
+ // FORCE_TARGET_SELF makes the caster the target; SWITCH_CASTER_TARGET swaps the two and is rare
575
+ // in the bake. Cite: UnitAI.cpp ~167-170.
576
+ if ((castFlags & (CAST_FLAG.FORCE_TARGET_SELF | CAST_FLAG.SWITCH_CASTER_TARGET)) !== 0) {
577
+ foe = f;
578
+ }
579
+ const forceCast =
580
+ (castFlags & (CAST_FLAG.FORCE_CAST | CAST_FLAG.FORCE_TARGET_SELF)) !== 0;
581
+ const triggered = (castFlags & CAST_FLAG.TRIGGERED) !== 0;
582
+
583
+ // Stance gate: NOT_SHAPESHIFT leaves form then casts, but the positional and stealth gates run
584
+ // FIRST — otherwise Backstab breaks stealth, behindOk flips, and the cast returns with no hit.
585
+ const gate = resolveFormCast(spell, f.form);
586
+ if (!gate.ok) return;
587
+ if (stealthPosError(f, foe, spell)) return;
588
+ // DG / Geometry v2.1b: out-of-range foe specials do not land (Spell.cpp CheckCast before spend).
589
+ // FORCE_CAST / FORCE_TARGET_SELF skip range (UnitAI.cpp ~196–202).
590
+ if (!forceCast) {
591
+ if (isChargeRole(spell)) {
592
+ if (!chargeCastOk(f, foe, spell)) return;
593
+ } else if (spellNeedsRangeGate(spell) || spellFoeDirectedGeometry(spell)) {
594
+ const rangeD = dest
595
+ ? combatDistanceYd(ctx.posOf(f.side), dest)
596
+ : distanceBetween(f, foe);
597
+ if (!isInSpellRange(
598
+ rangeD, spellGateRangeMaxYd(spell), spellGateRangeMinYd(spell),
599
+ !dest && ctx.isMoving(f.side) && ctx.isMoving(foe.side),
600
+ f.spec.combatReachYd, foe.spec.combatReachYd,
601
+ )) return;
602
+ }
603
+ // Spell.cpp:4742 CheckCast unit-target LOS. FORCE_CAST / TRIGGERED skip CanCastSpell.
604
+ if (!triggered && cfg.navLos && spellNeedsLosGate(spell)) {
605
+ const destSight = dest ?? posOf(foe.side);
606
+ if (!navSightClear(cfg, posOf(f.side), destSight)) return;
607
+ }
608
+ }
609
+ if (dest) f.castDest = dest;
610
+ else if (!spell.onNextSwing) f.castDest = null;
611
+ // Pick Pocket must NOT leave stealth: 1.12 keeps the thief stealthed after a successful pocket,
612
+ // and NOT_SHAPESHIFT on the bake would otherwise strip the form. Cite: SpellEffects.cpp.
613
+ const isPickPocketSpell = (spell.effects ?? []).some((e) => e.kind === 'pickpocket')
614
+ && !(spell.effects ?? []).some((e) => e.kind !== 'pickpocket');
615
+ if (gate.leaveForm && f.form && !isPickPocketSpell) {
616
+ // Leaving FORM_STEALTH via NOT_SHAPESHIFT also drops the stealth aura.
617
+ if (isStealthShapeshift(f.form) && f.stealthed) breakStealth(f, 'action');
618
+ else leaveForm(f);
619
+ }
620
+ // School lockout (PreventionType SILENCE + locked school). Cite: Object.cpp IsSpellReady ~2736.
621
+ if (schoolLocked(f, spell)) return;
622
+
623
+ // A hostile cast start puts both in combat, AFTER CheckCast as in Spell::prepare. Flagging first
624
+ // made Charge refuse itself on the flag it had just set, so every pull lost its opener.
625
+ if (isHostileSpellDef(spell) && foe !== f) {
626
+ touchCombatFlag(f, ctx.t);
627
+ touchCombatFlag(foe, ctx.t);
628
+ if (isEnemySide(foe.side)) maybeAggroZonePulse(foe, ctx.partyUnits(), ctx.t);
629
+ }
630
+
631
+ // Party interrupt: one Kick/CS/Spell Lock per casting target. Miss releases the claim so
632
+ // the next free interrupter can cover; a landed kick clears the cast (and the claim).
633
+ if (
634
+ spellHasInterruptCast(spell)
635
+ && isEnemySide(foe.side)
636
+ && (f.side === 'player' || f.side === 'pet' || isAllySide(f.side))
637
+ ) {
638
+ const owner = ctx.interruptClaim.get(foe.side);
639
+ if (owner != null && owner !== f.side) {
640
+ const o = ctx.bySide(owner as Side);
641
+ if (o && !dead(o)) return;
642
+ }
643
+ ctx.interruptClaim.set(foe.side, f.side);
644
+ }
645
+
646
+ // Party dispel: one Tranq/Purge/Cleanse per (target, category). Cite: AUTOPILOT-PLAN §4 row 21.
647
+ if (
648
+ isDispelSpell(spell)
649
+ && (f.side === 'player' || f.side === 'pet' || isAllySide(f.side))
650
+ ) {
651
+ for (const e of spell.effects) {
652
+ if (e.kind !== 'dispel') continue;
653
+ const res = resolveDispelTarget(f, foe, e.dispelType);
654
+ if (!res) continue;
655
+ // The take used to accept any live owner, without asking whether the category was still
656
+ // on the target or the owner still casting — a kicked holder kept the key. dispelDuty.ts.
657
+ const owner = dispelOwnerThisTick(ctx, res.unit.side, e.dispelType, dead);
658
+ if (owner != null && owner !== f.side) return;
659
+ ctx.dispelClaim.set(dispelClaimKey(res.unit.side, e.dispelType), f.side);
660
+ }
661
+ }
662
+
663
+ // Party battle rez: one Rebirth per corpse. Every druid ranks corpses the same way, so without the
664
+ // claim they all open on one body and every cast but the first spends a 30 min cooldown for nothing.
665
+ if (isBattleRezSpell(spell) && (f.side === 'player' || isAllySide(f.side))) {
666
+ const corpse = pickBattleRezTarget(
667
+ ctx.partyUnits(),
668
+ f,
669
+ (c) => ctx.rezClaim.get(c.side),
670
+ );
671
+ if (!corpse) return;
672
+ ctx.rezClaim.set(corpse.side, f.side);
673
+ }
674
+
675
+ // Past CheckCast-style gates: consume the react window (one use per proc; CD ≈ window).
676
+ consumeReactiveWindow(f, foe, spell);
677
+
678
+ // CAST_TRIGGERED — zero mana + no cast time. UnitAI.cpp ~214 TRIGGERED_OLD_TRIGGERED.
679
+ let cost = triggered ? 0 : spellCost(f, spell);
680
+ // FORCE_CAST still spends mana if affordable; OOM is allowed only because canCast was skipped.
681
+ if (forceCast && !triggered && !canAfford(f, spell)) {
682
+ cost = 0; // force through without mana (C++ CastSpell with FORCE_CAST path)
683
+ }
684
+ const spendPt: PowerType | undefined =
685
+ spell.powerType === 'health' ? 'health'
686
+ : spell.powerType === 'mana' || isFormSpell(spell) ? 'mana'
687
+ : spell.powerType === 'rage' || spell.powerType === 'energy' ? spell.powerType
688
+ : undefined;
689
+
690
+ // On-next-swing abilities do not hit now and do not trigger the GCD: they arm the next white swing.
691
+ // CheckPower is current rage, not a predicted next-hit term. Cite: Spell.cpp CheckPower; Prepare.
692
+ if (isNextMeleeSwingSpell(spell)) {
693
+ if (f.pendingSwing) return; // already armed — don't re-spend
694
+ if (!triggered && !forceCast && !canAfford(f, spell)) return;
695
+ clipInFlightCast();
696
+ // The arm is Spell::prepare, which sends SPELL_START and stops — cast() runs later, off the
697
+ // swing that carries it, so cast_finish is pushed there. Cite: Spell.cpp:2978-2981.
698
+ if (isEnemySide(f.side)) ctx.notifyEventAiSpellCast?.(f, spell.id);
699
+ spend(f, cost, spendPt, spell);
700
+ takeReagentsFor(f, spell);
701
+ if (spell.rageGenerated) gainRage(f, spell.rageGenerated);
702
+ f.pendingSwing = spell;
703
+ return;
704
+ }
705
+ // Pure Auto Shot / Shoot / Throw enables ranged auto with the equipped weapon, paced by weapon
706
+ // speed on ONE path. Re-tapping in ranged mode is a no-op; Multi and Aimed fall through to cast.
707
+ if (isRangedAutoRepeatToggle(spell)) {
708
+ const rw = f.spec.ranged;
709
+ if (!rw) return; // no ranged weapon equipped — nothing to fire (also gated in usability)
710
+ // SpellRange min dead zone — Throw / Auto Shot cannot fire in melee.
711
+ // Cite: SpellRange.dbc min; isInSpellRange; Spell.cpp range check.
712
+ {
713
+ const dist = distanceBetween(f, foe);
714
+ if (!isInSpellRange(dist, spell.rangeMax, spell.rangeMin ?? rangedAutoMinYards(f))) {
715
+ return;
716
+ }
717
+ }
718
+ const wasRanged = f.mode === 'ranged';
719
+ f.mode = 'ranged'; // exclusive with melee white (swing uses ranged when mode=ranged)
720
+ f.autoAttackEnabled = true;
721
+ // Already wanding/Auto Shot: swing phase owns the stream. Do not stack a second hit
722
+ // or burn GCD (healer AI re-picking Shoot every CD must not lock out heals).
723
+ if (wasRanged) return;
724
+ clipInFlightCast();
725
+ spend(f, cost, spendPt, spell);
726
+ const rangedHaste = f.haste + (f.spec.rangedHastePct ?? 0);
727
+ const effRwSpeed = scaleTime(rw.speedMs, rangedHaste, 0);
728
+ f.cooldowns.set(spell.id, ctx.t + Math.max(personalCooldownMs(spell), effRwSpeed));
729
+ {
730
+ const catMs = categoryCooldownMs(spell);
731
+ if (spell.category != null && catMs > 0) {
732
+ f.categoryCooldowns.set(spell.category, ctx.t + catMs);
733
+ }
734
+ }
735
+ const gShoot = getGcdMs(spell);
736
+ if (gShoot > 0) f.gcdUntil = gcdExpiryFor(f, spell, gShoot);
737
+ // First enable: one shot now, then lock the shared swing timer to weapon pace.
738
+ fireRangedAuto(f, foe);
739
+ f.swingReadyAt = Math.max(f.swingReadyAt, ctx.t + effRwSpeed);
740
+ return;
741
+ }
742
+ // Consume an armed next-cast modifier (Presence of Mind, Cold Blood, Nature's Swiftness): zero the
743
+ // cost, zero the cast time, and arm the per-cast crit bonus read at the rolls below.
744
+ let effCost = applyOwnedSpellMod(f, spell, SPELLMOD_COST, cost);
745
+ let effCastMs = triggered ? 0 : applyOwnedSpellMod(f, spell, SPELLMOD_CASTING_TIME, spell.castTimeMs);
746
+ if (f.pendingSpellMod && nextCastModApplies(f.pendingSpellMod, spell)) {
747
+ const m = f.pendingSpellMod;
748
+ if (m.freeCost) effCost = 0;
749
+ // costModPct = SPELLMOD_COST on the resolved cost — not SpellDef.costPct (base-mana %).
750
+ // Cite: SpellDefines.h SPELLMOD_COST=14; Fel Domination −50.
751
+ else if (m.costModPct) effCost = Math.max(0, Math.round(effCost * (1 + m.costModPct / 100)));
752
+ // ApplySpellMod adds the FLAT rows before the PCT (Warrior's Wrath −50, Retributive Smite −300).
753
+ if (m.costFlat) effCost = Math.max(0, effCost + m.costFlat);
754
+ if (m.instant) effCastMs = 0;
755
+ else if (m.castTimeFlat) effCastMs = Math.max(0, effCastMs + m.castTimeFlat);
756
+ if (m.critAdd) f.castCritAdd = m.critAdd;
757
+ if (m.effectPct) f.castEffectPct = m.effectPct; // Amplify Curse +50% next-curse effect
758
+ consumePendingSpellMod(f);
759
+ }
760
+ // Check if we need to cancel autorepeat (like wand Shoot). Auto Shot lacks the bit.
761
+ // Cite: Unit.cpp:4256-4259 case CURRENT_AUTOREPEAT_SPELL and case CURRENT_CHANNELED_SPELL.
762
+ if (!triggered && f.mode === 'ranged' && f.autoAttackEnabled) {
763
+ const autorepeatSpell = f.spec.spells.find(isRangedAutoRepeatToggle);
764
+ if (autorepeatSpell && hasAttrEx3(autorepeatSpell, SPELL_ATTR_EX3.CASTING_CANCELS_AUTOREPEAT)) {
765
+ f.autoAttackEnabled = false;
766
+ }
767
+ }
768
+
769
+ // Prepare only starts the timer and adds the GCD; the power cost and cooldown are taken in cast(),
770
+ // and cancel() takes neither, so an interrupted cast is free. Cost is held post-mod, paid at finish.
771
+ if (effCastMs > 0) {
772
+ clipInFlightCast();
773
+ const gCast = getGcdMs(spell);
774
+ if (gCast > 0) f.gcdUntil = gcdExpiryFor(f, spell, gCast);
775
+ // Starting a bar drops a live channel HERE, at Prepare, not when the new spell lands: waiting
776
+ // let a 3 s Frostbolt tick a Mind Flay through its whole cast. Cite: Unit.cpp:4221-4224.
777
+ if (f.channeling) interruptChannel(f, channelInterruptCtx(ctx));
778
+ f.castingSpell = spell;
779
+ f.castStartedAt = ctx.t;
780
+ // Haste-modified cast time (m_casttime). Cite: Unit.cpp / Spell.cpp cast timer;
781
+ // formulas.scaleTime; Berserking MOD_HASTE (KI-125).
782
+ f.castBaseMs = scaleTime(effCastMs, f.castHaste, 0);
783
+ f.castFinishAt = ctx.t + f.castBaseMs;
784
+ // E3.6: the only write that can move the session's next cast landing EARLIER. Miss this and
785
+ // the bar hangs until some later cast happens to reopen the walk.
786
+ if (f.castFinishAt < castFinishDue) castFinishDue = f.castFinishAt;
787
+ f.castPushbacks = 0;
788
+ f.castCost = effCost;
789
+ f.castCostPower = spendPt;
790
+ // Non-default aim survives the bar. Finish otherwise re-resolves foeOf (the kill target).
791
+ if (isEnemySide(foe.side)) {
792
+ const fallback = isEnemySide(f.side) ? hostileTargetOf(f) : foeOf(f);
793
+ if (foe !== fallback) {
794
+ (ctx.castTargetOverride ??= new Map()).set(f.side, {
795
+ spellId: spell.id,
796
+ side: foe.side as EnemySide,
797
+ });
798
+ }
799
+ }
800
+ // cast_start.castMs is the live bar duration (haste-scaled), not the spell row base.
801
+ push({ t: ctx.t, kind: 'cast_start', actor: f.side, spellId: spell.id, name: spell.name, castMs: f.castBaseMs });
802
+ return;
803
+ }
804
+ // A deliberate instant breaks a live channel at the cast, as the bar branch does. Triggered
805
+ // payloads are exempt — they are the channel's own output. Cite: Unit.cpp:4236-4243.
806
+ if (!triggered && f.channeling) maybeCancelChannelOnAction(f, channelInterruptCtx(ctx));
807
+ clipInFlightCast();
808
+ // Instant: Prepare falls through to cast() this tick, so cost and cooldown land now and cast_finish
809
+ // is emitted here too. Everything downstream keys off it; "a bar was shown" is cast_start.
810
+ push({ t: ctx.t, kind: 'cast_finish', actor: f.side, spellId: spell.id, name: spell.name });
811
+ // EVENT_T_SPELL_CAST (37) on the caster. Cite: CreatureEventAI.cpp OnSpellCast.
812
+ if (isEnemySide(f.side)) ctx.notifyEventAiSpellCast?.(f, spell.id);
813
+ commitAfterCast(f, spell);
814
+ spend(f, effCost, spendPt, spell);
815
+ maybeResetMeleeAttackTimersOnCast(f, spell, ctx.t, triggered);
816
+ // TakeReagents after TakePower (Spell.cpp cast ~3168–3176). Cite: KI-804 Shadowburn.
817
+ takeReagentsFor(f, spell);
818
+ if (!triggered && !hasAttrEx2(spell, SPELL_ATTR_EX2.NOT_AN_ACTION)) {
819
+ ctx.removeAurasWithInterruptFlags?.(f, AURA_INTERRUPT.ACTION_LATE, 'action late');
820
+ }
821
+ // Personal + category recovery (SpellMgr.h GetSpellRecoveryTime / separate timers).
822
+ // Cite: spells/recovery.ts; category may use categoryRecoveryMs when sourced.
823
+ {
824
+ const rec = recoveryReadyAts(spell, ctx.t);
825
+ // COOLDOWN_ON_EVENT: the cast installs a permanent entry and the real timer starts when the aura
826
+ // fades. Re-stealth used to be free the instant stealth broke. Cite: Spell.cpp:3414.
827
+ const deferred = cooldownStartsOnAuraFade(spell);
828
+ if (rec.personalReadyAt != null) {
829
+ const adj = applyOwnedSpellMod(f, spell, SPELLMOD_COOLDOWN, rec.personalReadyAt - ctx.t);
830
+ if (adj > 0) {
831
+ if (deferred) {
832
+ f.cooldowns.set(spell.id, PERMANENT_COOLDOWN_READY_AT);
833
+ (f.deferredCooldowns ??= new Map()).set(spell.id, adj);
834
+ } else {
835
+ f.cooldowns.set(spell.id, ctx.t + adj);
836
+ }
837
+ }
838
+ }
839
+ if (rec.category != null && rec.categoryReadyAt != null) {
840
+ const adj = applyOwnedSpellMod(f, spell, SPELLMOD_COOLDOWN, rec.categoryReadyAt - ctx.t);
841
+ if (adj > 0) {
842
+ // The client keeps the category cd by the original spell id, and the category entry
843
+ // defers with it. Cite: Player.cpp:20197-20208.
844
+ f.categoryCooldowns.set(
845
+ rec.category,
846
+ deferred ? PERMANENT_COOLDOWN_READY_AT : ctx.t + adj,
847
+ );
848
+ if (deferred) (f.deferredCategoryCooldowns ??= new Map()).set(spell.id, { category: rec.category, ms: adj });
849
+ }
850
+ }
851
+ }
852
+ const g = getGcdMs(spell);
853
+ if (g > 0) f.gcdUntil = gcdExpiryFor(f, spell, g);
854
+ if (spell.rageGenerated) gainRage(f, spell.rageGenerated);
855
+
856
+ // Hostile / actionable cast: break stealth (AIF ACTION) unless this spell *is* the stealth enter.
857
+ // Pick Pocket does not break stealth (vanilla 1.12; KI-612).
858
+ const isStealthEnter = isStealthSpell(spell);
859
+ const isPickPocketCast = (spell.effects ?? []).some((e) => e.kind === 'pickpocket')
860
+ && !(spell.effects ?? []).some((e) => e.kind !== 'pickpocket');
861
+ const isSpellBreakStealth = !hasAttrEx(spell, SPELL_ATTR_EX.ALLOW_WHILE_STEALTHED);
862
+ if (!isStealthEnter && !isPickPocketCast && isSpellBreakStealth) maybeBreakStealthOn(f, AURA_INTERRUPT.ACTION, 'action');
863
+ // The rest of RemoveAurasOnCast: every OTHER ACTION-flagged holder goes, the caster's own
864
+ // excepted. Stealth is skipped because the guarded break above owns it. Cite: Spell.cpp:2943-2944;
865
+ // Unit.cpp:5239-5252.
866
+ if (!triggered && !hasAttrEx2(spell, SPELL_ATTR_EX2.NOT_AN_ACTION)) {
867
+ ctx.removeAurasWithInterruptFlags?.(f, AURA_INTERRUPT.ACTION, 'action', {
868
+ skipStealth: true, exceptSpellId: spell.id,
869
+ skipInvisibility: hasAttrEx2(spell, SPELL_ATTR_EX2.ALLOW_WHILE_INVISIBLE),
870
+ });
871
+ }
872
+ // Feign Death AIF includes ACTION (0x4) — any cast (except FD itself) drops the pose.
873
+ // CheckCast already ran, so a trap still places while OOC. Cite: Spell.cpp:2944; 5384 AIF 0x3c3c.
874
+ if (!spell.effects.some((e) => e.kind === 'feign_death')) breakFeignDeath(f);
875
+
876
+ // AttackStop on the flag, or on Gouge/Sap incapacitate. Do not fall through to the combo-point
877
+ // re-enable — Gouge generates one. Cite: Spell.cpp:3691-3700.
878
+ if (spellCancelsAutoAttack(spell)) {
879
+ f.autoAttackEnabled = false;
880
+ f.pendingSwing = null;
881
+ } else if (roleOf(spell) === 'strike' || roleOf(spell) === 'shoot'
882
+ || spell.requiresBehind || spell.comboPointsGenerated
883
+ || (spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.MELEE
884
+ || ((spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.RANGED
885
+ && spell.effects.some((e) => e.kind === 'weapon_percent_damage'))) {
886
+ // Openers, builders, melee abilities and ranged shots re-enable auto-attack. A MELEE dmgClass
887
+ // flips off Auto Shot so whites can finish a totem; Multi and Aimed keep ranged mode.
888
+ if (((spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.RANGED || roleOf(spell) === 'shoot')
889
+ && f.spec.ranged) {
890
+ f.mode = 'ranged';
891
+ } else if ((spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.MELEE) {
892
+ f.mode = 'melee';
893
+ }
894
+ f.autoAttackEnabled = true;
895
+ if (f.swingReadyAt > ctx.t + 60_000) f.swingReadyAt = ctx.t;
896
+ }
897
+
898
+ // Charge and Intercept spend above, then travel at charge speed until melee before the effects
899
+ // apply. Arrival can act on the same tick. Without geometry it falls through to an instant cast.
900
+ if (isChargeRole(spell) && ctx.unitPositionsMutable) {
901
+ f.chargeUsed = true;
902
+ f.autoAttackEnabled = true;
903
+ if (f.swingReadyAt > ctx.t + 60_000) f.swingReadyAt = ctx.t;
904
+ const dist = distanceBetween(f, foe);
905
+ const speed = (spell.speed != null && spell.speed > 0) ? spell.speed : BASE_CHARGE_SPEED;
906
+ if (dist != null && dist > MIN_AGGRO_DISTANCE) {
907
+ f.pendingCharge = { spell, targetSide: foe.side, speed };
908
+ // One charge step this tick; if already in band after step, resolve effects now.
909
+ advanceChargeMove(f);
910
+ return;
911
+ }
912
+ finishChargeArrival(f, foe, spell);
913
+ return;
914
+ }
915
+
916
+ // Weapon strike / nuke / etc. — missiles with SpellEntry.speed delay impact.
917
+ deliverOrQueueMissile(f, foe, spell);
918
+ // Immediate path clears next-cast mods inside deliverOrQueueMissile; delayed keeps
919
+ // the snapshot on the pending row and zeros the fighter so a second cast does not double-dip.
920
+ };
921
+
922
+ // Apply spell effects / weapon strike at impact time (cast finish or missile land). Cite: Spell.cpp
923
+ // handle_immediate after travel.
924
+ const deliverSpellImpact = (f: Fighter, castTgt: Fighter, spell: SpellDef): void => {
925
+ // Weapon sample path: melee strikes and ranged weapon shots, which still carry weapon_percent
926
+ // and a RANGED dmgClass. Pure Auto Shot never reaches here — the toggle returns early.
927
+ const hasWeaponPct = spell.effects.some((e) => e.kind === 'weapon_percent_damage');
928
+ const weaponSample = roleOf(spell) === 'strike' || roleOf(spell) === 'shoot'
929
+ || !!spell.strikeBonus
930
+ || (hasWeaponPct && (spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.RANGED);
931
+ if (weaponSample) {
932
+ // A ranged normalized strike goes to rangedStrike when a ranged weapon is equipped; NPC kits
933
+ // bake main-hand only, so they fall back rather than silently dealing melee damage.
934
+ const isRanged = (spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.RANGED
935
+ || roleOf(spell) === 'shoot';
936
+ if (isRanged && f.spec.ranged) {
937
+ // Multi-Shot chain / single Aimed: primary via rangedStrike, then chain jumps.
938
+ const primaryInRadius = !spell.aoe || aoeUnitInRadius(f, castTgt, castTgt, spell);
939
+ const landed = primaryInRadius ? rangedStrike(f, castTgt, spell) : false;
940
+ const riders = spell.effects.filter(
941
+ (e) => e.kind === 'buff' || e.kind === 'dot' || e.kind === 'cc' || e.kind === 'root'
942
+ || e.kind === 'knock_back' || e.kind === 'modify_threat_pct'
943
+ // Target-borne periodic-trigger debuffs (Nefarian's class calls) fan with the AoE;
944
+ // channel holders (targetA CASTER) carry no auraOnTarget and stay off the rider.
945
+ || (e.kind === 'proc_trigger_spell' && e.auraOnTarget === true),
946
+ );
947
+ const riderSpell = riders.length
948
+ ? { ...spell, effects: riders, rawEffects: undefined }
949
+ : null;
950
+ if (landed && riderSpell) applySpell(f, castTgt, riderSpell);
951
+ if (spell.aoe) {
952
+ let pool = isEnemySide(f.side) ? livingFriendlies() : livingEnemies();
953
+ if (cfg.endless && !isEnemySide(f.side)
954
+ && !(spell.radiusYd != null && spell.radiusYd > 0)) {
955
+ pool = pool.filter((e) => {
956
+ if (e === castTgt) return true;
957
+ const tm = threatOf(e); return !!tm && !tm.isEmpty();
958
+ });
959
+ }
960
+ const cap = aoeTotalTargetCap(spell);
961
+ const others = selectAoeSecondaries(f, castTgt, spell, pool, cap);
962
+ for (const other of others) {
963
+ const otherLanded = rangedStrike(f, other, spell);
964
+ if (otherLanded && riderSpell) applySpell(f, other, riderSpell);
965
+ }
966
+ } else if (wantsChainFan(spell)) {
967
+ // Multi-Shot: EffectChainTarget 3, SCHEME_CLOSEST_CHAIN.
968
+ // Cite: chainFan.ts; Spell.cpp SCHEME_CLOSEST_CHAIN; raw chainTargets=3.
969
+ let pool = isEnemySide(f.side) ? livingFriendlies() : livingEnemies();
970
+ if (cfg.endless && !isEnemySide(f.side)
971
+ && !(spell.radiusYd != null && spell.radiusYd > 0)) {
972
+ pool = pool.filter((e) => {
973
+ if (e === castTgt) return true;
974
+ const tm = threatOf(e); return !!tm && !tm.isEmpty();
975
+ });
976
+ }
977
+ const cap = aoeTotalTargetCap(spell) ?? 1;
978
+ const chain = selectHostileChainTargets(
979
+ castTgt, pool.filter((u) => !dead(u)), cap, distanceBetween,
980
+ CHAIN_SPELL_JUMP_RADIUS_YD, undefined,
981
+ // Cleave and Swipe jump from the CASTER, not from the first target. Cite: Spell.cpp:2179
982
+ // PUSH_SELF_CENTER; 11 carriers, all Cleave/Swipe ranks.
983
+ hasAttrEx2(spell, SPELL_ATTR_EX2.CHAIN_FROM_CASTER) ? f : undefined,
984
+ );
985
+ for (let i = 1; i < chain.length; i++) {
986
+ const other = chain[i]!;
987
+ const otherLanded = rangedStrike(f, other, spell);
988
+ if (otherLanded && riderSpell) applySpell(f, other, riderSpell);
989
+ }
990
+ }
991
+ } else if (isRanged && f.side === 'player') {
992
+ // Player missing required ranged weapon — no MH fallback.
993
+ return;
994
+ } else {
995
+ // AoE weapon strikes (Whirlwind): primary uses the same SpellRadius gate as
996
+ // secondaries when radiusYd is set — mirrors castOnTargets (KI-650 sibling).
997
+ const primaryInRadius = !spell.aoe || aoeUnitInRadius(f, castTgt, castTgt, spell);
998
+ const landed = primaryInRadius ? weaponStrike(f, castTgt, spell) : false;
999
+ // Riders applied after a landed strike: buff, dot, cc, root, knock_back. `rawEffects` is
1000
+ // cleared on the shell or applySpell rebuilds the full list and double-awards combo points.
1001
+ const riders = spell.effects.filter(
1002
+ (e) => e.kind === 'buff' || e.kind === 'dot' || e.kind === 'cc' || e.kind === 'root'
1003
+ || e.kind === 'knock_back' || e.kind === 'modify_threat_pct'
1004
+ // Target-borne periodic-trigger debuffs (Nefarian's class calls) fan with the AoE;
1005
+ // channel holders (targetA CASTER) carry no auraOnTarget and stay off the rider.
1006
+ || (e.kind === 'proc_trigger_spell' && e.auraOnTarget === true),
1007
+ );
1008
+ const riderSpell = riders.length
1009
+ ? { ...spell, effects: riders, rawEffects: undefined }
1010
+ : null;
1011
+ if (landed && riderSpell) applySpell(f, castTgt, riderSpell);
1012
+ if (spell.aoe) {
1013
+ // Whirlwind shape: one cost on cast, a weapon strike per target, each carrying `spell` so
1014
+ // no attacker rage is rewarded. On the endless floor, free-fire only — not every passive.
1015
+ let pool = isEnemySide(f.side) ? livingFriendlies() : livingEnemies();
1016
+ if (cfg.endless && !isEnemySide(f.side)
1017
+ && !(spell.radiusYd != null && spell.radiusYd > 0)) {
1018
+ pool = pool.filter((e) => {
1019
+ if (e === castTgt) return true;
1020
+ const tm = threatOf(e); return !!tm && !tm.isEmpty();
1021
+ });
1022
+ }
1023
+ const cap = aoeTotalTargetCap(spell);
1024
+ const others = selectAoeSecondaries(f, castTgt, spell, pool, cap);
1025
+ for (const other of others) {
1026
+ const otherLanded = weaponStrike(f, other, spell);
1027
+ if (otherLanded && riderSpell) applySpell(f, other, riderSpell);
1028
+ }
1029
+ } else if (wantsChainFan(spell)) {
1030
+ // Multi-Shot / non-aoe chain strikes: jump nearby hostiles (not aoe splash).
1031
+ // Cite: chainFan.ts; Spell.cpp SCHEME_CLOSEST_CHAIN; EffectChainTarget.
1032
+ let pool = isEnemySide(f.side) ? livingFriendlies() : livingEnemies();
1033
+ if (cfg.endless && !isEnemySide(f.side)
1034
+ && !(spell.radiusYd != null && spell.radiusYd > 0)) {
1035
+ pool = pool.filter((e) => {
1036
+ if (e === castTgt) return true;
1037
+ const tm = threatOf(e); return !!tm && !tm.isEmpty();
1038
+ });
1039
+ }
1040
+ const cap = aoeTotalTargetCap(spell) ?? 1;
1041
+ const chain = selectHostileChainTargets(
1042
+ castTgt, pool.filter((u) => !dead(u)), cap, distanceBetween,
1043
+ CHAIN_SPELL_JUMP_RADIUS_YD, undefined,
1044
+ // Cleave and Swipe jump from the CASTER, not from the first target. Cite: Spell.cpp:2179
1045
+ // PUSH_SELF_CENTER; 11 carriers, all Cleave/Swipe ranks.
1046
+ hasAttrEx2(spell, SPELL_ATTR_EX2.CHAIN_FROM_CASTER) ? f : undefined,
1047
+ );
1048
+ for (let i = 1; i < chain.length; i++) {
1049
+ const other = chain[i]!;
1050
+ const otherLanded = weaponStrike(f, other, spell);
1051
+ if (otherLanded && riderSpell) applySpell(f, other, riderSpell);
1052
+ }
1053
+ }
1054
+ }
1055
+ } else {
1056
+ castOnTargets(f, castTgt, spell);
1057
+ }
1058
+ // The pin exists for exactly one cast: its effects have now applied (or fizzled).
1059
+ if (f.friendlyPin) f.friendlyPin = null;
1060
+ };
1061
+
1062
+ // Queue a missile flight when the spell has a speed and geometry is live, else impact now. Charge
1063
+ // travels as a body and is never queued. A session without geometry stays byte-identical.
1064
+ const deliverOrQueueMissile = (f: Fighter, castTgt: Fighter, spell: SpellDef): void => {
1065
+ // Local-combat backstop: an offensive spell with a MISSING baked rangeMax skips every range gate
1066
+ // and landed cross-map — Mortal Strike at 490 yd. Nothing offensive delivers past its cap.
1067
+ if (cfg.acquireRangeYd != null && ctx.unitPositionsMutable && !isChargeRole(spell)
1068
+ && !isSelfCenteredAoe(spell)) {
1069
+ const meleeShaped = !!spell.strikeBonus || !!spell.onNextSwing || roleOf(spell) === 'strike';
1070
+ if (meleeShaped || isHostileSpellDef(spell) || roleOf(spell) === 'shoot'
1071
+ || spellFoeDirectedOpaque(spell)) {
1072
+ const d = distanceBetween(f, castTgt);
1073
+ const cap = Math.max(
1074
+ meleeShaped && !((spell.rangeMax ?? 0) > 0) ? MIN_AGGRO_DISTANCE : cfg.acquireRangeYd,
1075
+ spell.rangeMax ?? 0,
1076
+ );
1077
+ if (d != null && d > cap) {
1078
+ f.castCritAdd = 0;
1079
+ f.castEffectPct = 0;
1080
+ return;
1081
+ }
1082
+ }
1083
+ }
1084
+ const critAdd = f.castCritAdd;
1085
+ const effectPct = f.castEffectPct;
1086
+ // Always clear fighter one-shots at "cast resolve" time; delayed rows hold a snapshot.
1087
+ f.castCritAdd = 0;
1088
+ f.castEffectPct = 0;
1089
+ // Pack wiped mid-cast: foeOf is self — fizzle single-target hostile (mana already spent).
1090
+ // Self-centered AoE still fans living hostiles around the caster (Frost Nova).
1091
+ if (isHostileSpellDef(spell) && (castTgt === f || dead(castTgt))
1092
+ && !isCasterOriginAoe(spell) && !f.castDest) return;
1093
+
1094
+ if (isChargeRole(spell) || !ctx.unitPositionsMutable) {
1095
+ f.castCritAdd = critAdd;
1096
+ f.castEffectPct = effectPct;
1097
+ deliverSpellImpact(f, castTgt, spell);
1098
+ f.castCritAdd = 0;
1099
+ f.castEffectPct = 0;
1100
+ return;
1101
+ }
1102
+ const speed = spell.speed ?? 0;
1103
+ if (!(speed > 0)) {
1104
+ f.castCritAdd = critAdd;
1105
+ f.castEffectPct = effectPct;
1106
+ deliverSpellImpact(f, castTgt, spell);
1107
+ f.castCritAdd = 0;
1108
+ f.castEffectPct = 0;
1109
+ return;
1110
+ }
1111
+ const dist = distanceBetween(f, castTgt);
1112
+ const travelMs = dist != null ? missileTravelMs(dist, speed) : 0;
1113
+ if (!(travelMs > 0)) {
1114
+ f.castCritAdd = critAdd;
1115
+ f.castEffectPct = effectPct;
1116
+ deliverSpellImpact(f, castTgt, spell);
1117
+ f.castCritAdd = 0;
1118
+ f.castEffectPct = 0;
1119
+ return;
1120
+ }
1121
+ ctx.pendingMissiles.push({
1122
+ launchAt: ctx.t, landAt: ctx.t + travelMs,
1123
+ casterSide: f.side, targetSide: castTgt.side, spell,
1124
+ castCritAdd: critAdd, castEffectPct: effectPct,
1125
+ });
1126
+ };
1127
+
1128
+ /** Land due missiles this tick (before motion so chill applies to the same gap-close step). */
1129
+ const landDueMissiles = (): void => {
1130
+ const list = ctx.pendingMissiles;
1131
+ if (!list.length) return;
1132
+ for (let i = list.length - 1; i >= 0; i--) {
1133
+ const m = list[i]!;
1134
+ if (ctx.t < m.landAt) continue;
1135
+ list.splice(i, 1);
1136
+ let caster: Fighter | undefined;
1137
+ let tgt: Fighter | undefined;
1138
+ caster = bySide(m.casterSide as Side);
1139
+ tgt = bySide(m.targetSide as Side);
1140
+ if (!caster || !tgt) continue;
1141
+ if (dead(caster) || dead(tgt)) continue;
1142
+ if (m.whiteRanged) {
1143
+ rangedStrike(caster, tgt, null);
1144
+ continue;
1145
+ }
1146
+ caster.castCritAdd = m.castCritAdd;
1147
+ caster.castEffectPct = m.castEffectPct;
1148
+ deliverSpellImpact(caster, tgt, m.spell);
1149
+ caster.castCritAdd = 0;
1150
+ caster.castEffectPct = 0;
1151
+ }
1152
+ };
1153
+
1154
+ /** Armed when the first enemy enters combat; fires once, like m_AlreadyCallAssistance. */
1155
+ let packAssistAt: number | null = null;
1156
+ /** The caller's victim, captured when the call goes out — the event carries it. */
1157
+ let packAssistOn: CombatantId | null = null;
1158
+ let packAssistCalled = false;
1159
+ // The pack joins when the mob you hit CALLS it, never at t=0. `packAssistForPull` already picked
1160
+ // who; before this, two helpers nobody had touched sprinted 27 yd with the opener still in the air.
1161
+ const packAssistPhase = (): void => {
1162
+ const delay = cfg.packAssistDelayMs;
1163
+ if (delay == null || packAssistCalled || cfg.endless) return;
1164
+ const threatEmpty = (ef: Fighter): boolean => {
1165
+ const tm = threatOf(ef);
1166
+ return !tm || tm.isEmpty();
1167
+ };
1168
+ if (packAssistAt == null) {
1169
+ let caller: Fighter | null = null;
1170
+ let idle = false;
1171
+ for (const ef of ctx.livingEnemies()) {
1172
+ if (threatEmpty(ef)) idle = true;
1173
+ else if (!caller) caller = ef;
1174
+ }
1175
+ if (!caller) return; // nobody in combat: no call went out
1176
+ if (!idle) { packAssistCalled = true; return; } // nothing left to call
1177
+ // The event carries the caller's victim and is queued NOW — killing the caller
1178
+ // inside the delay does not recall it (Creature.cpp:2163 AssistDelayEvent).
1179
+ const victim = hostileTargetOf(caller);
1180
+ packAssistOn = victim && victim !== caller ? victim.side : 'player';
1181
+ packAssistAt = ctx.t + delay;
1182
+ return;
1183
+ }
1184
+ if (ctx.t < packAssistAt) return;
1185
+ packAssistCalled = true;
1186
+ const on = packAssistOn ?? 'player';
1187
+ for (const ef of ctx.livingEnemies()) {
1188
+ if (!threatEmpty(ef)) continue;
1189
+ const tm = threatOf(ef);
1190
+ if (tm) {
1191
+ tm.addThreatDirectly(on, 1, false);
1192
+ wakeForDecision(ef);
1193
+ }
1194
+ }
1195
+ };
1196
+
1197
+ const chosenPlayerSpell = (
1198
+ castSpellId: number | null | undefined,
1199
+ dest?: CombatPosYd,
1200
+ ): SpellDef | null => {
1201
+ if (castSpellId == null) return null;
1202
+ const s = spellFromKit(player.spec.spells, castSpellId);
1203
+ if (!s || onCooldown(player, s) || !canAfford(player, s)) return null;
1204
+ // Re-attached trigger and proc payloads resolve for the applySpell lookup but are never cast by
1205
+ // the player — their parents apply them. Cite: pickAction isTriggeredCompanion.
1206
+ if (isTriggeredCompanion(player, s)) return null;
1207
+ // Ruthlessness-style free talent CP grants — never player-cast (KI-691).
1208
+ if (isNonCastableTalentProc(s)) return null;
1209
+ if (schoolLocked(player, s)) return null;
1210
+ const offSide = ctx.rotationOffTarget?.get(player.side);
1211
+ const offAim = offSide ? bySide(offSide) : null;
1212
+ const focus = offAim && !dead(offAim) ? offAim : ensurePlayerFocus();
1213
+ const groundDest = isGroundTargetedMulti(s) && dest != null;
1214
+ // Endless floor stand-down: focus is the caster. Hostile casts need a live foe.
1215
+ // Cite: pickAction needsLiveHostile; Spell.cpp SPELL_FAILED_BAD_TARGETS.
1216
+ if (!groundDest && isHostileSpellDef(s) && !isSelfCenteredAoe(s) && (focus === player || (dead(focus) && !hasAttrEx2(s, SPELL_ATTR_EX2.ALLOW_DEAD_TARGET)))) {
1217
+ if (!invalidTargetReported.has(s.id)) {
1218
+ invalidTargetReported.add(s.id);
1219
+ if (!hasAttrEx2(s, SPELL_ATTR_EX2.DO_NOT_REPORT_SPELL_FAILURE)) {
1220
+ push({ t: ctx.t, kind: 'flavor', text: `${s.name}: No target.` });
1221
+ }
1222
+ }
1223
+ return null;
1224
+ }
1225
+ // CheckTargetCreatureType: refuse the cast up front, so no mana or GCD is wasted. One log line
1226
+ // per spell per fight, so a rotation retrying it does not spam.
1227
+ if (!groundDest && isHostileSpellDef(s) && !isSelfCenteredAoe(s) && !spellTargetTypeAllowed(s, focus.creatureType)) {
1228
+ if (!invalidTargetReported.has(s.id)) {
1229
+ invalidTargetReported.add(s.id);
1230
+ if (!hasAttrEx2(s, SPELL_ATTR_EX2.DO_NOT_REPORT_SPELL_FAILURE)) {
1231
+ push({ t: ctx.t, kind: 'flavor', text: `${s.name}: invalid target.` });
1232
+ }
1233
+ }
1234
+ return null;
1235
+ }
1236
+ // CheckCast SPELL_FAILED_IMMUNE: refuse before the cast, including weapon strikes that are not
1237
+ // hostile spell defs. A forced impact still reports immune in applySpell if one slips through.
1238
+ const nLive = (isSelfCenteredAoe(s) || groundDest) ? livingEnemies().length : 1;
1239
+ if (
1240
+ (!groundDest || nLive <= 1)
1241
+ && focusImmunityBlocksSpell(s, focus, nLive)
1242
+ ) {
1243
+ if (!invalidTargetReported.has(s.id)) {
1244
+ invalidTargetReported.add(s.id);
1245
+ if (!hasAttrEx2(s, SPELL_ATTR_EX2.DO_NOT_REPORT_SPELL_FAILURE)) {
1246
+ push({ t: ctx.t, kind: 'flavor', text: `${focus.spec.name} is immune to ${s.name}.` });
1247
+ }
1248
+ }
1249
+ return null;
1250
+ }
1251
+ // FS3: pet-only heals/buffs (Mend Pet, Bestial Wrath shape) — no living pet → bad target.
1252
+ if (requiresLivingPetTarget(s) && !livingPetOf(player)) {
1253
+ if (!invalidTargetReported.has(s.id)) {
1254
+ invalidTargetReported.add(s.id);
1255
+ if (!hasAttrEx2(s, SPELL_ATTR_EX2.DO_NOT_REPORT_SPELL_FAILURE)) {
1256
+ push({ t: ctx.t, kind: 'flavor', text: `${s.name}: invalid target.` });
1257
+ }
1258
+ }
1259
+ return null;
1260
+ }
1261
+ if (s.requiresComboPoints && comboOn(player, focus) <= 0) return null;
1262
+ if (isNextMeleeSwingSpell(s) && player.pendingSwing) return null; // only one on-next-swing armed at a time
1263
+ // Player EquippedItem full slot gate (same helper as abilities()).
1264
+ // Cite: Spell.cpp:6539–6543; equippedItemGate.ts.
1265
+ if (playerMissingRequiredEquippedItem(s, player.spec)) return null;
1266
+ // Spell.cpp CheckItems — refuse Shadowburn etc. with no Soul Shard (KI-804).
1267
+ if (!canPaySpellReagents(player, s)) return null;
1268
+ const gate = resolveFormCast(s, player.form);
1269
+ if (!gate.ok) return null;
1270
+ if (!groundDest && stealthPosError(player, focus, s)) return null;
1271
+ // DG: player spell picker respects SpellRange when yards are seeded.
1272
+ // Dest-bearing ground rain measures caster → dest, not caster → focus.
1273
+ if (isChargeRole(s)
1274
+ ? !chargeCastOk(player, focus, s)
1275
+ : (spellNeedsRangeGate(s) || spellFoeDirectedGeometry(s))
1276
+ && !isInSpellRange(
1277
+ groundDest ? combatDistanceYd(ctx.posOf(player.side), dest) : distanceBetween(player, focus),
1278
+ spellGateRangeMaxYd(s),
1279
+ spellGateRangeMinYd(s),
1280
+ !groundDest && ctx.isMoving(player.side) && ctx.isMoving(focus.side),
1281
+ player.spec.combatReachYd, focus.spec.combatReachYd,
1282
+ )) {
1283
+ return null;
1284
+ }
1285
+ // Refuse a cast through a wall when navLos is on. Off-mask ends fail open inside
1286
+ // navSightClear — a mob half a yard into a wall column must stay killable.
1287
+ if (cfg.navLos && spellNeedsLosGate(s)) {
1288
+ const destSight = groundDest ? dest : posOf(focus.side);
1289
+ if (!navSightClear(cfg, posOf(player.side), destSight)) return null;
1290
+ }
1291
+ if (s.condition || (s.conditionId != null && s.conditionId !== 0)) {
1292
+ if (!passesConditionGate(s, conditionSnapshotFor(player), cfg.conditionTable).ok) return null;
1293
+ }
1294
+ // Mongoose Bite / Overpower / Revenge — CasterAuraState / reactive windows.
1295
+ // Cite: Player.cpp IsSpellReady; UnitAuraProcHandler ProcSkillsAndReactives.
1296
+ if (reactiveCastGate(player, focus, s, ctx.t) != null) return null;
1297
+ if (targetCastGate(player, focus, s, ctx.t) != null) return null;
1298
+ // Classic pure-DISPEL CheckCast: NOTHING_TO_DISPEL (Purge / Remove Curse / Cure*).
1299
+ // Cite: Spell.cpp:4890–4919; KI-610.
1300
+ if (isPureDispelSpell(s)) {
1301
+ const de = firstDispelEffect(s);
1302
+ if (de && resolveDispelTarget(player, focus, de.dispelType) == null) return null;
1303
+ }
1304
+ // leaveForm applied at startOrCast time so cost checks use pre-leave mana correctly
1305
+ return s;
1306
+ };
1307
+
1308
+ // Decision gating: skip pickAction when the unit is not dirty and its wake time has not come. An
1309
+ // off-GCD next-swing still arms on the skip path, and item rules wake on HP or aura dirty.
1310
+ const KIT_NONE: SpellDef[] = [];
1311
+ const kitOffGcdCache = new WeakMap<SpellDef[], { len: number; list: SpellDef[] }>();
1312
+ /** Bake-constant: kit has an on-next-swing arm (HS / Cleave / Maul / …). */
1313
+ const kitHasNextSwing = (f: Fighter): boolean => {
1314
+ const spells = f.spec.spells ?? KIT_NONE;
1315
+ const hit = kitOffGcdCache.get(spells);
1316
+ if (hit !== undefined && hit.len === spells.length) return hit.list.length > 0;
1317
+ const list = spells.filter((s) => isNextMeleeSwingSpell(s));
1318
+ kitOffGcdCache.set(spells, { len: spells.length, list });
1319
+ return list.length > 0;
1320
+ };
1321
+ // Sides carrying at least one item, refreshed once per tick. Rescue-item CC break still needs this; item
1322
+ // *rotation* rules no longer force a think.
1323
+ let bagScanTick = -1;
1324
+ const itemSides = new Set<Side>();
1325
+ const sidesWithItems = (): Set<Side> => {
1326
+ if (bagScanTick === ctx.t) return itemSides;
1327
+ itemSides.clear();
1328
+ if (ctx.playerItems.length > 0) itemSides.add(player.side);
1329
+ for (const [side, bag] of ctx.unitItems) if (bag.length > 0) itemSides.add(side);
1330
+ bagScanTick = ctx.t;
1331
+ return itemSides;
1332
+ };
1333
+ // These four were WeakMaps keyed on the Fighter, whose lifetime is the Fighter's — a field written
1334
+ // the long way round, at four map lookups per unit per tick.
1335
+ const belowHealByFac = new Map<string, Set<string>>();
1336
+ const inCombatByFac = new Map<string, Set<string>>();
1337
+ // The spare half of each pair: a rebuild fills the spare, compares, then swaps the references, so
1338
+ // an unchanged answer costs no allocation. Both are per FACTION and two samples can share one.
1339
+ const belowHealSpare = new Map<string, Set<string>>();
1340
+ const inCombatSpare = new Map<string, Set<string>>();
1341
+ const samples: Fighter[] = [];
1342
+ /** Cheap O(N) once per tick — not a livingHostilesFor fingerprint. */
1343
+ const refreshCheapDirty = (): void => {
1344
+ for (const f of fightersOrder()) {
1345
+ if (dead(f)) continue;
1346
+ if (f.seenDecideHp !== f.hp) {
1347
+ const dropped = f.hp < f.seenDecideHp;
1348
+ f.seenDecideHp = f.hp;
1349
+ // OOC regen toward full is not a decision input; damage and in-combat changes are. A unit
1350
+ // under ALLY_HEAL_THRESHOLD keeps waking: its OOC self-heal may have been refused on mana.
1351
+ const hurt = f.hp / Math.max(1, f.spec.maxHealth) < ALLY_HEAL_THRESHOLD;
1352
+ if (dropped || hurt || fighterIsInCombat(f, ctx.t)) f.decideDirty = true;
1353
+ }
1354
+ if (f.seenDodgedAt !== f.lastDodgedAt) {
1355
+ f.decideDirty = true;
1356
+ f.seenDodgedAt = f.lastDodgedAt;
1357
+ }
1358
+ if (f.seenDefenseReactAt !== f.lastDefenseReactAt) {
1359
+ f.decideDirty = true;
1360
+ f.seenDefenseReactAt = f.lastDefenseReactAt;
1361
+ }
1362
+ if (f.seenHunterParryAt !== f.lastHunterParryAt) {
1363
+ f.decideDirty = true;
1364
+ f.seenHunterParryAt = f.lastHunterParryAt;
1365
+ }
1366
+ }
1367
+ samples.length = 0;
1368
+ samples.push(player);
1369
+ // The block's first seat, not `ctx.enemy`: that field is captured once at construction
1370
+ // (createSession.ts:148) and a recycled seat 0 leaves it pointing at the corpse. E1.1.
1371
+ const firstFoe = ctx.enemyBlock()[0];
1372
+ if (firstFoe) samples.push(firstFoe);
1373
+ // One pool pass answers both questions. They were two loops over the same pool, so a 40-man
1374
+ // walked 40 allies four times a tick. Interleaving is exact: both bodies read, both only mark.
1375
+ for (const sample of samples) {
1376
+ const pool = ctx.friendlyPoolOf(sample);
1377
+ const fac = ctx.factionOf(sample.side);
1378
+ const prevBelow = belowHealByFac.get(fac);
1379
+ const prevFighting = inCombatByFac.get(fac);
1380
+ let below = belowHealSpare.get(fac);
1381
+ if (!below) { below = new Set<string>(); belowHealSpare.set(fac, below); }
1382
+ let fighting = inCombatSpare.get(fac);
1383
+ if (!fighting) { fighting = new Set<string>(); inCombatSpare.set(fac, fighting); }
1384
+ below.clear();
1385
+ fighting.clear();
1386
+ for (const u of pool) {
1387
+ if (isTotemSide(u.side)) continue;
1388
+ if (u.hp / Math.max(1, u.spec.maxHealth) < ALLY_HEAL_THRESHOLD) below.add(u.side);
1389
+ if (fighterIsInCombat(u, ctx.t)) fighting.add(u.side);
1390
+ }
1391
+ belowHealByFac.set(fac, below);
1392
+ belowHealSpare.set(fac, prevBelow ?? new Set<string>());
1393
+ inCombatByFac.set(fac, fighting);
1394
+ inCombatSpare.set(fac, prevFighting ?? new Set<string>());
1395
+ if (prevBelow) {
1396
+ let crossed = prevBelow.size !== below.size;
1397
+ if (!crossed) {
1398
+ for (const s of below) if (!prevBelow.has(s)) { crossed = true; break; }
1399
+ }
1400
+ if (crossed) {
1401
+ for (const u of pool) {
1402
+ if (u.spec.groupRole === 'healer') u.decideDirty = true;
1403
+ }
1404
+ }
1405
+ }
1406
+ // Combat enter listed as an S2 dirty, but touchCombatFlag only marks THAT body.
1407
+ // A full-HP mage with nextWakeAt=Inf never joins the pull until they take a hit.
1408
+ if (prevFighting) {
1409
+ let entered = false;
1410
+ for (const s of fighting) if (!prevFighting.has(s)) { entered = true; break; }
1411
+ if (entered) for (const u of pool) wakeForDecision(u);
1412
+ }
1413
+ }
1414
+ };
1415
+ /** Cached per tick: an enemy is holding an interruptible bar. */
1416
+ let interruptibleCastTick = -1;
1417
+ let interruptibleCastLive = false;
1418
+ const anyInterruptibleHostileCast = (): boolean => {
1419
+ if (interruptibleCastTick === ctx.t) return interruptibleCastLive;
1420
+ interruptibleCastTick = ctx.t;
1421
+ interruptibleCastLive = false;
1422
+ for (const ef of ctx.livingEnemies()) {
1423
+ if (ef.castingSpell && isCastInterruptible(ef.castingSpell, true)) {
1424
+ interruptibleCastLive = true;
1425
+ break;
1426
+ }
1427
+ if (
1428
+ ef.channeling
1429
+ && ctx.t < ef.channeling.endsAt
1430
+ && isCastInterruptible(ef.channeling.spell, true)
1431
+ ) {
1432
+ interruptibleCastLive = true;
1433
+ break;
1434
+ }
1435
+ }
1436
+ return interruptibleCastLive;
1437
+ };
1438
+ // Off-GCD kicker with a ready instant interrupt while a hostile bar is up, cached per kit array:
1439
+ // both filter halves are SpellDef fields, and the walk ran per friendly per tick of every boss bar.
1440
+ const kitInterruptCache = new WeakMap<SpellDef[], { len: number; list: SpellDef[] }>();
1441
+ const kitInstantInterrupts = (f: Fighter): SpellDef[] => {
1442
+ const spells = f.spec.spells ?? KIT_NONE;
1443
+ const hit = kitInterruptCache.get(spells);
1444
+ if (hit !== undefined && hit.len === spells.length) return hit.list;
1445
+ const list = spells.filter((s) => spellHasInterruptCast(s) && (s.castTimeMs ?? 0) <= 0);
1446
+ kitInterruptCache.set(spells, { len: spells.length, list });
1447
+ return list;
1448
+ };
1449
+ const friendlyCanAttemptInterrupt = (f: Fighter): boolean => {
1450
+ if (isEnemySide(f.side)) return false;
1451
+ if (ctx.t < f.gcdUntil) return false;
1452
+ const kit = kitInstantInterrupts(f);
1453
+ // Ahead of the hostile-bar query on purpose: a unit with no kicker never needs the answer, and
1454
+ // that query walks every living enemy the first time it is asked in a tick.
1455
+ if (kit.length === 0) return false;
1456
+ if (!anyInterruptibleHostileCast()) return false;
1457
+ for (const s of kit) {
1458
+ if (onCooldown(f, s) || !canAfford(f, s)) continue;
1459
+ return true;
1460
+ }
1461
+ return false;
1462
+ };
1463
+ const mustDecide = (f: Fighter, action?: CombatAction): boolean => {
1464
+ if (f.pendingCharge) return false;
1465
+ const midCast = f.castingSpell != null
1466
+ || (f.channeling != null && ctx.t < f.channeling.endsAt);
1467
+ // Enemies may pick CAST_INTERRUPT_PREVIOUS. Cite: UnitAI.h CAST_INTERRUPT_PREVIOUS.
1468
+ if (midCast && isEnemySide(f.side)) return true;
1469
+ if (isControlledHard(f)) {
1470
+ return hardCcMustDecide(
1471
+ f,
1472
+ !!(cfg.autoRescueItems && sidesWithItems().has(f.side)),
1473
+ isBreaker,
1474
+ onCooldown,
1475
+ canAfford,
1476
+ );
1477
+ }
1478
+ // A bar press arrives as `action` from nextCast. Always-thinking the manual player
1479
+ // defeated SlowUpdate (profiler.md Maraudon: combat.sim leftover 26.6%, pickAction every tick).
1480
+ if (action != null && f === player) return true;
1481
+ // Ready kicker: think now even if SlowUpdate parked us, and even mid-cast so we can clip.
1482
+ if (friendlyCanAttemptInterrupt(f)) return true;
1483
+ // Queued Kick/CS must wake mid-cast or the clip never runs (kickClipKeepsCast).
1484
+ if ((allCastOrders()?.get(f.side)?.length ?? 0) > 0) return true;
1485
+ // Arena rotation-driven friendlies still think mid-cast so a higher instant can replace the bar.
1486
+ // Cite: Unit.cpp:4203-4212 SetCurrentCastedSpell. Raids keep sleeping the bar (SlowUpdate).
1487
+ if (midCast) {
1488
+ if (
1489
+ cfg.enemiesAreATeam === true
1490
+ && !isEnemySide(f.side)
1491
+ && ((f === player && ctx.aiPlayer) || ctx.unitActionProvider.get(f.side))
1492
+ ) {
1493
+ return true;
1494
+ }
1495
+ return false;
1496
+ }
1497
+ if (f.stealthed) return true;
1498
+ if (f.decideDirty === true) return true;
1499
+ if (ctx.t < (f.nextWakeAt ?? 0)) return false;
1500
+ return true;
1501
+ };
1502
+ const skipsDecision = (f: Fighter, action?: CombatAction): boolean => !mustDecide(f, action);
1503
+
1504
+ const step = (action?: CombatAction): CombatEvent[] => {
1505
+ if (ctx.ended) return [];
1506
+ const startLen = events.length;
1507
+ if (events.length === 0) {
1508
+ // The start event stays primary-enemy shaped for the UI; pack size is flavour above one. A
1509
+ // floor session has no primary: the event still fires for its playerHp seed and drops the name.
1510
+ push({
1511
+ t: 0,
1512
+ kind: 'start',
1513
+ playerName: player.spec.name,
1514
+ playerHp: player.hp,
1515
+ ...(enemy ? { enemyName: enemy.spec.name, enemyHp: enemy.hp } : {}),
1516
+ });
1517
+ // Off the store, which is the only roster there is now. E1.1.
1518
+ const foes = ctx.enemyBlock();
1519
+ if (foes.length > 1) {
1520
+ push({
1521
+ t: 0,
1522
+ kind: 'flavor',
1523
+ text: `${foes.length} foes engage (${foes.map((e) => e.spec.name).join(', ')}).`,
1524
+ });
1525
+ }
1526
+ const mates = ctx.allyBlock();
1527
+ if (mates.length > 0) {
1528
+ push({
1529
+ t: 0,
1530
+ kind: 'flavor',
1531
+ text: `${mates.map((a) => a.spec.name).join(', ')} fight${mates.length === 1 ? 's' : ''} alongside ${player.spec.name}.`,
1532
+ });
1533
+ }
1534
+ }
1535
+
1536
+ // S3: skip totem / pool / effects sweeps until the next due instant.
1537
+ // Cite: EventProcessor.cpp:39; SpellAuras.cpp:396–406 Aura::Update.
1538
+ if (ctx.t >= ctx.nextPeriodicDue) {
1539
+ combatPerfEnter('sim.periodic');
1540
+ // Totem duration expiry (CMaNGOS Totem::Update m_duration).
1541
+ for (const [side, tot] of [...totems.entries()]) {
1542
+ if (tot && tot.totemExpiresAt != null && ctx.t >= tot.totemExpiresAt) unsummonTotem(side, 'expire');
1543
+ }
1544
+
1545
+ // DynamicObject::Update — visit/apply/refresh auras, pulse, expire.
1546
+ // Cite: DynamicObject.cpp:122 VisitAllObjects; groundPoolTick.ts.
1547
+ tickGroundPools(ctx, dead);
1548
+
1549
+ // SpellAuraHolder::Update — a negative aura with HEARTBEAT_RESIST rolls to break early on its own
1550
+ // interval. No armed holder means no draw, so goldens do not move. Cite: SpellAuras.cpp:5713.
1551
+ ctx.tickHeartbeatResists?.();
1552
+
1553
+ // COOLDOWN_ON_EVENT: start the real cooldown once the aura is gone. A poll, not a hook — this
1554
+ // engine has many removal paths and `buffActive` is the one flag every one of them maintains.
1555
+ const startFadedCooldowns = (f: Fighter): void => {
1556
+ const pending = f.deferredCooldowns;
1557
+ if (!pending || pending.size === 0) return;
1558
+ for (const [spellId, ms] of pending) {
1559
+ const stillUp = f.buffActive.has(spellId)
1560
+ || ctx.effectsOn(f.side).some((e) => e.spellId === spellId && !e.broken
1561
+ && e.expiresAt > ctx.t);
1562
+ if (stillUp) continue;
1563
+ f.cooldowns.set(spellId, ctx.t + ms);
1564
+ pending.delete(spellId);
1565
+ const cat = f.deferredCategoryCooldowns?.get(spellId);
1566
+ if (cat) {
1567
+ f.categoryCooldowns.set(cat.category, ctx.t + cat.ms);
1568
+ f.deferredCategoryCooldowns!.delete(spellId);
1569
+ }
1570
+ }
1571
+ };
1572
+ // Every unit, not player + enemies + allies + pet: that list left TOTEMS out, so a totem
1573
+ // holding a deferred COOLDOWN_ON_EVENT entry never started it. No-op for a unit with none.
1574
+ for (const f of fightersOrder()) startFadedCooldowns(f);
1575
+
1576
+ // Aura-removal bodies fire AFTER the sweep: their payloads push and splice `effects`, which this
1577
+ // reverse walk cannot survive mid-pass. Cite: dispelReflect fireAuraRemoveBodies.
1578
+ const removalBodies: Array<[ActiveEffect, 'expire' | 'death']> = [];
1579
+ for (let i = effects.length - 1; i >= 0; i--) {
1580
+ const e = effects[i];
1581
+ // removeAurasDueToSpell mid-pass can shrink the array while i walks downward.
1582
+ if (!e) continue;
1583
+ // Holder died while the aura still had time on it (Anub'Rekhan's Aura → Corpse Scarabs).
1584
+ // Cite: SpellAuras.cpp:1425-1430 AURA_REMOVE_BY_DEATH.
1585
+ const eHolder = bySide(e.target);
1586
+ const isDeathPersistent = hasAttrEx3(ctx.spellOf?.(e.spellId), SPELL_ATTR_EX3.ALLOW_AURA_WHILE_DEAD);
1587
+ if (e.onRemove && ctx.t < e.expiresAt && dead(eHolder) && !isDeathPersistent) {
1588
+ removalBodies.push([e, 'death']);
1589
+ effects.splice(i, 1);
1590
+ continue;
1591
+ }
1592
+ if (
1593
+ e.kind === 'dot'
1594
+ && e.nextTickAt !== undefined
1595
+ // > 0, not !== undefined: `nextTickAt += 0` never advances and this while never exits.
1596
+ && e.tickMs !== undefined && e.tickMs > 0
1597
+ && e.fromDynamicObject == null
1598
+ ) {
1599
+ while (e.nextTickAt <= ctx.t && e.nextTickAt < e.expiresAt + 1) {
1600
+ const tgt = bySide(e.target);
1601
+ const src = bySide(e.source);
1602
+ // SPELL_AURA_PERIODIC_HEALTH_FUNNEL (62): victim = caster, beneficiary = aura target.
1603
+ // Cite: SpellAuras.cpp PeriodicTick :4606–4612, :4661–4683.
1604
+ if (e.healthFunnel) {
1605
+ if (src && !dead(src)) {
1606
+ const amount = dealDamage(
1607
+ src,
1608
+ rollBetween(e.min ?? 0, e.max ?? 0, rng.next()),
1609
+ e.school ?? 'physical',
1610
+ src.spec.level,
1611
+ { binary: e.binary ?? true, damageType: 'dot', attacker: src },
1612
+ );
1613
+ push({
1614
+ t: ctx.t, kind: 'dot_tick', actor: e.source, target: src.side, spellId: e.spellId,
1615
+ name: e.name, amount, school: e.school ?? 'physical',
1616
+ });
1617
+ const mult = e.leechMultiple != null && e.leechMultiple > 0 ? e.leechMultiple : 1;
1618
+ if (amount > 0 && tgt && !dead(tgt)) {
1619
+ const heal = Math.trunc(amount * mult);
1620
+ if (heal > 0) {
1621
+ const hpBefore = tgt.hp;
1622
+ tgt.hp = Math.min(tgt.spec.maxHealth, Math.round(tgt.hp + heal));
1623
+ const effective = tgt.hp - hpBefore;
1624
+ const overheal = Math.max(0, heal - effective);
1625
+ push({
1626
+ t: ctx.t, kind: 'heal', actor: tgt.side, amount: effective,
1627
+ name: e.name, spellId: e.spellId, periodic: true,
1628
+ ...(e.source !== tgt.side ? { source: e.source } : {}),
1629
+ ...(overheal > 0 ? { overheal } : {}),
1630
+ });
1631
+ }
1632
+ }
1633
+ }
1634
+ } else {
1635
+ // Pass spell id + family flags so Dimension-C procs (Pyroclasm) see Hellfire/RoF ticks.
1636
+ // Cite: UnitAuraProcHandler.cpp:1355–1362; ActiveEffect.spellFamilyFlags.
1637
+ const dotSpellStub = {
1638
+ id: e.spellId,
1639
+ name: e.name,
1640
+ school: e.school ?? 'physical',
1641
+ spellFamilyFlags: e.spellFamilyFlags,
1642
+ } as SpellDef;
1643
+ let tickAmt = rollBetween(e.min ?? 0, e.max ?? 0, rng.next());
1644
+ if (isCurseOfAgonySpellId(e.spellId)) {
1645
+ e.auraTicks = (e.auraTicks ?? 0) + 1;
1646
+ tickAmt = curseOfAgonyTickAmount(tickAmt, e.auraTicks);
1647
+ }
1648
+ const amount = dealDamage(
1649
+ tgt,
1650
+ tickAmt,
1651
+ e.school ?? 'physical',
1652
+ src.spec.level,
1653
+ { binary: e.binary ?? true, damageType: 'dot', attacker: src, spell: dotSpellStub },
1654
+ );
1655
+ // DoT ticks still cause threat via dealDamage (Unit.cpp HandleDamageDealt; damagetype DOT).
1656
+ push({ t: ctx.t, kind: 'dot_tick', actor: e.source, target: tgt.side, spellId: e.spellId, name: e.name, amount, school: e.school ?? 'physical' });
1657
+ // PERIODIC_LEECH: heal the caster for the damage dealt times the effect multiplier. The
1658
+ // victim is the aura target and the beneficiary the caster; that heal carries no threat.
1659
+ if (
1660
+ e.leechMultiple != null
1661
+ && e.leechMultiple > 0
1662
+ && amount > 0
1663
+ && src
1664
+ && !dead(src)
1665
+ ) {
1666
+ const heal = Math.trunc(amount * e.leechMultiple);
1667
+ if (heal > 0) {
1668
+ const hpBefore = src.hp;
1669
+ src.hp = Math.min(src.spec.maxHealth, Math.round(src.hp + heal));
1670
+ const effective = src.hp - hpBefore;
1671
+ const overheal = Math.max(0, heal - effective);
1672
+ push({
1673
+ t: ctx.t, kind: 'heal', actor: src.side, amount: effective,
1674
+ name: e.name, spellId: e.spellId, periodic: true,
1675
+ ...(overheal > 0 ? { overheal } : {}),
1676
+ });
1677
+ }
1678
+ }
1679
+ }
1680
+ e.nextTickAt += e.tickMs;
1681
+ }
1682
+ }
1683
+ // Healing Stream / Mana Spring periodic ticks, hostile mana-leech (Viper Sting), and
1684
+ // OBS_MOD_HEALTH/MANA percent-of-max regen (Demonic Sacrifice Voidwalker/Felhunter).
1685
+ if ((e.kind === 'restore_health' || e.kind === 'restore_power') && e.nextTickAt !== undefined && e.tickMs !== undefined && e.tickMs > 0) {
1686
+ while (e.nextTickAt <= ctx.t && e.nextTickAt < e.expiresAt + 1) {
1687
+ const tgt = bySide(e.target);
1688
+ // Frenzied Regeneration: rage→health per tick (not flat max HoT).
1689
+ // Cite: SpellAuras.cpp:1104–1115; warrior-druid-kit frenziedRegenTick.
1690
+ if (
1691
+ e.kind === 'restore_health'
1692
+ && FRENZIED_REGEN_SPELL_IDS.has(e.spellId | 0)
1693
+ && tgt.spec.powerType === 'rage'
1694
+ && !dead(tgt)
1695
+ ) {
1696
+ const lifePerRage = frenziedRegenLifePerRage(e.amountPerTick ?? 0);
1697
+ const { heal, displayRageSpent } = frenziedRegenTick(lifePerRage, tgt.power);
1698
+ if (displayRageSpent > 0) {
1699
+ const before = tgt.power;
1700
+ tgt.power = Math.max(0, tgt.power - displayRageSpent);
1701
+ if (tgt.power !== before) {
1702
+ push({
1703
+ t: ctx.t, kind: 'resource', actor: tgt.side, power: tgt.spec.powerType,
1704
+ value: Math.round(tgt.power), delta: tgt.power - before,
1705
+ });
1706
+ }
1707
+ }
1708
+ if (heal > 0) {
1709
+ const hpBefore = tgt.hp;
1710
+ tgt.hp = Math.min(tgt.spec.maxHealth, Math.round(tgt.hp + heal));
1711
+ const effective = tgt.hp - hpBefore;
1712
+ const overheal = Math.max(0, heal - effective);
1713
+ push({
1714
+ t: ctx.t, kind: 'heal', actor: tgt.side, amount: effective,
1715
+ name: e.name, spellId: e.spellId, periodic: true,
1716
+ ...(overheal > 0 ? { overheal } : {}),
1717
+ });
1718
+ }
1719
+ e.nextTickAt += e.tickMs;
1720
+ continue;
1721
+ }
1722
+ // Nitrous Boost 27746: under 10 mana the aura is removed outright, which strips the speed
1723
+ // aura and the maintain drain together. Cite: SpellAuras.cpp:1031-1042.
1724
+ if (
1725
+ e.kind === 'restore_power'
1726
+ && (e.spellId | 0) === NITROUS_BOOST_SPELL_ID
1727
+ && !dead(tgt)
1728
+ && powerOfType(tgt, POWER_MANA) < NITROUS_MANA_COST_PER_TICK
1729
+ ) {
1730
+ ctx.removeAurasDueToSpell?.(tgt, NITROUS_BOOST_SPELL_ID);
1731
+ // Holder gone (speed + drain). Skip remaining tick body and outer checks on e.
1732
+ i = -1; // end reverse for — array indices shifted by multi-row splice
1733
+ break;
1734
+ }
1735
+ // pctOfMax: floor(max × pct / 100); else flat amountPerTick.
1736
+ // Cite: SpellAuras.cpp PeriodicTick OBS_MOD_HEALTH / OBS_MOD_MANA.
1737
+ // The pool a restore_power row names, never the bearer's primary bar: Mana Spring on a
1738
+ // warrior fills nothing, and on a bear druid it fills the parallel mana. A row with no
1739
+ // powerType is an older bake and reads as mana. Cite: SpellAuras.cpp:4833-4844.
1740
+ const pwr = e.kind === 'restore_power' ? (e.powerType ?? POWER_MANA) | 0 : POWER_MANA;
1741
+ const maxPwr = e.kind === 'restore_power' ? maxPowerOfType(tgt, pwr) : 0;
1742
+ const raw = e.pctOfMax != null && e.pctOfMax > 0
1743
+ ? resolvePeriodicPctOfMax({
1744
+ maxResource: e.kind === 'restore_health' ? tgt.spec.maxHealth : maxPwr,
1745
+ pctOfMax: e.pctOfMax,
1746
+ })
1747
+ : Math.round(e.amountPerTick ?? 0);
1748
+ if (e.kind === 'restore_health') {
1749
+ // Skip corpse ticks — a 0 HP unit must stay dead until checkDeaths ends the fight.
1750
+ // Flat HoTs keep min-1; pure %-of-max may legitimately compute 0 (no heal event).
1751
+ const amt = e.pctOfMax != null && e.pctOfMax > 0 ? raw : Math.max(1, raw);
1752
+ if (!dead(tgt) && Number.isFinite(amt) && amt > 0) {
1753
+ const hpBefore = tgt.hp;
1754
+ const intended = Math.round(amt);
1755
+ tgt.hp = Math.min(tgt.spec.maxHealth, Math.round(tgt.hp + amt));
1756
+ const effective = tgt.hp - hpBefore;
1757
+ const overheal = Math.max(0, intended - effective);
1758
+ push({
1759
+ t: ctx.t, kind: 'heal', actor: tgt.side, amount: effective,
1760
+ name: e.name, spellId: e.spellId, periodic: true,
1761
+ ...(e.source !== tgt.side ? { source: e.source } : {}),
1762
+ ...(overheal > 0 ? { overheal } : {}),
1763
+ });
1764
+ }
1765
+ } else if (!dead(tgt) && maxPwr > 0) {
1766
+ // Signed: positive restore (Mana Spring); negative = PERIODIC_MANA_LEECH drain.
1767
+ const before = powerOfType(tgt, pwr);
1768
+ if (raw < 0) {
1769
+ const drain = Math.min(before, Math.abs(raw));
1770
+ setPowerOfType(tgt, pwr, before - drain);
1771
+ // Drain Mana: transfer drained × leechMultiple to caster. Viper Sting has no mult.
1772
+ // Cite: SpellAuras.cpp:4747–4806; schema restore_power.leechMultiple; KI-702.
1773
+ if (
1774
+ drain > 0
1775
+ && e.source !== e.target
1776
+ && e.leechMultiple != null
1777
+ && e.leechMultiple > 0
1778
+ ) {
1779
+ const srcF = bySide(e.source);
1780
+ // The gain lands in the SAME pool the drain came out of, and only when the caster has
1781
+ // one. Cite: SpellAuras.cpp:4789 `pCaster->GetMaxPower(power) > 0`; :4803 ModifyPower.
1782
+ const srcMax = srcF ? maxPowerOfType(srcF, pwr) : 0;
1783
+ if (srcF && !dead(srcF) && srcMax > 0) {
1784
+ const gain = Math.trunc(drain * e.leechMultiple);
1785
+ if (gain > 0) {
1786
+ const srcBefore = powerOfType(srcF, pwr);
1787
+ setPowerOfType(srcF, pwr, Math.min(srcMax, srcBefore + gain));
1788
+ const srcAfter = powerOfType(srcF, pwr);
1789
+ if (srcAfter !== srcBefore) {
1790
+ push({
1791
+ t: ctx.t, kind: 'resource', actor: srcF.side,
1792
+ power: powerTypeName(pwr) ?? 'mana',
1793
+ value: Math.round(srcAfter), delta: srcAfter - srcBefore,
1794
+ });
1795
+ }
1796
+ }
1797
+ }
1798
+ }
1799
+ } else {
1800
+ const amt = e.pctOfMax != null && e.pctOfMax > 0 ? raw : Math.max(1, raw);
1801
+ if (amt > 0) {
1802
+ setPowerOfType(tgt, pwr, Math.min(maxPwr, before + amt));
1803
+ }
1804
+ }
1805
+ const after = powerOfType(tgt, pwr);
1806
+ if (after !== before) {
1807
+ // spellId: Enrage 5229 / Bloodrage 29131 vs white-hit rage (KI-703).
1808
+ push({
1809
+ t: ctx.t, kind: 'resource', actor: tgt.side,
1810
+ power: powerTypeName(pwr) ?? tgt.spec.powerType,
1811
+ value: Math.round(after), delta: after - before,
1812
+ spellId: e.spellId,
1813
+ });
1814
+ }
1815
+ }
1816
+ e.nextTickAt += e.tickMs;
1817
+ }
1818
+ }
1819
+ // PERIODIC_TRIGGER_SPELL: fire the trigger spell every amplitude while the channel lives, as
1820
+ // Arcane Missiles 5143 fires 7268. Cite: SpellAuras PERIODIC_TRIGGER_SPELL; EffectTriggerSpell.
1821
+ if (
1822
+ e.kind === 'proc_trigger_spell'
1823
+ && e.tickMs !== undefined && e.tickMs > 0
1824
+ && e.nextTickAt !== undefined
1825
+ && e.triggerSpellId
1826
+ ) {
1827
+ while (e.nextTickAt <= ctx.t && e.nextTickAt < e.expiresAt + 1) {
1828
+ const src = bySide(e.source);
1829
+ // Scripted per-tick roll (Brood Affliction: Bronze 10%). Drawn only when the field is
1830
+ // set, so every fight without one stays byte-identical. Cite: SpellAuras.cpp:975–981.
1831
+ if (e.tickChance != null && !rng.chance(e.tickChance)) {
1832
+ e.nextTickAt += e.tickMs;
1833
+ continue;
1834
+ }
1835
+ if (!dead(src)) {
1836
+ if (e.triggerSpellId === CURSE_OF_IDIOCY_SPELL_ID) {
1837
+ // Cite: SpellAuras.cpp HandlePeriodicTriggerSpell case 1010
1838
+ // skip if casterGUID == triggerTarget (prevent self-triggered recast)
1839
+ // sum SPELL_AURA_MOD_STAT amounts on the target for spell id 1010
1840
+ // if (intelectLoss <= -90 && spiritLoss <= -90) return;
1841
+ if (src.side === e.target || !curseOfIdiocyMayRetriggerFromBuffs(ctx.effectsOn(e.target), e.target)) {
1842
+ e.nextTickAt += e.tickMs;
1843
+ continue;
1844
+ }
1845
+ }
1846
+ // A totem's payload lives on `totemTriggerSpells`, off `spec.spells` so TotemAI never
1847
+ // casts it directly. The global store is the last resort: a payload in no kit is real.
1848
+ let trig = spellFromKit(src.spec.spells, e.triggerSpellId)
1849
+ ?? src.totemTriggerSpells?.find((s) => s.id === e.triggerSpellId)
1850
+ ?? ctx.spellOf?.(e.triggerSpellId);
1851
+ // The inline damage loop below never reaches applySpell, so nothing else scales the
1852
+ // payload to the caster. Hellfire Effect paid its rank-1 base at 60. Cite: applySpell.ts:496.
1853
+ if (trig) trig = payloadAtCasterLevel(trig, src.spec.level, isPlayerCharacterSide(src.side));
1854
+ if (trig && e.spellId === 16191 && e.amount != null) {
1855
+ trig = { ...trig, effects: trig.effects.map((te, i) => i === 0 && 'min' in te ? { ...te, min: e.amount as number, max: e.amount as number } : te) };
1856
+ }
1857
+ if (trig) {
1858
+ // Friendly payloads reach the totem's party. Hostile Magma and Fire Nova are PBAoE
1859
+ // around the SOURCE, not whatever `foeOf` is across the map. Cite: AREA_PARTY_SRC 22.
1860
+ if (src.totemSlot) ctx.firePartyTotemPulse?.(src, trig);
1861
+ const hostileFx = trig.effects.filter((te) => te.kind === 'direct_damage');
1862
+ // An AoE payload need not pay in direct_damage: Locust Swarm 28786 is a radius-30 dot
1863
+ // and silence, and the holder-cast below put all of it on the boss itself.
1864
+ const fanAoe = trig.aoe === true && trig.radiusYd != null && trig.radiusYd > 0;
1865
+ if (!hostileFx.length && !fanAoe && !src.totemSlot) {
1866
+ // Non-damage payload. Reading only direct_damage dropped 190 periodic payloads that
1867
+ // could never fire — stun, polymorph, heal, root, knock_back. Totems keep the pulse.
1868
+ const holder = bySide(e.target);
1869
+ const targetA = trig.rawEffects?.[0]?.targetA ?? 0;
1870
+ const dest = targetA === 6 || targetA === 25 ? foeOf(holder) ?? holder : holder;
1871
+ if (holder && !dead(holder) && dest && !dead(dest)) applySpell(holder, dest, trig);
1872
+ } else if (hostileFx.length || (fanAoe && !src.totemSlot)) {
1873
+ // Stand-down / OOC: no free-fire focus and not an AoE pulse → skip.
1874
+ const focus = foeOf(src);
1875
+ const origin = posOf?.(src.side);
1876
+ const radius = trig.radiusYd != null && trig.radiusYd > 0 ? trig.radiusYd : null;
1877
+ const rangeMax = trig.rangeMax != null && trig.rangeMax > 0 ? trig.rangeMax : null;
1878
+ let targets: Fighter[] = [];
1879
+ // A friendly payload pulses the CASTER'S OWN side: an enemy Healing Ward heals
1880
+ // its pack, it does not heal the raid. Cite: friendlyFan.ts isFriendlyFannedEffect.
1881
+ const ownSidePulse = !hostileFx.length && trig.effects.length > 0
1882
+ && trig.effects.every(isFriendlyFannedEffect);
1883
+ const atEnemySide = isEnemySide(src.side) === ownSidePulse;
1884
+ if (trig.aoe && radius != null) {
1885
+ // Magma / Fire Nova: every living hostile in SpellRadius of the totem.
1886
+ // Geometry missing → only the current focus (never the whole stream).
1887
+ const pool = atEnemySide ? livingEnemies() : livingFriendlies();
1888
+ // A payload whose aoeShape is `cone` is an ARC, not a disc: the radius alone gave
1889
+ // Lava Breath 19272 every raider inside 35 yd. Facing is the caster's own victim.
1890
+ const arc = trig.aoeShape === 'cone' ? coneArcOf(trig.coneDeg) : null;
1891
+ const facingAt = arc && focus && focus !== src ? posOf?.(focus.side) : undefined;
1892
+ if (origin) {
1893
+ for (const u of pool) {
1894
+ if (dead(u) || u === src) continue;
1895
+ if (!isWithinRadiusYd(origin, posOf?.(u.side), radius)) continue;
1896
+ if (arc && facingAt && !isWithinConeYd(
1897
+ origin, facingAt, posOf?.(u.side), arc.halfAngleRad, arc.rear,
1898
+ )) continue;
1899
+ if (cfg.navLos && !navSightClear(cfg, origin, posOf?.(u.side))) continue;
1900
+ targets.push(u);
1901
+ }
1902
+ } else if (!ownSidePulse && focus && focus !== src && !dead(focus)) {
1903
+ targets = [focus];
1904
+ }
1905
+ } else if (focus && focus !== src && !dead(focus)) {
1906
+ // Single-target pulse (Arcane Missiles etc.): range gate when known.
1907
+ const d = distanceBetween(src, focus);
1908
+ if (d == null || rangeMax == null || d <= rangeMax) targets = [focus];
1909
+ }
1910
+ // A payload with a speed is a real missile, and the inline delivery applied damage at
1911
+ // the caster, so the channel drew no orb at all. Same launcher every cast uses.
1912
+ if ((trig.speed ?? 0) > 0 && targets.length === 1 && ctx.unitPositionsMutable) {
1913
+ deliverOrQueueMissile(src, targets[0]!, trig);
1914
+ e.nextTickAt += e.tickMs;
1915
+ continue;
1916
+ }
1917
+ if (!hostileFx.length) {
1918
+ // Nothing for the inline direct-damage loop to pay, so the payload rides the ordinary
1919
+ // cast path once per fanned target. Cite: SpellAuras.cpp:1218-1222 CastTriggeredSpell.
1920
+ for (const tgt of targets) applySpell(src, tgt, trig);
1921
+ e.nextTickAt += e.tickMs;
1922
+ continue;
1923
+ }
1924
+ const bin = resolveSpellBinary(trig);
1925
+ for (const tgt of targets) {
1926
+ for (const te of hostileFx) {
1927
+ if (te.kind !== 'direct_damage') continue;
1928
+ let r = rollBetween(te.min, te.max, rng.next());
1929
+ r = applySpellDamageBonusIfPresent(r, trig, te.effectSlot ?? 0, {
1930
+ spellPower: src.spec.spellPower,
1931
+ spellDamageBySchool: src.spec.spellDamageBySchool,
1932
+ school: te.school,
1933
+ attackPower: src.attackPower,
1934
+ damagePct: src.damagePctBySchool[te.school],
1935
+ });
1936
+ const critHere = rng.chance(
1937
+ spellCritChance(fighterSpellCrit(src), src.spec.level, tgt.spec.level, trig.dmgClass),
1938
+ );
1939
+ if (critHere) r *= critMultiplierWithVersus(TUNING.spellCritMult, src.critDamageVersusPct, tgt.creatureType);
1940
+ const amount = dealDamage(tgt, r, te.school, src.spec.level, {
1941
+ binary: bin, damageType: 'spell', attacker: src,
1942
+ });
1943
+ if (amount > 0) {
1944
+ push({ t: ctx.t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: trig.id, name: trig.name,
1945
+ result: critHere ? 'crit' : 'hit', amount, school: te.school,
1946
+ });
1947
+ applyDamageThreat(src, tgt, amount, false, undefined);
1948
+ }
1949
+ }
1950
+ }
1951
+ }
1952
+ }
1953
+ }
1954
+ e.nextTickAt += e.tickMs;
1955
+ }
1956
+ }
1957
+ // No periodic self-break: a fear runs its full duration unless damage rolls it off or it is
1958
+ // dispelled. The 6%-per-second pulse that sat here ended ~39% of 8 s fears for free.
1959
+ if (ctx.t >= e.expiresAt) {
1960
+ if (e.kind === 'buff') {
1961
+ const owner = bySide(e.target);
1962
+ if (e.spellId) {
1963
+ stripOwnedSpellMods(owner, e.spellId);
1964
+ if (owner.radiusSpellMods?.length) {
1965
+ owner.radiusSpellMods = owner.radiusSpellMods.filter((m) => m.id !== `owned:${e.spellId}:6`);
1966
+ }
1967
+ }
1968
+ reverseBuffMod(owner, e.mod, e.amount ?? 0, e.school, {
1969
+ creatureTypeMask: e.creatureTypeMask,
1970
+ schoolMask: e.schoolMask,
1971
+ });
1972
+ // SPELL_AURA_MOD_TOTAL_THREAT (103) Fade — restore each reverse-hate offset.
1973
+ // Cite: HostileReference::resetFadeoutThreatReduction addThreat(−iFadeout).
1974
+ if (e.threatFadeDeltas && e.threatFadeDeltas.length > 0) {
1975
+ for (const { id, delta } of e.threatFadeDeltas) {
1976
+ if (!delta) continue;
1977
+ const host = bySide(id);
1978
+ if (!host || dead(host)) continue;
1979
+ const tm = threatOf(host);
1980
+ if (tm) tm.addThreatDirectly(e.target, -delta, false);
1981
+ }
1982
+ }
1983
+ // SPELL_AURA_RETAIN_COMBO_POINTS: expire-by-time only strips CP.
1984
+ // Cite: SpellAuras.cpp:4447–4451 AURA_REMOVE_BY_EXPIRE + AddComboPoints(−amount).
1985
+ if (e.retainComboAmount != null && e.retainComboAmount > 0 && owner.comboTarget != null) {
1986
+ const comboTgt = bySide(owner.comboTarget);
1987
+ if (comboTgt) {
1988
+ owner.comboPoints = Math.max(0, owner.comboPoints - e.retainComboAmount);
1989
+ if (owner.comboPoints === 0) owner.comboTarget = null;
1990
+ }
1991
+ }
1992
+ // Only when this spell has no OTHER live row on the same unit. A spell with two aura
1993
+ // rows of different length (Strength of Ossirian 25176 is a permanent +300% beside a
1994
+ // cosmetic scale) had the short row delete the id, so `buffPresent` read false while
1995
+ // the real aura was still applied — and the rule that answers it never fired.
1996
+ if (!hasOtherLiveAuraRow(ctx.effectsOn(e.target), e)) owner.buffActive.delete(e.spellId);
1997
+ // A seal buff wearing off (or consumed by Judgement) must also drop the live seal state so
1998
+ // it stops proccing on swings. (Judgement already nulls it; this covers the 30s timeout.)
1999
+ if (owner.activeSeal && owner.activeSeal.spellId === e.spellId) owner.activeSeal = null;
2000
+ // A next-cast-mod aura timing out (unconsumed) drops its armed modifier too.
2001
+ if (owner.pendingSpellMod && owner.pendingSpellMod.spellId === e.spellId) owner.pendingSpellMod = null;
2002
+ // Soul Link wearing off drops the damage-split redirect it armed.
2003
+ if (owner.splitDamage && owner.splitDamage.id === e.spellId) owner.splitDamage = null;
2004
+ // Blessing of Sacrifice flat split.
2005
+ if (owner.splitDamageFlat && owner.splitDamageFlat.id === e.spellId) owner.splitDamageFlat = null;
2006
+ // Blessing of Light dual heal bonus.
2007
+ if (owner.blessingOfLight && owner.blessingOfLight.spellId === e.spellId) owner.blessingOfLight = null;
2008
+ if (owner.healingWay && owner.healingWay.spellId === e.spellId) owner.healingWay = null;
2009
+ if (owner.healingWayChancePct > 0 && isHealingWayTalentSpellId(e.spellId)) {
2010
+ owner.healingWayChancePct = 0;
2011
+ }
2012
+ // Sweeping Strikes 20s window ends — drop remaining cleave charges.
2013
+ // Cite: UnitAuraProcHandler.cpp:810–841; spell_template DurIndex 18 / ProcCharges 5.
2014
+ if (e.spellId === 12292 || e.spellId === 18765) {
2015
+ owner.sweepingStrikesCharges = 0;
2016
+ }
2017
+ // Combustion window ends -> the crit stacks go with it. Cite: Unit.cpp:5372-5375.
2018
+ if (e.spellId === COMBUSTION_SPELL_ID || e.spellId === COMBUSTION_STACK_SPELL_ID) {
2019
+ clearCombustion(owner, COMBUSTION_STACK_SPELL_ID);
2020
+ }
2021
+ // Concentration Aura: amount is pushback resist % (no BuffMod).
2022
+ // Cite: Spell.cpp:6817–6820; only reverse when mod is absent (not a real stat buff).
2023
+ if (e.mod == null && (e.amount ?? 0) > 0 && !e.mechanicResist && owner.pushbackResistChance > 0) {
2024
+ owner.pushbackResistChance = Math.max(0, owner.pushbackResistChance - (e.amount ?? 0));
2025
+ }
2026
+ reverseMechanicResist(owner, e);
2027
+ // SPELL_AURA_MOD_DETECT_RANGE (91) — reverse yards into GetAttackDistance.
2028
+ // Cite: Unit.cpp:11772; applySpellLate mod_detect_range; KI-796.
2029
+ if (e.detectRangeAmount != null) {
2030
+ owner.detectRangeMod = (owner.detectRangeMod ?? 0) - e.detectRangeAmount;
2031
+ }
2032
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2033
+ } else if (e.kind === 'resist') {
2034
+ // MOD_RESISTANCE / Mage Armor — reverse via applyResistMod (clamps ≥0).
2035
+ // Cite: applySpell resist; fighterFactory applyResistMod; KI-482.
2036
+ const owner = bySide(e.target);
2037
+ applyResistMod(owner, e.school, -(e.amount ?? 0));
2038
+ owner.buffActive.delete(e.spellId);
2039
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2040
+ } else if (e.kind === 'damage_shield' || e.kind === 'proc_trigger_spell') {
2041
+ const owner = bySide(e.target);
2042
+ owner.buffActive.delete(e.spellId);
2043
+ // Removal-body holders never took an icon on apply, so they must not drop one here.
2044
+ if (e.onRemove) removalBodies.push([e, 'expire']);
2045
+ else push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2046
+ } else if (e.kind === 'school_absorb' || e.kind === 'mana_shield') {
2047
+ const owner = bySide(e.target);
2048
+ if (e.kind === 'school_absorb') {
2049
+ owner.schoolAbsorbs = owner.schoolAbsorbs.filter((a) => a.id !== e.spellId);
2050
+ } else {
2051
+ owner.manaShields = owner.manaShields.filter((a) => a.id !== e.spellId);
2052
+ }
2053
+ owner.buffActive.delete(e.spellId);
2054
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2055
+ } else if (e.kind === 'immunity') {
2056
+ const owner = bySide(e.target);
2057
+ owner.immunity = removeImmunityAura(owner.immunity, e.spellId);
2058
+ owner.buffActive.delete(e.spellId);
2059
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2060
+ } else if (e.kind === 'restore_health' || e.kind === 'restore_power') {
2061
+ const owner = bySide(e.target);
2062
+ owner.buffActive.delete(e.spellId);
2063
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2064
+ } else if (e.kind === 'root') {
2065
+ // Early break (breakRootOnDamage / breakControl) already emitted applied:false.
2066
+ if (!e.broken) push({ t: ctx.t, kind: 'root', target: e.target, applied: false });
2067
+ } else if (e.kind === 'speed') {
2068
+ // Beneficial sprints/aspects register on buffActive for playerView().buffs;
2069
+ // hostile snares never do (delete is a no-op). Clear so chips do not ghost.
2070
+ bySide(e.target).buffActive.delete(e.spellId);
2071
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2072
+ } else if (e.kind === 'reflect') {
2073
+ // FS4 reflect self-buff wearing off — no fighter-field state to reverse (read on the fly).
2074
+ bySide(e.target).buffActive.delete(e.spellId);
2075
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2076
+ } else if (e.kind === 'spell_magnet') {
2077
+ bySide(e.target).buffActive.delete(e.spellId);
2078
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2079
+ } else if (e.kind === 'mod_taunt') {
2080
+ // SPELL_AURA_MOD_TAUNT expire → TauntUpdate clears STATE_TAUNTED for this caster.
2081
+ // Cite: SpellAuras.cpp:2860–2875 apply=false; ThreatManager.cpp:557–575.
2082
+ const host = bySide(e.target);
2083
+ if (isEnemySide(host.side)) {
2084
+ const stillHeld = ctx.effectsOn(e.target).some(
2085
+ (o) =>
2086
+ o !== e
2087
+ && o.kind === 'mod_taunt'
2088
+ && o.source === e.source
2089
+ && o.expiresAt > ctx.t,
2090
+ );
2091
+ if (!stillHeld) {
2092
+ const tm = threatOf(host);
2093
+ if (tm) tm.setTauntState(e.source, TAUNT_STATE.NONE);
2094
+ }
2095
+ }
2096
+ push({ t: ctx.t, kind: 'aura', actor: e.target, name: e.name, applied: false });
2097
+ } else if (e.kind === 'cc') {
2098
+ // Charm expiry → full Uncharm (Anastari inv/possessed teardown).
2099
+ // Cite: SpellAuras.cpp HandleModCharm apply=false → Uncharm.
2100
+ if (!e.broken && (e.control === 'charm' || e.mechanic === 'charm')) {
2101
+ const owner = bySide(e.target);
2102
+ // charmedUntil has already elapsed here so isCharmed is false — still Uncharm
2103
+ // so Possess Inv (17250) drops. Cite: SpellAuras HandleModCharm apply=false.
2104
+ breakCharm(owner);
2105
+ } else if (!e.broken && e.control && !controlActive(bySide(e.target), e.control)) {
2106
+ // Faded event only when no overlapping same-kind CC still holds the timer, and not if it
2107
+ // was already broken early (breakControl already emitted that).
2108
+ push({ t: ctx.t, kind: 'control', target: e.target, control: e.control, applied: false, name: e.name });
2109
+ }
2110
+ // Banish immunity rides the CC duration — clear when the banish CC expires (baked mechanic).
2111
+ if (e.mechanic === 'banish') {
2112
+ const owner = bySide(e.target);
2113
+ owner.immunity = removeImmunityAura(owner.immunity, e.spellId);
2114
+ }
2115
+ }
2116
+ effects.splice(i, 1);
2117
+ }
2118
+ }
2119
+ for (const [e, mode] of removalBodies) ctx.fireAuraRemoveBodies?.(e, mode);
2120
+ ctx.recomputePeriodicDue();
2121
+ combatPerfLeave('sim.periodic');
2122
+ }
2123
+
2124
+ // Lightwell (724): a placed well reactively renews the most-hurt friendly (< half HP) each
2125
+ // interval, spending a charge, until exhausted or expired. Owner-side only; no charges → no-op.
2126
+ for (const owner of fightersOrder()) {
2127
+ const lw = owner.lightwell;
2128
+ if (!lw) continue;
2129
+ if (ctx.t >= lw.expiresAt || lw.charges <= 0) {
2130
+ owner.lightwell = null;
2131
+ owner.buffActive.delete(lw.spellId);
2132
+ for (const e of ctx.effectsOn(owner.side)) if (e.kind === 'buff' && e.spellId === lw.spellId) {
2133
+ e.expiresAt = Math.min(e.expiresAt, ctx.t);
2134
+ ctx.touchEffect(e);
2135
+ }
2136
+ push({ t: ctx.t, kind: 'aura', actor: owner.side, name: 'Lightwell', applied: false });
2137
+ continue;
2138
+ }
2139
+ while (ctx.t >= lw.nextHealAt && lw.charges > 0) {
2140
+ // Most-hurt living friendly below half health (the "clicker").
2141
+ let target: Fighter | null = null;
2142
+ let worst = 0.5;
2143
+ for (const fr of livingFriendlies()) {
2144
+ const pct = fr.hp / Math.max(1, fr.spec.maxHealth);
2145
+ if (pct < worst) { worst = pct; target = fr; }
2146
+ }
2147
+ lw.nextHealAt += lw.intervalMs;
2148
+ if (!target) break; // nobody hurt enough this interval — hold the charge
2149
+ const before = target.hp;
2150
+ target.hp = Math.min(target.spec.maxHealth, Math.round(target.hp + lw.healPerCharge));
2151
+ lw.charges--;
2152
+ if (target.hp !== before) push({ t: ctx.t, kind: 'heal', actor: target.side, amount: target.hp - before, ...(target !== owner && ctx.allyBlock().length > 0 ? { source: owner.side } : {}) });
2153
+ applyHealThreat(owner, target.hp - before, null, target.side);
2154
+ }
2155
+ }
2156
+ combatPerf('sim.deaths', checkDeaths);
2157
+ if (ctx.ended) { finalize(); return events.slice(startLen); }
2158
+
2159
+ // Missiles whose travel timer elapsed this tick (SpellEntry.speed). Before cast-finish
2160
+ // and motion so a chill that lands slows the same-tick gap-close step.
2161
+ combatPerf('sim.missiles', landDueMissiles);
2162
+
2163
+ // CombatManager::Update — expire PvP combat timer, re-arm Charge for next engage.
2164
+ // Cite: CombatManager.cpp:64–72 countdown; :88–104 HandleExitCombat; chargeUsed clear.
2165
+ for (const f of fightersOrder()) expireCombatFlag(f, ctx.t);
2166
+
2167
+ // CallAssistance: the pack joins because the mob you hit called them, not because the session
2168
+ // existed. Who joins was fixed at pull build; this is the when, and it fires once.
2169
+ packAssistPhase();
2170
+ // AGGRO_ZONE raid bosses: first threat → whole party in combat (not trash).
2171
+ // Cite: Unit.cpp:7986–7988 ExtraFlags AGGRO_ZONE; Creature.cpp SetInCombatWithZone.
2172
+ for (const ef of ctx.livingEnemies()) {
2173
+ const tm = threatOf(ef);
2174
+ if (!tm || tm.isEmpty()) continue;
2175
+ maybeAggroZonePulse(ef, ctx.partyUnits(), ctx.t);
2176
+ }
2177
+
2178
+ // The cast bar is a TIMER, so the walk that lands one advances to the next instead of asking all
2179
+ // 40 seats every tick. A bar with no finish time counts as due NOW, never Infinity.
2180
+ const castFinishDueOf = (): number => {
2181
+ let due = Number.POSITIVE_INFINITY;
2182
+ for (const f of fightersOrder()) {
2183
+ if (!f.castingSpell) continue;
2184
+ const at = f.castFinishAt > 0 ? f.castFinishAt : ctx.t;
2185
+ if (at < due) due = at;
2186
+ }
2187
+ return due;
2188
+ };
2189
+ combatPerfEnter('sim.castFinish');
2190
+ if (ctx.t >= castFinishDue) {
2191
+ for (const f of fightersOrder()) {
2192
+ if (f.castingSpell && ctx.t >= f.castFinishAt) {
2193
+ // Drop the bar even on a corpse — skipping dead left castingSpell set and the UI
2194
+ // cast bar stuck with the spell name after the unit died mid-cast.
2195
+ if (dead(f)) {
2196
+ f.castingSpell = null;
2197
+ f.castCost = 0;
2198
+ f.castCostPower = undefined;
2199
+ f.castFinishAt = 0;
2200
+ f.castStartedAt = 0;
2201
+ continue;
2202
+ }
2203
+ const spell = f.castingSpell;
2204
+ const heldCost = f.castCost;
2205
+ const heldCostPower = f.castCostPower;
2206
+ f.castingSpell = null;
2207
+ f.castCost = 0;
2208
+ f.castCostPower = undefined;
2209
+ // cast() re-runs CheckPower: a cast that finishes with the bar drained fizzles — nothing
2210
+ // spent, no cooldown, no effects. Cite: Spell.cpp cast ~3095-3105; CheckPower ~6374-6403.
2211
+ if (!canPayPower(f, heldCost, heldCostPower)) {
2212
+ if (!hasAttrEx2(spell, SPELL_ATTR_EX2.DO_NOT_REPORT_SPELL_FAILURE)) {
2213
+ push({ t: ctx.t, kind: 'flavor', text: `${spell.name} fizzles: not enough power.` });
2214
+ }
2215
+ continue;
2216
+ }
2217
+ // Re-check reagents at cast() (bag may have changed mid-cast). Cite: TakeReagents; KI-804.
2218
+ if (!canPaySpellReagents(f, spell)) {
2219
+ if (!hasAttrEx2(spell, SPELL_ATTR_EX2.DO_NOT_REPORT_SPELL_FAILURE)) {
2220
+ push({ t: ctx.t, kind: 'flavor', text: `${spell.name} fizzles: missing reagents.` });
2221
+ }
2222
+ continue;
2223
+ }
2224
+ // Carry the SCHEDULED finish, not the tick that observed it, so a health-scaled payload (Berserking
2225
+ // icon 1661) is read at the instant the cast completed. Cite: SpellEffects.cpp:1270-1284; KI-897.
2226
+ const finishT = f.castFinishAt > 0 ? f.castFinishAt : ctx.t;
2227
+ push({ t: finishT, kind: 'cast_finish', actor: f.side, spellId: spell.id, name: spell.name });
2228
+ if (isEnemySide(f.side)) ctx.notifyEventAiSpellCast?.(f, spell.id);
2229
+ commitAfterCast(f, spell);
2230
+ // TakePower + TakeReagents + SendSpellCooldown (Spell.cpp ~3168–3176).
2231
+ spend(f, heldCost, heldCostPower, spell);
2232
+ takeReagentsFor(f, spell);
2233
+ if (!hasAttrEx2(spell, SPELL_ATTR_EX2.NOT_AN_ACTION)) {
2234
+ ctx.removeAurasWithInterruptFlags?.(f, AURA_INTERRUPT.ACTION_LATE, 'action late');
2235
+ }
2236
+ maybeResetMeleeAttackTimersOnCast(f, spell, ctx.t, false);
2237
+ {
2238
+ const rec = recoveryReadyAts(spell, ctx.t);
2239
+ if (rec.personalReadyAt != null) {
2240
+ const adj = applyOwnedSpellMod(f, spell, SPELLMOD_COOLDOWN, rec.personalReadyAt - ctx.t);
2241
+ if (adj > 0) f.cooldowns.set(spell.id, ctx.t + adj);
2242
+ }
2243
+ if (rec.category != null && rec.categoryReadyAt != null) {
2244
+ const adj = applyOwnedSpellMod(f, spell, SPELLMOD_COOLDOWN, rec.categoryReadyAt - ctx.t);
2245
+ if (adj > 0) f.categoryCooldowns.set(rec.category, ctx.t + adj);
2246
+ }
2247
+ }
2248
+ if (spell.rageGenerated) gainRage(f, spell.rageGenerated);
2249
+ // An explicitly aimed cast lands where it was aimed. Only the spell that set the
2250
+ // override may claim it; a dead target falls back to the live resolve.
2251
+ const ovMap = (ctx as {
2252
+ castTargetOverride?: Map<Side, { spellId: number; side: EnemySide }>;
2253
+ }).castTargetOverride;
2254
+ const ov = ovMap?.get(f.side);
2255
+ if (ov) ovMap?.delete(f.side);
2256
+ const aimed = ov?.spellId === spell.id ? bySide(ov.side) : null;
2257
+ const castTgt = aimed && !dead(aimed)
2258
+ ? aimed
2259
+ : (isEnemySide(f.side) ? hostileTargetOf(f) : foeOf(f));
2260
+ // Re-check spell range at finish, since the target may have walked out. Missile flight still
2261
+ // starts only when in range at cast(), which is the Classic CheckCast gate.
2262
+ if (
2263
+ !(spellNeedsRangeGate(spell) || spellFoeDirectedGeometry(spell))
2264
+ || isInSpellRange(
2265
+ distanceBetween(f, castTgt), spellGateRangeMaxYd(spell), spellGateRangeMinYd(spell),
2266
+ ctx.isMoving(f.side) && ctx.isMoving(castTgt.side),
2267
+ f.spec.combatReachYd, castTgt.spec.combatReachYd,
2268
+ )
2269
+ ) {
2270
+ // CheckTarget unit LOS at launch. Cite: Spell.cpp:7015-7037 TARGET_LOS_CASTER. The
2271
+ // friendly arm asks the SAME resolver applyHealEffect uses, or it prices another body.
2272
+ const healTgt = cfg.navLos && spellNeedsFriendlyLosGate(spell)
2273
+ ? ctx.lowestHpFriendlyOf(f)
2274
+ : null;
2275
+ if (cfg.navLos && spellNeedsLosGate(spell)
2276
+ && !navSightClear(cfg, posOf(f.side), posOf(castTgt.side))) {
2277
+ f.castCritAdd = 0;
2278
+ f.castEffectPct = 0;
2279
+ } else if (healTgt && healTgt !== f
2280
+ && !navSightClear(cfg, posOf(f.side), posOf(healTgt.side))) {
2281
+ f.castCritAdd = 0;
2282
+ f.castEffectPct = 0;
2283
+ } else {
2284
+ // Cost/CD already taken; impact may be delayed by SpellEntry.speed.
2285
+ // Cite: Spell.cpp cast → missile; stepSession deliverOrQueueMissile.
2286
+ deliverOrQueueMissile(f, castTgt, spell);
2287
+ }
2288
+ } else {
2289
+ // OOR fizzle of effects — still drop one-shot cast bonuses.
2290
+ f.castCritAdd = 0;
2291
+ f.castEffectPct = 0;
2292
+ }
2293
+ }
2294
+ }
2295
+ castFinishDue = castFinishDueOf();
2296
+ }
2297
+ combatPerfLeave('sim.castFinish');
2298
+ combatPerf('sim.deaths', checkDeaths);
2299
+ ctx.dropIdlePossessInv?.();
2300
+ if (ctx.ended) { finalize(); return events.slice(startLen); }
2301
+
2302
+ // VE EventAI: phase / spawn / flee for living enemies (independent of cast GCD).
2303
+ eventAiPhase();
2304
+
2305
+ // Geometry v2.1b: straight-line motion before swings/casts. When a unit arrives in band this tick, the
2306
+ // same tick may swing/cast (deterministic). Absent unitPositionsYd → no-op (1v1 byte-identical).
2307
+ runMotionPhase();
2308
+ // P0b: continuous area auras re-check radius + membership against the fresh
2309
+ // positions before anybody acts on the buffed stats this tick.
2310
+ areaAuraMaintainPhase();
2311
+ // Detect sweep against those same fresh positions: a stealthed body that walked into
2312
+ // someone's front arc is seen here, before the enemy-idle gate below reads `stealthed`.
2313
+ ctx.stealthDetectPhase?.();
2314
+ // Hunter traps: GO radius of the placed point, not fight-wide. Cite: GameObject.cpp:436–503.
2315
+ if (typeof ctx.springProximityHunterTraps === 'function') {
2316
+ (ctx.springProximityHunterTraps as () => void)();
2317
+ }
2318
+
2319
+ const armNextSwing = (f: Fighter, tgt: Fighter): void => {
2320
+ if (f.pendingSwing || isEnemySide(f.side)) return;
2321
+ const arm = pickNextSwingArm(f, tgt);
2322
+ if (!arm) return;
2323
+ // Per-spell, not blanket: Heroic Strike carries the main-hand bit AND PreventionType
2324
+ // PACIFY, so both refuse it — but a swing ability with neither is still armable.
2325
+ if (f.disarmedUntil > ctx.t && isWeaponAction(arm)) return;
2326
+ if (f.pacifiedUntil > ctx.t && isPacifyPrevented(arm)) return;
2327
+ if (canCastSpell(f, tgt, arm)) startOrCast(f, tgt, arm);
2328
+ };
2329
+ // CURRENT_MELEE_SPELL must already sit when AttackerStateUpdate runs, or the due blow is a
2330
+ // white and that white's rage pays for the queue. Cite: Unit.cpp:2632-2641.
2331
+ let playerNextSwingPreArmed = false;
2332
+ // Next-swing arm needs a combat victim; livingEnemies is true for seated idle hostiles.
2333
+ // Cite: Unit.cpp AttackerStateUpdate; S2 idle foeOf.
2334
+ const liveHostiles = livingEnemies();
2335
+ const hasLiveHostile = liveHostiles.length > 0;
2336
+ const hasEngagedHostile = liveHostiles.some((e) => {
2337
+ const tm = threatOf(e);
2338
+ return !!tm && !tm.isEmpty();
2339
+ });
2340
+ if (hasEngagedHostile) {
2341
+ if (!ctx.aiPlayer && action != null && !isItemCombatAction(action) && !dead(player)
2342
+ && !isControlledHard(player) && !player.pendingSwing) {
2343
+ const pressed = chosenPlayerSpell(combatActionSpellId(action), combatActionDest(action));
2344
+ if (pressed && isNextMeleeSwingSpell(pressed)) {
2345
+ startOrCast(player, foeOf(player), pressed);
2346
+ playerNextSwingPreArmed = player.pendingSwing != null;
2347
+ }
2348
+ }
2349
+ for (const f of fightersOrder()) {
2350
+ if (dead(f) || isEnemySide(f.side) || f.pendingSwing) continue;
2351
+ if (f === player && !ctx.aiPlayer) continue;
2352
+ if (isControlledHard(f) || !kitHasNextSwing(f)) continue;
2353
+ armNextSwing(f, foeOf(f));
2354
+ }
2355
+ }
2356
+ if (hasLiveHostile) {
2357
+ combatPerfEnter('sim.swings');
2358
+ for (const f of fightersOrder()) {
2359
+ // Root immobilizes only — white swings continue in range. Cite: Unit.h
2360
+ // UNIT_STAT_CAN_NOT_REACT (stun/fear/…, no ROOT); Unit.cpp AttackerStateUpdate ~2600.
2361
+ if (dead(f) || f.castingSpell) continue;
2362
+ // Mid-charge travel: no white swings until arrival resolves the charge.
2363
+ if (f.pendingCharge) continue;
2364
+ // Totems never white-melee (TotemAI only CastSpell).
2365
+ if (isTotemSide(f.side)) continue;
2366
+ // While the player is still stealthed (opener window / undetected), enemies do not engage.
2367
+ // Cite: Unit.cpp IsVisibleForOrDetect — undetected stealth is not attackable.
2368
+ if (isEnemySide(f.side) && player.stealthed) continue;
2369
+ // EventAI FLEE / AUTO_ATTACK / REACT_PASSIVE — stop meaningful melee.
2370
+ // Cite: UnitAI.cpp DoFlee ~829–841; SetMeleeEnabled ~794; AttackStart REACT_PASSIVE ~285.
2371
+ if (isEnemySide(f.side)) {
2372
+ const cs = enemyCastBySide.get(f.side as EnemySide);
2373
+ if (cs?.eventAi.fleeing) continue;
2374
+ if (cs && !cs.eventAi.meleeEnabled) continue;
2375
+ if (cs && cs.eventAi.reactState === 0 /* REACT_PASSIVE */) continue;
2376
+ // MAIN_SPELL / NPC Shoot ready this tick → cast phase owns the attack (no white+shoot).
2377
+ // Cite: UnitAI CAST_MAIN_SPELL; isCreatureRangedAuto.
2378
+ if (cs && creatureRangedMainReady(cs, f)) continue;
2379
+ }
2380
+ // Hard CC and pacify stop auto-attacks. DISARM is deliberately NOT here: it drops the main hand
2381
+ // to unarmed damage rather than stopping the swing, and root is motion-only.
2382
+ if (f.stunnedUntil > ctx.t || f.fearedUntil > ctx.t || f.incapUntil > ctx.t || f.pacifiedUntil > ctx.t) continue;
2383
+ // After-cast kite: a white the next tick shatters Frost Nova (AIF_DAMAGE).
2384
+ if (isStepBackWalking(f, ctx.t)) continue;
2385
+ // CANCELS_AUTO_ATTACK (Gouge/Stealth/Sap) holds white swings until re-enabled.
2386
+ if (!f.autoAttackEnabled) continue;
2387
+ // A halt covering PHYSICAL stops the white swing too, because what it answers is threat gained.
2388
+ // `haltDpsUntil` is its expiry: without one a unit stayed muted for a 1,800,100 ms stalemate.
2389
+ if (
2390
+ f.haltDpsSchool !== undefined
2391
+ && f.haltDpsEntries === undefined
2392
+ && ctx.t <= (f.haltDpsUntil ?? -1)
2393
+ && haltDpsRefusesSchool(f.haltDpsSchool, 'physical')
2394
+ ) continue;
2395
+ // Move-only: no free white swings (autoAttackEnabled is cleared on stance set).
2396
+ // 'hold' is NOT here — it forbids motion only, so a held unit still swings what it reaches.
2397
+ if (!isEnemySide(f.side)) {
2398
+ const st = (ctx as { unitStance?: Map<string, string> }).unitStance?.get(f.side);
2399
+ if (st === 'passive') continue;
2400
+ }
2401
+ // No threat yet → not in combat engagement (stand until missile land / AttackStart).
2402
+ if (!enemyEngaged(f)) continue;
2403
+ // Dual-wield: MH and OH have independent timers (Unit.cpp update timers + AttackerStateUpdate).
2404
+ // Same-tick both-ready: delay the other hand by ATTACK_DISPLAY_DELAY (200ms).
2405
+ const ATTACK_DISPLAY_DELAY_MS = 200; // Unit.h ATTACK_DISPLAY_DELAY
2406
+ // A swing the unit cannot land (out of range) re-checks in 100 ms, it does not consume the
2407
+ // swing. Cite: Unit.cpp:696-699 setAttackTimer(BASE_ATTACK/OFF_ATTACK, 100) on swingError.
2408
+ const SWING_ERROR_RETRY_MS = 100;
2409
+ const tgt = isEnemySide(f.side) ? hostileTargetOf(f) : foeOf(f);
2410
+ // Endless floor OOC: foeOf returns self when no living hostiles — do not swing air.
2411
+ if (!tgt || tgt === f || tgt.hp <= 0) continue;
2412
+ // Neither hand is due, so nothing below is read: all four sources are pure reads and at 10 Hz
2413
+ // a unit is due one tick in 20 to 36. The target resolve stays ABOVE — it PRUNES the list.
2414
+ if (ctx.t < f.swingReadyAt && ctx.t < f.ohSwingReadyAt) continue;
2415
+ // Scoped halt: the same physical refusal as above, once the body it names is known.
2416
+ if (
2417
+ f.haltDpsSchool !== undefined
2418
+ && f.haltDpsEntries !== undefined
2419
+ && ctx.t <= (f.haltDpsUntil ?? -1)
2420
+ && haltDpsRefusesSchool(f.haltDpsSchool, 'physical')
2421
+ && haltDpsCoversEntry(f.haltDpsEntries, tgt.spec.creatureEntry)
2422
+ ) continue;
2423
+ const dw = fighterDualWield(f);
2424
+ // Melee white only inside the melee band when pairwise yards exist. Undefined distance allows
2425
+ // the swing, except on the endless floor, where failing open hit stream injects miles away.
2426
+ const meleeDist = distanceBetween(f, tgt);
2427
+ // The park stop is ON reach plus slack and the contact ring is 0.05 yd past it, so a chase that
2428
+ // drifts a hair loses its swings. The server pays a running pair 2.67 yd more reach.
2429
+ const meleeReach = meleeContactYd(
2430
+ ctx.isMoving(f.side) && ctx.isMoving(tgt.side),
2431
+ f.spec.combatReachYd,
2432
+ tgt.spec.combatReachYd,
2433
+ );
2434
+ const meleeInReach = cfg.endless
2435
+ ? meleeDist != null && meleeDist <= meleeReach
2436
+ : meleeDist == null || meleeDist <= meleeReach;
2437
+
2438
+ if (ctx.t >= f.swingReadyAt) {
2439
+ // Auto-attack mode: ranged fires the equipped weapon, melee swings main hand. An armed
2440
+ // on-next-swing strike owns the swing only while the blow can be attempted.
2441
+ const armedStrike = f.pendingSwing;
2442
+ let shootAuto = f.mode === 'ranged' && !!f.spec.ranged && !(armedStrike && meleeInReach);
2443
+ // Classic dead zone: Auto Shot and Throw cannot fire inside min range. Too close drops to
2444
+ // melee when a real main hand exists; a pure ranged kit skips the shot and advances the timer.
2445
+ if (shootAuto && meleeDist != null) {
2446
+ const minRangedYd = rangedAutoMinYards(f);
2447
+ if (minRangedYd > 0 && meleeDist < minRangedYd) {
2448
+ const hasMelee = (f.spec.weapon?.dmgMax ?? 0) > 1;
2449
+ if (hasMelee) {
2450
+ f.mode = 'melee';
2451
+ shootAuto = false;
2452
+ } else {
2453
+ const rangedHaste = f.haste + (f.spec.rangedHastePct ?? 0);
2454
+ f.swingReadyAt = ctx.t + scaleTime(f.spec.ranged!.speedMs, rangedHaste, 0);
2455
+ continue;
2456
+ }
2457
+ } else if (meleeDist > 35) {
2458
+ // SpellRange MAX for the auto shot: only the dead-zone min was checked, so a hunter bot
2459
+ // shot 670 white damage at 490 yd. 35 yd is Auto Shot's own bound (spell 75).
2460
+ const rangedHaste = f.haste + (f.spec.rangedHastePct ?? 0);
2461
+ f.swingReadyAt = ctx.t + scaleTime(f.spec.ranged!.speedMs, rangedHaste, 0);
2462
+ continue;
2463
+ }
2464
+ }
2465
+ // Wand / Auto Shot / Throw is SpellStart of the auto-repeat, so CheckCast LOS applies.
2466
+ // Cite: Unit.cpp:4192 _UpdateAutoRepeatSpell; Spell.cpp:4742.
2467
+ if (shootAuto && cfg.navLos) {
2468
+ const shot = rangedAutoSpellOf(f);
2469
+ if (!shot || spellNeedsLosGate(shot)) {
2470
+ if (!navSightClear(cfg, posOf(f.side), posOf(tgt.side))) {
2471
+ f.swingReadyAt = ctx.t + SWING_ERROR_RETRY_MS;
2472
+ continue;
2473
+ }
2474
+ }
2475
+ }
2476
+ // A swing that cannot reach is a SWING ERROR: both hands retry in 100 ms and do not spend the
2477
+ // weapon swing. Charging a full swing threw away up to 3.8 s each time a mob stepped out.
2478
+ if (!shootAuto && !meleeInReach) {
2479
+ f.swingReadyAt = ctx.t + SWING_ERROR_RETRY_MS;
2480
+ f.ohSwingReadyAt = ctx.t + SWING_ERROR_RETRY_MS;
2481
+ } else {
2482
+ // Consume armed next-swing only when we actually attempt the blow — a shot fired
2483
+ // while the strike waits out of reach must leave it armed.
2484
+ const strike = shootAuto ? null : f.pendingSwing;
2485
+ if (!shootAuto) f.pendingSwing = null;
2486
+ // The swing IS the cast: AttackerStateUpdate runs Spell::cast() here and returns without a
2487
+ // white blow, so SPELL_GO belongs to this tick. Cite: Unit.cpp:2619-2627.
2488
+ if (strike) {
2489
+ push({ t: ctx.t, kind: 'cast_finish', actor: f.side, spellId: strike.id, name: strike.name });
2490
+ }
2491
+ // Melee white/on-next-swing: rewardRage inside weaponStrike + taken rage in dealDamage.
2492
+ // Ranged does not grant attacker rage (Unit.cpp attackType != RANGED_ATTACK).
2493
+ const landed = shootAuto ? fireRangedAuto(f, tgt) : weaponStrike(f, tgt, strike, 'mainhand');
2494
+ if (landed) {
2495
+ if (!shootAuto) ctx.removeAurasWithInterruptFlags?.(f, AURA_INTERRUPT.ATTACKING, 'attack');
2496
+ maybeBreakStealthOn(f, AURA_INTERRUPT.ATTACKING, 'attack');
2497
+ breakFeignDeath(f);
2498
+ }
2499
+ // On-next-swing riders: weaponStrike deals the weapon damage only, so pendingSwing must apply
2500
+ // the riders too or every on-next-swing CC lands as damage with zero control.
2501
+ if (landed && strike) {
2502
+ // rawEffects cleared — same re-resolve trap as the instant-strike rider path above.
2503
+ const riders = strike.effects.filter(
2504
+ (e) => e.kind === 'buff' || e.kind === 'dot' || e.kind === 'cc' || e.kind === 'root'
2505
+ || e.kind === 'knock_back',
2506
+ );
2507
+ const riderSpell = riders.length
2508
+ ? { ...strike, effects: riders, rawEffects: undefined }
2509
+ : null;
2510
+ if (riderSpell) applySpell(f, tgt, riderSpell);
2511
+ // Cleave fans to additional living enemies up to the cap. Heroic Strike and Maul do not.
2512
+ // Cite: Cleave EffectChainTarget 2; Maul chainTargets 0.
2513
+ const fanCap = nextSwingFanCap(strike);
2514
+ if (fanCap != null && fanCap > 1) {
2515
+ const pool = isEnemySide(f.side) ? livingFriendlies() : livingEnemies();
2516
+ // Multi-enemy only — single living foe → no extra strikes (1v1 RNG intact).
2517
+ if (pool.length > 1) {
2518
+ // The secondary must be in the CASTER's melee reach, not within N yards of the primary:
2519
+ // primary-origin made two mobs 5 yd apart read as single-target. Rage must not refund.
2520
+ const radiusYd = (strike.radiusYd != null && strike.radiusYd > 0)
2521
+ ? strike.radiusYd
2522
+ : nextSwingFanRadiusYd(strike);
2523
+ const fanSpell = { ...strike, radiusYd, aoeShape: 'pbaoe' as const };
2524
+ const others = selectAoeSecondaries(f, tgt, fanSpell, pool, fanCap);
2525
+ for (const other of others) {
2526
+ const otherLanded = weaponStrike(f, other, strike, 'mainhand');
2527
+ if (otherLanded && riderSpell) {
2528
+ applySpell(f, other, riderSpell);
2529
+ }
2530
+ }
2531
+ }
2532
+ }
2533
+ }
2534
+ // ADD_EXTRA_ATTACKS pending whites, with the cap applied at grant. The executing flag blocks
2535
+ // a Windfury re-proc on the extras themselves. Cite: Unit::DoExtraAttacks :2666-2692.
2536
+ if (landed && !shootAuto && f.extraAttacks > 0) {
2537
+ f.extraAttacksExecuting = true;
2538
+ while (f.extraAttacks > 0 && !dead(tgt) && !dead(f)) {
2539
+ f.extraAttacks -= 1;
2540
+ weaponStrike(f, tgt, null, 'mainhand');
2541
+ }
2542
+ f.extraAttacksExecuting = false;
2543
+ if (f.extraAttacks <= 0) f.extraAttackSource = null;
2544
+ }
2545
+ // Ranged keeps its own haste; the melee arm goes through mainHandSwingMs so a disarm
2546
+ // reads BASE_ATTACK_TIME instead of the (now unusable) weapon's delay.
2547
+ f.swingReadyAt = ctx.t + (shootAuto
2548
+ ? scaleTime(f.spec.ranged!.speedMs, f.haste + (f.spec.rangedHastePct ?? 0), 0)
2549
+ : mainHandSwingMs(f));
2550
+ // Prevent MH+OH same-tick display clash (Unit.cpp ~675–679).
2551
+ if (dw && !shootAuto && f.ohSwingReadyAt - ctx.t < ATTACK_DISPLAY_DELAY_MS) {
2552
+ f.ohSwingReadyAt = ctx.t + ATTACK_DISPLAY_DELAY_MS;
2553
+ }
2554
+ if (strike) {
2555
+ // The on-next-swing cooldown starts when the blow lands, through the shared recovery rules
2556
+ // so a category-only cooldown counts — Raptor Strike rank 1 bakes 0 plus a 6000 category.
2557
+ const rec = recoveryReadyAts(strike, ctx.t);
2558
+ if (rec.personalReadyAt != null) f.cooldowns.set(strike.id, rec.personalReadyAt);
2559
+ if (rec.category != null && rec.categoryReadyAt != null) {
2560
+ f.categoryCooldowns.set(rec.category, rec.categoryReadyAt);
2561
+ }
2562
+ }
2563
+ } // end in-reach / ranged auto branch
2564
+ }
2565
+
2566
+ // Off-hand white auto, with no on-next-swing and not in ranged mode. Out of reach it takes the
2567
+ // same 100 ms swing-error retry and draws no RNG. Cite: Unit.cpp ~683-691.
2568
+ if (dw && f.mode === 'melee' && ctx.t >= f.ohSwingReadyAt) {
2569
+ if (!meleeInReach) {
2570
+ // Same swing error, same 100 ms retry on both hands. Cite: Unit.cpp:694-699.
2571
+ f.ohSwingReadyAt = ctx.t + SWING_ERROR_RETRY_MS;
2572
+ f.swingReadyAt = ctx.t + SWING_ERROR_RETRY_MS;
2573
+ } else {
2574
+ const landedOh = weaponStrike(f, tgt, null, 'offhand');
2575
+ if (landedOh) {
2576
+ ctx.removeAurasWithInterruptFlags?.(f, AURA_INTERRUPT.ATTACKING, 'attack');
2577
+ maybeBreakStealthOn(f, AURA_INTERRUPT.ATTACKING, 'attack');
2578
+ breakFeignDeath(f);
2579
+ }
2580
+ // Extra attacks granted by an OFF-hand proc execute immediately as MAIN-hand strikes; without
2581
+ // this drain they idled until the next main-hand swing fired them late.
2582
+ if (landedOh && f.extraAttacks > 0) {
2583
+ f.extraAttacksExecuting = true;
2584
+ while (f.extraAttacks > 0 && !dead(tgt) && !dead(f)) {
2585
+ f.extraAttacks -= 1;
2586
+ weaponStrike(f, tgt, null, 'mainhand');
2587
+ }
2588
+ f.extraAttacksExecuting = false;
2589
+ if (f.extraAttacks <= 0) f.extraAttackSource = null;
2590
+ }
2591
+ const ohSpd = f.spec.offhand!.speedMs;
2592
+ f.ohSwingReadyAt = ctx.t + scaleTime(ohSpd, f.haste, 0);
2593
+ if (f.swingReadyAt - ctx.t < ATTACK_DISPLAY_DELAY_MS) {
2594
+ f.swingReadyAt = ctx.t + ATTACK_DISPLAY_DELAY_MS;
2595
+ }
2596
+ }
2597
+ }
2598
+ }
2599
+ combatPerfLeave('sim.swings');
2600
+ }
2601
+ combatPerf('sim.deaths', checkDeaths);
2602
+ ctx.dropIdlePossessInv?.();
2603
+ if (ctx.ended) { finalize(); return events.slice(startLen); }
2604
+
2605
+ // nextWakeAt is written AFTER this unit's startOrCast (gcdUntil moves). Finish the
2606
+ // previous thinker at the next iteration / after the loop.
2607
+ let wakeF: Fighter | null = null;
2608
+ const finishPrevWake = (): void => {
2609
+ if (!wakeF) return;
2610
+ wakeF.nextWakeAt = nextDecideWakeAt(
2611
+ wakeF, ctx.t, tickMs, slowEngaged(wakeF, ctx.t, threatOf),
2612
+ );
2613
+ // A halt is a DECISION, so it expires with the decision: the latch is only cleared by pickAction
2614
+ // and S2 sleeps the unit. An unengaged unit wakes at Infinity, so leave it unstamped.
2615
+ wakeF.haltDpsUntil = wakeF.haltDpsSchool && Number.isFinite(wakeF.nextWakeAt)
2616
+ ? wakeF.nextWakeAt
2617
+ : undefined;
2618
+ wakeF = null;
2619
+ };
2620
+ combatPerfEnter('sim.acts');
2621
+ refreshCheapDirty();
2622
+ for (const f of actionOrder()) {
2623
+ finishPrevWake();
2624
+ if (dead(f)) continue;
2625
+ if (skipsDecision(f, action)) {
2626
+ (ctx.slowDiag ??= emptySlowDiag()).pickSkip++;
2627
+ // Next-swing abilities still arm while the rotation snapshot is asleep. Enemies never arm one,
2628
+ // so resolving their target here threw the answer away. Skip foeOf when nobody has threat.
2629
+ if (hasEngagedHostile && !isEnemySide(f.side) && !isControlledHard(f) && kitHasNextSwing(f)) {
2630
+ armNextSwing(f, foeOf(f));
2631
+ }
2632
+ continue;
2633
+ }
2634
+ (ctx.slowDiag ??= emptySlowDiag()).pickThink++;
2635
+ f.decideDirty = false;
2636
+ wakeF = f;
2637
+ // Mid-cast: enemies may pick CAST_INTERRUPT_PREVIOUS. Friendlies keep the bar unless the pick below
2638
+ // is an instant interrupt (or the player pressed an instant). Cite: UnitAI.h CAST_INTERRUPT_PREVIOUS.
2639
+ const wasCasting = f.castingSpell != null;
2640
+ const wasChanneling = f.channeling != null && ctx.t < f.channeling.endsAt;
2641
+ // Mid-charge: travel-only until arrival (effects applied in motion phase).
2642
+ if (f.pendingCharge) continue;
2643
+ // Move-only stance: AI free-fire and charge kits stay off, while a player action or a queued
2644
+ // takeover still wins below. 'passive' is "do not fight"; 'hold' is "do not move".
2645
+ if (!isEnemySide(f.side)) {
2646
+ const st = (ctx as { unitStance?: Map<string, string> }).unitStance?.get(f.side);
2647
+ if (st === 'passive') {
2648
+ const hasManualQueue = (castOrdersOf(f)?.length ?? 0) > 0
2649
+ || (f === player && !ctx.aiPlayer && action != null);
2650
+ if (!hasManualQueue) continue;
2651
+ }
2652
+ }
2653
+ // A live walk order or WASD intent still fights: only cast-time and channel spells are refused.
2654
+ // The old gate skipped the whole pick, so instants and heals went dark for every walking tick.
2655
+ let walkingFriendly = false;
2656
+ let walkManualQueue = false;
2657
+ const walkOrders = (ctx as { unitMoveOrders?: Map<string, unknown[]> }).unitMoveOrders;
2658
+ const walkIntents = (ctx as { unitMoveIntents?: Map<string, unknown> }).unitMoveIntents;
2659
+ if (!isEnemySide(f.side)) {
2660
+ walkingFriendly = (walkOrders?.get(f.side)?.length ?? 0) > 0 || walkIntents?.has(f.side) === true;
2661
+ if (walkingFriendly) {
2662
+ walkManualQueue = (castOrdersOf(f)?.length ?? 0) > 0
2663
+ || (f === player && !ctx.aiPlayer && action != null);
2664
+ }
2665
+ }
2666
+ // Enemy (and pet) idle while player remains stealthed — opener window.
2667
+ if (f.side !== 'player' && player.stealthed) continue;
2668
+ const hardCC = isControlledHard(f); // stun / fear / polymorph
2669
+ // A player item-use is the whole action for the tick, blocked by hard control UNLESS the item is
2670
+ // what breaks it — otherwise a feared flag carrier can never trinket out.
2671
+ const breaksOwnCC = hardCC
2672
+ && isItemCombatAction(action)
2673
+ && itemBreaksControl(f, action.item);
2674
+ if (f === player && !ctx.aiPlayer && (!hardCC || breaksOwnCC) && isItemCombatAction(action)) {
2675
+ // Pass the target through UNRESOLVED. Defaulting to the player aimed every untargeted use at
2676
+ // the user, so an offensive item went off in your hand; useItem picks the default per kind.
2677
+ useItem(action.item, action.target);
2678
+ continue;
2679
+ }
2680
+ let spell: SpellDef | null = null;
2681
+ // Did a HUMAN press produce this pick? Only that may clip a cast bar in flight. A held one-shot
2682
+ // is not a keypress: treating it as one let a queued Rejuvenation cancel its own Regrowth bar.
2683
+ let fromHumanPress = false;
2684
+ const aiTgt = isEnemySide(f.side) ? hostileTargetOf(f) : foeOf(f);
2685
+ // Player, pet and companion share ONE ordered cast list: a pet-bar slot, a menu entry and a
2686
+ // Pre-buff row are the same one-shot on the same hold-through-a-GCD terms.
2687
+ const castList = castOrdersOf(f);
2688
+ const headCast = castList?.[0];
2689
+ // The enemy the head step named, live only while that step is. Kept SEPARATE from the AI target:
2690
+ // while the step waits for a GCD the rotation must keep hitting what it was already fighting.
2691
+ let queuedTgt: Fighter | null = null;
2692
+ /** Set only when the queued spell actually wins this tick — consumed at startOrCast. */
2693
+ let queuedCastTgt: Fighter | null = null;
2694
+ if (headCast?.targetSide != null && isEnemySide(headCast.targetSide)) {
2695
+ const qf = bySide(headCast.targetSide);
2696
+ if (qf && !dead(qf)) queuedTgt = qf;
2697
+ }
2698
+ // The head of the cast list for whichever friendly seat owns one. Extracted so the player runs
2699
+ // the identical path; `idle` is what the seat does on a tick the head cannot win.
2700
+ const takeHeldQueue = (idle: () => SpellDef | null): HeldPick => {
2701
+ const q = headCast;
2702
+ if (!castList || !q) return { spell: idle(), castTgt: null };
2703
+ // Retire the head, spent or proven impossible. The list is the ONLY copy of the step, so one
2704
+ // splice is the whole clear and no second map keeps a stale aim.
2705
+ const dropHead = (): void => {
2706
+ castList.shift();
2707
+ if (!castList.length) allCastOrders()?.delete(f.side);
2708
+ };
2709
+ // Say what became of the step: one line per cast, skip and drop is the whole progress report.
2710
+ // Only the engine can tell "waiting on a GCD" from "impossible", and the UI renders these.
2711
+ const orderEvent = (
2712
+ state: 'cast' | 'already_up' | 'dropped',
2713
+ reason?: string,
2714
+ ): void => {
2715
+ if (q.spellId == null) return; // an item-only step names no spell
2716
+ push({
2717
+ t: ctx.t,
2718
+ kind: 'cast_order',
2719
+ actor: f.side,
2720
+ spellId: q.spellId,
2721
+ ...(q.targetSide != null ? { target: q.targetSide } : {}),
2722
+ state,
2723
+ ...(reason != null ? { reason } : {}),
2724
+ });
2725
+ };
2726
+ let picked: SpellDef | null = null;
2727
+ let heldTgt: Fighter | null = null;
2728
+ // Party takeover: the queued step wins the next pick it CAN win, held rather than consumed on
2729
+ // sight. A self-cast is aimed at the CASTER, or a rangeMax-0 buff measures out of range.
2730
+ const kit = f.side === 'pet' ? (f.spec.petFullKit ?? f.spec.spells) : f.spec.spells;
2731
+ const qSpell = q.spellId != null ? spellFromKit(kit, q.spellId) : undefined;
2732
+ // Missing kit row → same as empty effects: not foe-directed.
2733
+ const qSelfCast = q.spellId != null && !(qSpell && isHostileSpellDef(qSpell));
2734
+ // Friendly aim: the step named a party member, so the cast is neither foe-directed nor aimed
2735
+ // at the caster. Range-gate against THAT body, or a 40 yd Rejuvenation lands on the caster.
2736
+ let qFriendly: Fighter | null = null;
2737
+ if (qSelfCast && q.targetSide != null && !isEnemySide(q.targetSide)) {
2738
+ const wf = bySide(q.targetSide);
2739
+ if (wf && !dead(wf)) qFriendly = wf;
2740
+ else {
2741
+ // The body it named is gone. Falling through would make it a SELF cast, which is
2742
+ // how a HoT aimed at a dead ally landed on the caster.
2743
+ dropHead();
2744
+ orderEvent('dropped', 'target died');
2745
+ return { spell: idle(), castTgt: null };
2746
+ }
2747
+ }
2748
+ // Already up. The ENGINE owns this and reads `buffActive`, the authoritative set. Deciding it
2749
+ // again from the view missed auras its seven filters drop, so the order retried until timeout.
2750
+ if (q.skipIfActive === true && q.spellId != null) {
2751
+ const holder = qFriendly ?? (qSelfCast ? f : null);
2752
+ if (holder?.buffActive.has(q.spellId)) {
2753
+ dropHead();
2754
+ orderEvent('already_up');
2755
+ return { spell: idle(), castTgt: null };
2756
+ }
2757
+ }
2758
+ const qTgt = qFriendly ?? (qSelfCast ? f : (queuedTgt ?? aiTgt));
2759
+ // An instant that is allowed to CLIP a cast bar already in flight: a Kick, or a live human press.
2760
+ // The same test the mid-cast gate below makes, so the two cannot drift. Cite: Spell.cpp cancel.
2761
+ const clipsCastInFlight = (sp: SpellDef): boolean =>
2762
+ (sp.castTimeMs ?? 0) <= 0 && (spellHasInterruptCast(sp) || q.fromPress === true);
2763
+ // Every gate the scheduler applies BELOW this pick that `canCastSpell` does not cover. Each
2764
+ // must read as WAIT: consuming the head and then hitting a `continue` casts nothing.
2765
+ const blockedBelow = (sp: SpellDef): boolean =>
2766
+ (hardCC && !isBreaker(sp, f))
2767
+ || ((wasCasting || wasChanneling) && !isEnemySide(f.side) && !clipsCastInFlight(sp))
2768
+ || (isStepBackWalking(f, ctx.t) && isHostileSpellDef(sp))
2769
+ || (spellIncursGcd(sp) && ctx.t < f.gcdUntil)
2770
+ || (f.silencedUntil > ctx.t && isSilencePrevented(sp))
2771
+ || (f.disarmedUntil > ctx.t && isWeaponAction(sp))
2772
+ || (f.pacifiedUntil > ctx.t && isPacifyPrevented(sp))
2773
+ || schoolLocked(f, sp);
2774
+ if (q.itemEntry != null) {
2775
+ dropHead();
2776
+ orderEvent('cast');
2777
+ useItem(q.itemEntry, f.side, f.side); // kit item on self (potion/healthstone)
2778
+ return { spell: null, castTgt: null };
2779
+ } else if (q.spellId != null) {
2780
+ // The pet resolves against everything it KNOWS, not the autocast subset: left-clicking a
2781
+ // greyed-out slot casts it once, as the client's pet bar does. Cite: Pet.h ACT_DISABLED.
2782
+ const sp = qSpell;
2783
+ if (!sp) {
2784
+ dropHead(); // no longer in the kit — cannot ever fire
2785
+ orderEvent('dropped', 'not in the kit');
2786
+ return { spell: idle(), castTgt: null };
2787
+ } else if (canCastSpell(f, qTgt, sp) && !blockedBelow(sp)) {
2788
+ dropHead();
2789
+ orderEvent('cast');
2790
+ picked = sp;
2791
+ // hand the one-shot target to the cast below (self-casts aim at the caster)
2792
+ heldTgt = qFriendly ?? (qSelfCast ? f : queuedTgt);
2793
+ // The heal/buff pool re-picks by triage unless pinned, so a Renew aimed at the
2794
+ // tank would still land on whoever was lowest. Cite: sessionPools lowestHpFriendlyOf.
2795
+ if (qFriendly) f.friendlyPin = qFriendly.side;
2796
+ } else {
2797
+ return { spell: idle(), castTgt: null }; // stay armed; rotation carries this tick
2798
+ }
2799
+ } else {
2800
+ dropHead(); // neither a spell nor an item — cannot ever fire
2801
+ return { spell: idle(), castTgt: null };
2802
+ }
2803
+ return { spell: picked, castTgt: heldTgt };
2804
+ };
2805
+ if (f === player && !ctx.aiPlayer) {
2806
+ const dest = combatActionDest(action);
2807
+ const spellId = combatActionSpellId(action);
2808
+ if (dest && spellId != null) {
2809
+ (ctx.castDestOverride ??= new Map()).set(player.side, { spellId, dest });
2810
+ } else {
2811
+ ctx.castDestOverride?.delete(player.side);
2812
+ }
2813
+ spell = chosenPlayerSpell(spellId, dest);
2814
+ fromHumanPress = spell != null;
2815
+ // Friendly aim travels with the action (macro `[@party2]`). Only a spell-bearing action
2816
+ // writes it, so a null tick mid-cast cannot wipe the pin of the cast in flight.
2817
+ if (spellId != null) {
2818
+ const wanted = typeof action === 'object' && action !== null && 'ally' in action
2819
+ ? (action as { ally?: CombatantId }).ally
2820
+ : undefined;
2821
+ player.friendlyPin = wanted ? (wanted as Side) : null;
2822
+ }
2823
+ // The player is a Pre-buff CASTER like any other seat, so an idle tick spends the held queue
2824
+ // instead of dropping to white swings. A live player has no rotation fallback.
2825
+ if (spell == null && headCast) {
2826
+ const held = takeHeldQueue(() => null);
2827
+ spell = held.spell;
2828
+ queuedCastTgt = held.castTgt;
2829
+ }
2830
+ }
2831
+ else if (headCast) {
2832
+ const held = takeHeldQueue(() => pickAction(f, aiTgt));
2833
+ spell = held.spell;
2834
+ queuedCastTgt = held.castTgt;
2835
+ }
2836
+ else spell = pickAction(f, aiTgt); // pet/totem/enemy AI; empty spells → null → idle
2837
+ // BG auto-rescue: hard-CC'd with no breaker SPELL picked, try a breaker ITEM from the unit's own
2838
+ // kit. All sides — an ally or an enemy bot escapes a stun the same way a player tap would.
2839
+ if (hardCC && cfg.autoRescueItems && (!spell || !isBreaker(spell, f)) && tryItemRescue(f)) continue;
2840
+ // Off-GCD next-swing arm. The provider returns one pick, so pack Cleave starved whenever a GCD
2841
+ // ability won: arm even when the pick is null or GCD-blocked, using the rage remainder.
2842
+ const tryArmNextSwing = (): void => {
2843
+ // An ARENA seat is a player and owns its next-swing kit; a creature has none, which is
2844
+ // why enemies were skipped outright. Measured without this: side B's warrior cast Heroic
2845
+ // Strike 0 times against side A's 10 over 5 bracket-2 mirrors (plan §W11).
2846
+ if (hardCC || f.pendingSwing) return;
2847
+ if (isEnemySide(f.side) && cfg.enemiesAreATeam !== true) return;
2848
+ if (f.disarmedUntil > ctx.t || f.pacifiedUntil > ctx.t) return;
2849
+ const arm = pickNextSwingArm(f, aiTgt);
2850
+ if (arm && canCastSpell(f, aiTgt, arm)) startOrCast(f, aiTgt, arm);
2851
+ };
2852
+ if (!spell) {
2853
+ // A null pick is the white-swing plan. Stealth enter cancelled auto-attack, so AI parked in
2854
+ // melee idled at full HP forever. Start it in the white band only, so openers walk in stealthed.
2855
+ const aiDriven = f === player ? ctx.aiPlayer : true;
2856
+ if (aiDriven && f.stealthed && !f.autoAttackEnabled) {
2857
+ const d = distanceBetween(f, aiTgt);
2858
+ const inMelee = d == null || d <= MELEE_CONTACT_YD;
2859
+ if (inMelee) {
2860
+ f.mode = 'melee';
2861
+ f.autoAttackEnabled = true;
2862
+ maybeBreakStealthOn(f, AURA_INTERRUPT.ATTACKING, 'attacking');
2863
+ breakFeignDeath(f);
2864
+ f.autoAttackEnabled = true;
2865
+ if (f.swingReadyAt > ctx.t + 60_000) f.swingReadyAt = ctx.t;
2866
+ }
2867
+ }
2868
+ tryArmNextSwing();
2869
+ continue;
2870
+ }
2871
+ // Mid-cast friendly: an instant Kick/CS, a player-pressed instant, or a different instant
2872
+ // the rotation just picked. Same-spell re-picks keep the bar. Cite: Unit.cpp:4203-4212.
2873
+ if ((wasCasting || wasChanneling) && !isEnemySide(f.side)) {
2874
+ const instant = (spell.castTimeMs ?? 0) <= 0;
2875
+ const kickClip = instant && spellHasInterruptCast(spell);
2876
+ const manualClip = instant && fromHumanPress;
2877
+ const rotationClip = cfg.enemiesAreATeam === true && instant && (
2878
+ (f.castingSpell != null && spell.id !== f.castingSpell.id)
2879
+ || (wasChanneling && (f.channeling?.spell.id ?? 0) !== spell.id)
2880
+ );
2881
+ if (!kickClip && !manualClip && !rotationClip) {
2882
+ tryArmNextSwing();
2883
+ continue;
2884
+ }
2885
+ }
2886
+ // Walking on WASD or a "go here" keeps instants only, for kiting; an engage or click-mob dest
2887
+ // still stops and pre-casts. The after-cast step-back skips cast-time until its yards are walked.
2888
+ if (isStepBackWalking(f, ctx.t) && isHostileSpellDef(spell)) continue;
2889
+ if ((spell.castTimeMs ?? 0) > 0 || (spell.channelMs ?? 0) > 0) {
2890
+ if (walkingFriendly && !walkManualQueue) {
2891
+ const hold = (ctx as { unitHoldCourse?: Set<string> }).unitHoldCourse?.has(f.side) === true;
2892
+ if (walkIntents?.has(f.side) === true || hold) {
2893
+ tryArmNextSwing();
2894
+ continue;
2895
+ }
2896
+ walkOrders?.delete(f.side);
2897
+ }
2898
+ }
2899
+ // Mid-cast enemy: only proceed when the armed slot carries CAST_INTERRUPT_PREVIOUS.
2900
+ if (wasCasting && isEnemySide(f.side)) {
2901
+ const cs = enemyCastBySide.get(f.side as EnemySide);
2902
+ const flags = cs?.pendingSlot?.entry.castFlags ?? 0;
2903
+ if ((flags & CAST_FLAG.INTERRUPT_PREVIOUS) === 0) {
2904
+ // Leave the in-flight cast alone; drop the pending slot so it is not marked used.
2905
+ if (cs) cs.pendingSlot = null;
2906
+ continue;
2907
+ }
2908
+ }
2909
+ // Hard control locks out everything EXCEPT a breaker that strips the active control(s)
2910
+ // (WotF under fear/sleep/charm — not under stun). Cite: KI-741 isBreaker mechanic gate.
2911
+ if (hardCC && !isBreaker(spell, f)) continue;
2912
+ if (!hardCC) {
2913
+ // Silence refuses PreventionType SILENCE and pacify refuses PACIFY. NOT "cast versus weapon
2914
+ // strike": Kick and Shield Bash are PACIFY and fire under a silence. Cite: Spell.cpp:6049-6052.
2915
+ if (f.silencedUntil > ctx.t && isSilencePrevented(spell)) {
2916
+ tryArmNextSwing();
2917
+ continue;
2918
+ }
2919
+ if (f.disarmedUntil > ctx.t && isWeaponAction(spell)) continue;
2920
+ if (f.pacifiedUntil > ctx.t && isPacifyPrevented(spell)) continue;
2921
+ // School lockout blocks silence-prevention casts of the locked school only.
2922
+ if (schoolLocked(f, spell)) {
2923
+ tryArmNextSwing();
2924
+ continue;
2925
+ }
2926
+ }
2927
+ // Only spells that incur GCD wait for the global cooldown to be clear. Off-GCD abilities
2928
+ // (gcdMs=0 and not defaulted for their role) and on-next-swing can fire through an active GCD.
2929
+ if (spellIncursGcd(spell) && ctx.t < f.gcdUntil) {
2930
+ tryArmNextSwing();
2931
+ continue;
2932
+ }
2933
+ // chargeUsed is set inside startOrCast when geometry charge travels / lands;
2934
+ // no-geometry path still marks it here for the classic once-per-combat gate.
2935
+ if (roleOf(spell) === 'charge' && !ctx.unitPositionsMutable) f.chargeUsed = true;
2936
+ // EventAI CAST or a spell-list TargetId through the shared selectTarget, defaulting to the
2937
+ // current victim. A rotation rule with target 'other' aims off-focus, one-shot and cleared on read.
2938
+ const offMap = (ctx as { rotationOffTarget?: Map<Side, EnemySide> }).rotationOffTarget;
2939
+ const offSide = offMap?.get(f.side);
2940
+ const offTgt = offSide ? bySide(offSide) : null;
2941
+ if (offSide) offMap?.delete(f.side);
2942
+ let castTgt = queuedCastTgt ?? (offTgt && !dead(offTgt) ? offTgt : null) ?? aiTgt;
2943
+ if (isEnemySide(f.side)) {
2944
+ const castState = enemyCastBySide.get(f.side as EnemySide);
2945
+ const pendingEntry = castState?.pendingSlot?.entry;
2946
+ const mode = pendingEntry?.target;
2947
+ const space = pendingEntry?.targetSpace === 'spell_list' ? 'spell_list' : 'eventai';
2948
+ const lowestHpYd = pendingEntry?.friendlyLowestHpYd ?? 0;
2949
+ const topN = pendingEntry?.highestHpTopThreat ?? 0;
2950
+ if (lowestHpYd > 0) {
2951
+ // DoSelectLowestHpFriendly: no wounded friendly in range means NO cast, which is what
2952
+ // `if (Unit* target = ...)` does. Cite: boss_sulfuron_harbinger.cpp:162-166.
2953
+ const healed = ctx.resolveLowestHpFriendly?.(
2954
+ f, lowestHpYd, pendingEntry?.friendlyHpDeficit ?? 1,
2955
+ ) ?? null;
2956
+ if (healed) castTgt = healed;
2957
+ else {
2958
+ if (castState?.pendingSlot) {
2959
+ markCreatureCastRowUsed(castState.slots, castState.pendingSlot, ctx.t, rng);
2960
+ castState.pendingSlot = null;
2961
+ }
2962
+ continue;
2963
+ }
2964
+ } else if (topN > 0) {
2965
+ const resolved = ctx.resolveHighestHpTopThreat?.(f, topN) ?? null;
2966
+ if (resolved) castTgt = resolved;
2967
+ else {
2968
+ // Primer CAST_OK, no 28308. Cite: boss_patchwerk.cpp:117-127 OnCheckTarget false.
2969
+ if (castState?.pendingSlot) {
2970
+ markCreatureCastRowUsed(castState.slots, castState.pendingSlot, ctx.t, rng);
2971
+ castState.pendingSlot = null;
2972
+ }
2973
+ continue;
2974
+ }
2975
+ } else if (mode != null && (space === 'spell_list' || mode !== TARGET_T.HOSTILE)) {
2976
+ const resolved = resolveEventAiCastTarget(
2977
+ f, spell, mode, space, pendingEntry?.selectClass ?? 0,
2978
+ // DoFindFriendlyEligibleDispel's own range literal. Cite: boss_bug_trio.cpp:371 50.0f.
2979
+ pendingEntry?.friendlyRadiusYd ?? 0,
2980
+ pendingEntry?.selectInMelee === true,
2981
+ );
2982
+ if (resolved) castTgt = resolved;
2983
+ else if (
2984
+ space === 'spell_list' && SPELL_LIST_FRIENDLY_TARGETS.has(mode | 0)
2985
+ && pendingEntry?.friendlyFallbackSelf === true
2986
+ ) {
2987
+ // `if (!target) target = m_creature;` — Sulfuron's Inspire buffs himself when no priest
2988
+ // is missing it. Cite: boss_sulfuron_harbinger.cpp:104-105.
2989
+ castTgt = f;
2990
+ } else if (space === 'spell_list' && SPELL_LIST_FRIENDLY_TARGETS.has(mode | 0)) {
2991
+ // An empty FRIENDLY pool casts NOTHING; keeping the AI victim aimed Yauj's Dispel at the
2992
+ // raid. Scoped to the four support selectors. Cite: UnitAI.cpp:1305-1318.
2993
+ if (castState) castState.pendingSlot = null;
2994
+ continue;
2995
+ } else if (pendingEntry?.selectInMelee === true) {
2996
+ // `if (Unit* target = SelectAttackingTarget(..., IN_MELEE_RANGE))` — nobody in melee is
2997
+ // NO cast, not a cast at whoever holds threat. Cite: boss_twinemperors.cpp:256-259.
2998
+ if (castState) castState.pendingSlot = null;
2999
+ continue;
3000
+ } else if ((pendingEntry?.selectClass ?? 0) > 0) {
3001
+ // SelectAttackingTarget returned no unit of that class — do not hit the tank.
3002
+ // Cite: boss_nefarian.cpp:266-287; no ResetCombatAction when the pick is null.
3003
+ if (castState) castState.pendingSlot = null;
3004
+ continue;
3005
+ }
3006
+ }
3007
+ }
3008
+ // Remember an explicit aim for the cast-completion path, which otherwise re-resolves the target.
3009
+ // Set and cleared only here, so a later pick supersedes it and nothing below can clobber it.
3010
+ {
3011
+ const ov = (ctx as {
3012
+ castTargetOverride?: Map<Side, { spellId: number; side: EnemySide }>;
3013
+ }).castTargetOverride;
3014
+ if (ov) {
3015
+ if (queuedCastTgt) ov.set(f.side, { spellId: spell.id, side: queuedCastTgt.side as EnemySide });
3016
+ else ov.delete(f.side);
3017
+ }
3018
+ }
3019
+ // One Prepare per press: the pre-swing arm already filled CURRENT_MELEE_SPELL for this blow.
3020
+ if (playerNextSwingPreArmed && f === player && isNextMeleeSwingSpell(spell)) continue;
3021
+ startOrCast(f, castTgt, spell);
3022
+ // After a GCD cast, queue Cleave/HS with remaining rage (next-swing is off-GCD).
3023
+ if (!isNextMeleeSwingSpell(spell)) tryArmNextSwing();
3024
+ // Arm EventAI repeat / exhaust after a cast attempt leaves the scheduler (per-enemy).
3025
+ if (isEnemySide(f.side)) {
3026
+ const castState = enemyCastBySide.get(f.side as EnemySide);
3027
+ if (castState?.pendingSlot && castState.pendingSlot.spell.id === spell.id) {
3028
+ const pending = castState.pendingSlot;
3029
+ // Row-level re-arm: EFLAG_RANDOM_ACTION siblings share the fired slot's timer.
3030
+ markCreatureCastRowUsed(castState.slots, pending, ctx.t, rng);
3031
+ countCreatureCastFire(pending);
3032
+ ctx.applyScriptDevTimerActions?.(f, spell.id, castTgt);
3033
+ // CAST_MAIN_SPELL only: pace by weapon speed so Shoot does not re-fire every
3034
+ // tick. D4 — specials never use weapon-pace filler. Cite: UnitAI.h CAST_MAIN_SPELL.
3035
+ const paceMs = Math.max(
3036
+ f.spec.weapon.speedMs | 0,
3037
+ personalCooldownMs(spell),
3038
+ 1500,
3039
+ );
3040
+ paceMainSpellSlot(pending, ctx.t, paceMs);
3041
+ // Main-spell / NPC Shoot: exclusive with white melee for this weapon window.
3042
+ // Cite: UnitAI CAST_MAIN_SPELL; isCreatureRangedAuto.
3043
+ const flags = pending.entry.castFlags ?? 0;
3044
+ if (isCreatureRangedMainAttack(spell, flags)) {
3045
+ f.swingReadyAt = Math.max(f.swingReadyAt, ctx.t + paceMs);
3046
+ }
3047
+ // CAST_DISTANCE_YOURSELF — stop combat chase / keep ranged gap after cast.
3048
+ // Cite: CreatureEventAI.cpp ~144–145 AddDistanceSpell; UnitAI.h ~76.
3049
+ if ((flags & CAST_FLAG.DISTANCE_YOURSELF) !== 0) {
3050
+ castState.eventAi.combatMovement = false;
3051
+ castState.eventAi.rangedMode = true;
3052
+ castState.eventAi.meleeEnabled = false;
3053
+ f.mode = 'ranged';
3054
+ f.autoAttackEnabled = false;
3055
+ }
3056
+ // CAST_NO_MELEE_IF_OOM — after a cast attempt, if still OOM, suppress melee approach.
3057
+ // Cite: UnitAI.h ~69 (header semantics; bake uses rarely).
3058
+ if ((flags & CAST_FLAG.NO_MELEE_IF_OOM) !== 0 && !canAfford(f, spell)) {
3059
+ castState.eventAi.meleeEnabled = false;
3060
+ f.autoAttackEnabled = false;
3061
+ }
3062
+ castState.pendingSlot = null;
3063
+ }
3064
+ }
3065
+ }
3066
+ finishPrevWake();
3067
+ combatPerfLeave('sim.acts');
3068
+ combatPerf('sim.deaths', checkDeaths);
3069
+ if (ctx.ended) { finalize(); return events.slice(startLen); }
3070
+
3071
+ // Power regen at end of tick (after spends): rage decay 200ms quanta while OOC, energy 100ms
3072
+ // quanta, mana 1s + 5SR (players) / pet mana 4s + 5SR, pet focus 4s, aura 116 combat HP.
3073
+ combatPerfEnter('sim.regen');
3074
+ for (const f of fightersOrder()) {
3075
+ if (dead(f)) continue;
3076
+ regenRage(f, tickMs);
3077
+ regenEnergy(f, tickMs);
3078
+ regenMana(f, tickMs);
3079
+ regenFocus(f, tickMs);
3080
+ regenHealth(f, tickMs);
3081
+ }
3082
+ combatPerfLeave('sim.regen');
3083
+
3084
+ ctx.t += tickMs;
3085
+ // Player::CheckDuelDistance — a duelist who runs out of the arbiter's ring forfeits.
3086
+ const fled = cfg.duel ? duelBounds.fled() : null;
3087
+ if (fled) {
3088
+ ctx.winner = fled === 'player' ? 'enemy' : 'player';
3089
+ ctx.ended = true;
3090
+ push({ t: ctx.t, kind: 'flavor', text: `${bySide(fled)?.spec.name ?? fled} fled the duel.` });
3091
+ finalize();
3092
+ return events.slice(startLen);
3093
+ }
3094
+ if (ctx.t > maxMs) { ctx.winner = 'timeout'; ctx.ended = true; finalize(); }
3095
+ return events.slice(startLen);
3096
+ };
3097
+
3098
+ // finishChargeArrival / runMotionPhase / areaAuraMaintainPhase installed by sessionMotion.
3099
+ ctx.startOrCast = startOrCast;
3100
+ // P11 ring trim + evade wrap sets ctx.step; deps passed so the captures stay identical.
3101
+ installStepWrap(ctx, { stepCore: step, events, push, player, pet, cfg, dead, isControlledHard });
3102
+ }