azeroth-shell 0.0.0-stage → 0.1.0

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 +26 -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 +126 -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,3031 @@
1
+ // PURE: no DOM/RNG/Date. Rotation build pipeline: defaults, resolve, gates, normalize.
2
+
3
+ import type { DataStore } from '../../data/dataStore.ts';
4
+ import { CLASS, SHAPESHIFT_FORM, type SpellDef } from '../../data/schema.ts';
5
+ import {
6
+ isRaidRuleId,
7
+ raidTacticsForSlug,
8
+ killOrderEntriesForBoss,
9
+ useObjectEntriesForBoss,
10
+ } from '../raid/raidEncounterTactics.ts';
11
+ import {
12
+ normalizeRaidCustomRows,
13
+ raidCustomRowRule,
14
+ type RaidCustomRow,
15
+ } from '../raid/raidCustomRows.ts';
16
+ import type { Character } from '../character/character.ts';
17
+ import {
18
+ knownUsableSpells,
19
+ combatItems,
20
+ isHunterAspectSpell,
21
+ isTriggeredSecondarySpell,
22
+ isHiddenInternalSpell,
23
+ } from '../character/character.ts';
24
+ import type { SpellBook } from '../character/spellbook.ts';
25
+ import { auraSidesOfSpell, keepUpPresenceCondition } from './auraSide.ts';
26
+ import type { Inventory } from '../items/inventory.ts';
27
+ import { inferRole } from '../../engine/combat.ts';
28
+ import { classUsesRangedAuto, classPowerType, SOUL_SHARD_ITEM } from '../core/ids.ts';
29
+ import { sanitizeStepBackYd } from './stand.ts';
30
+ import { isHealthstoneItem } from '../bots/healthstoneSupply.ts';
31
+ import { totemSlotIndex, totemSlotOfSpell } from '../spells/totem.ts';
32
+ import {
33
+ getClassSuggestions,
34
+ normalizeSpellName,
35
+ rankBaseSpellName,
36
+ selectBand,
37
+ suggestionClassKeyFromClassId,
38
+ type BandMode,
39
+ type SuggestionClassKey,
40
+ type SuggestionPhase,
41
+ type SuggestionRule,
42
+ SUGGESTION_PHASES,
43
+ } from './rotationSuggestions.ts';
44
+ import {
45
+ formIdFromLabel,
46
+ mapWhen,
47
+ materializeConditions,
48
+ } from '../run/whenMap.ts';
49
+ import { isFormSpell, shapeshiftCastError } from '../character/forms.ts';
50
+ import { SND_MIN_COMBO_POINTS } from './finisherLadder.ts';
51
+ import type {
52
+ CondMetric,
53
+ CondSubject,
54
+ Condition,
55
+ Rotation,
56
+ RotationAutoSig,
57
+ RotationPhase,
58
+ RotationRule,
59
+ RuleAction,
60
+ } from './types.ts';
61
+ import { DEFAULT_THRESHOLDS, REQUIRED_ROTATION_PHASES, ROTATION_PHASES } from './types.ts';
62
+ import { bestFamilyItem, consumableFamilyOfEntry } from '../items/consumableFamily.ts';
63
+ import { FLEE_CLEAR_MARGIN_YD } from './raidRowTuning.ts';
64
+ import { ensurePrebuffRows } from './prebuff.ts';
65
+
66
+ /** Mind Control. Every MC target lives in RAID_BOSS_TACTICS.mcEntries. */
67
+ const SPELL_MIND_CONTROL = 605;
68
+ import {
69
+ applyDefenseReactGate,
70
+ applyFormEnterGate,
71
+ applyMultiTargetCombatGates,
72
+ channeledAoeDamageConditions,
73
+ collapseExclusiveFamilyRules,
74
+ exclusiveKeepupFamily,
75
+ bindConjureStockGate,
76
+ ccBreakGateConditions,
77
+ cond,
78
+ conjureItemCountThreshold,
79
+ repairConjureStockGates,
80
+ dispelGateConditions,
81
+ ensureCondition,
82
+ formIdForRotationSpell,
83
+ hostileAuraEffect,
84
+ hostileStackCap,
85
+ isBloodrageSpell,
86
+ isChanneledAoeDamageSpell,
87
+ isCombatDamageShield,
88
+ isComboPointConsumer,
89
+ isDefenseReactSpell,
90
+ isDispelSpell,
91
+ isFeignDeathSpell,
92
+ isThreatDumpUtility,
93
+ isHostileSelfUtilityBuff,
94
+ isInterruptSpell,
95
+ isPureInterruptSpell,
96
+ isLongArmorKeepUp,
97
+ isSingleTargetPartyBuff,
98
+ isMageArmorSpell,
99
+ isManaDrainKeepUp,
100
+ isMaxHealthPctEmergency,
101
+ isDamagelessHardCc,
102
+ isFearSpell,
103
+ isMultiTargetCombatSpell,
104
+ isNovaKiteSpell,
105
+ isPiercingHowlSpell,
106
+ novaKiteConditions,
107
+ NOVA_KITE_STEP_BACK_YD,
108
+ piercingHowlGapConditions,
109
+ multiTargetNeedsPackDensity,
110
+ isNonKeepUpUtility,
111
+ isOffensiveBurstCooldown,
112
+ burstCooldownConditions,
113
+ isOverpowerSpell,
114
+ isHunterParryReactSpell,
115
+ isPresenceKeepUpOnly,
116
+ isPureHotSpell,
117
+ pureHotSelfConditions,
118
+ isBattleRezSpell,
119
+ isResurrectSpell,
120
+ isSealSpell,
121
+ isSelfDefensiveSpell,
122
+ isThreatOrTauntUtility,
123
+ isTrueWeaponImbueSpell,
124
+ isUseHealthstoneLabel,
125
+ requiredFormIdFromStances,
126
+ resurrectGateConditions,
127
+ sealKindOf,
128
+ sealKindPriority,
129
+ shouldDropEmptyEffectRule,
130
+ shouldScheduleTargetDebuffKeepUp,
131
+ shouldSkipBuffKeepUp,
132
+ spellAppliesTargetAura,
133
+ UNDEAD_DEMON_CREATURE_MASK,
134
+ } from './classify.ts';
135
+ import { bestCombatConsumables, combatConsumableConditions, friendlyCastItemGate } from './consumables.ts';
136
+ import { primarySpec } from './spec.ts';
137
+ import {
138
+ applyClassAfterFallback,
139
+ applyClassAfterSuggestion,
140
+ spellAllowedInRoleBucketFallback,
141
+ type FinalizeContext,
142
+ } from './classes/index.ts';
143
+
144
+ // Default conditions and phase for a manually added spell rule. Mirrors the generator's
145
+ // classification and returns `phase` as a HINT — the editor keeps whatever the user chose.
146
+ export function defaultSpellRuleConditions(
147
+ s: SpellDef,
148
+ char: Character,
149
+ ): { conditions: Condition[]; phase: RotationPhase } {
150
+ // Interrupts first: even a dual-role spell gets the casting gate on a manual add, since the
151
+ // generator already lists the nuke usage. The gate is visible and deletable.
152
+ if (isInterruptSpell(s)) {
153
+ return { conditions: [cond('target', 'castingPresent', '==', 1)], phase: 'combat' };
154
+ }
155
+ // Dispel: prefill the gate for its primary category — a friendly debuff for cures, an enemy buff
156
+ // for offensive-only ones. The user adds the other subject; the generator lists both.
157
+ if (isDispelSpell(s)) {
158
+ const cats = s.effects.filter((e) => e.kind === 'dispel').map((e) => (e as { dispelType: number }).dispelType);
159
+ const cat = cats[0] ?? 1;
160
+ const offensiveOnly = cat === 5 || cat === 6 || cat === 8 || cat === 9; // stealth/invisibility/npc/enrage
161
+ return {
162
+ conditions: [offensiveOnly
163
+ ? cond('target', 'dispellablePresent', '==', 1, cat)
164
+ : cond('self', 'dispellablePresent', '==', 1, cat)],
165
+ phase: 'combat',
166
+ };
167
+ }
168
+ // True TEMP imbues only — poison conjure vials use itemCount via role:'conjure' (KI-369).
169
+ if (isTrueWeaponImbueSpell(s)) {
170
+ // ref 2: cast while any eligible weapon hand is open (MH-first empty fill on spell path).
171
+ return { conditions: [cond('self', 'weaponImbuePresent', '==', 0, 2)], phase: 'buffs' };
172
+ }
173
+ // Party rez: never buffPresent keep-up (no self aura → thrash at costPct 75–90%).
174
+ // Gate on a dead friendly; snapshot defaults deadAllyCount to 0 until death is modelled.
175
+ if (isResurrectSpell(s)) {
176
+ // Rebirth is combat-castable (battle rez, long CD); classic OOC rezzes stay buffs-phase.
177
+ return {
178
+ conditions: resurrectGateConditions(),
179
+ phase: isBattleRezSpell(s) ? 'combat' : 'buffs',
180
+ };
181
+ }
182
+ // Overpower: post-dodge react only (never CP — warriors have no combo points).
183
+ if (isOverpowerSpell(s)) {
184
+ return { conditions: [cond('target', 'dodgedRecently', '==', 1)], phase: 'combat' };
185
+ }
186
+ if (isComboPointConsumer(s)) {
187
+ const isSnd = s.effects.some((e) => e.kind === 'buff' && e.mod === 'haste');
188
+ return {
189
+ conditions: isSnd
190
+ ? [
191
+ cond('self', 'comboPoints', '>=', SND_MIN_COMBO_POINTS),
192
+ cond('self', 'buffPresent', '==', 0, s.id),
193
+ ]
194
+ : [cond('self', 'comboPoints', '>=', 4)],
195
+ phase: 'combat',
196
+ };
197
+ }
198
+ const totemSlot = totemSlotOfSpell(s);
199
+ if (totemSlot && totemSlot !== 'none') {
200
+ return { conditions: [cond('self', 'totemPresent', '==', 0, totemSlotIndex(totemSlot))], phase: 'pull' };
201
+ }
202
+ if (isSealSpell(s)) {
203
+ return { conditions: [cond('self', 'buffPresent', '==', 0, s.id)], phase: 'combat' };
204
+ }
205
+ // Form / stance enter: only when not already in that form. Recasting the same form
206
+ // toggles OFF in combat — empty conditions → enter/leave spam every GCD.
207
+ if (isFormSpell(s) || formIdFromLabel(s.name) != null) {
208
+ const formId = formIdForRotationSpell(s);
209
+ return {
210
+ conditions: formId ? [cond('self', 'formId', '!=', formId)] : [],
211
+ phase: 'combat',
212
+ };
213
+ }
214
+ // Feign Death: an enemy is on you. NOT gated on a live pet — the source has no pet term, and that
215
+ // gate kept it from firing exactly when the hunter is about to be the one that dies.
216
+ if (isFeignDeathSpell(s)) {
217
+ return {
218
+ conditions: [cond('self', 'hasAggro', '==', 1)],
219
+ phase: 'combat',
220
+ };
221
+ }
222
+ // Damage cooldowns / racials: spend it into the fight, never a low-health panic.
223
+ if (isOffensiveBurstCooldown(s)) {
224
+ return { conditions: burstCooldownConditions(s), phase: 'combat' };
225
+ }
226
+ // Pure mechanic-immunity casts (Berserker Rage) gate on the control they remove. Only the first
227
+ // mechanic can be prefilled here, since this returns one set; the defaults emit the rest.
228
+ {
229
+ const ccBreaks = ccBreakGateConditions(s);
230
+ if (ccBreaks[0]) return { conditions: ccBreaks[0], phase: 'combat' };
231
+ }
232
+ // Self defensives (Ice Block, Divine Shield, Shield Wall): health gate in combat,
233
+ // never enemy debuff keep-up (KI-213 / KI-395 / KI-407).
234
+ if (isSelfDefensiveSpell(s)) {
235
+ return {
236
+ conditions: [cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct)],
237
+ phase: 'combat',
238
+ };
239
+ }
240
+ // A threat DUMP is not a taunt and had the taunt's gate, so Feint and Cower fired only while the
241
+ // target was on somebody else — never the situation they exist for. Dumps take the inverse.
242
+ if (isThreatDumpUtility(s)) {
243
+ return { conditions: [cond('self', 'targetAttackingMe', '==', 1)], phase: 'combat' };
244
+ }
245
+ if (isThreatOrTauntUtility(s)) {
246
+ return { conditions: [cond('self', 'targetAttackingMe', '==', 0)], phase: 'combat' };
247
+ }
248
+ // Frost Nova: fire when anyone is in the disc, then step back. Not "target is casting".
249
+ if (isNovaKiteSpell(s)) {
250
+ return { conditions: novaKiteConditions(s), phase: 'combat' };
251
+ }
252
+ // Fear: only when the target is not already feared. Not "enemy is casting" — that default
253
+ // is for sheep/poly. Any fear (this spell, Howl, Scream) counts as detected.
254
+ if (isFearSpell(s)) {
255
+ return { conditions: [cond('target', 'feared', '==', 0)], phase: 'combat' };
256
+ }
257
+ // Howl is control (hostile speed) so the generic branch below would stamp "enemy is casting".
258
+ if (isPiercingHowlSpell(s)) {
259
+ return { conditions: piercingHowlGapConditions(s), phase: 'combat' };
260
+ }
261
+ // Pure CC (sheep/poly/sap): default to "enemy is casting" so free-form kits
262
+ // sheep casters without a hand-written gate. User can ✕ the condition for hard-CC any target.
263
+ if ((s.role ?? inferRole(s)) === 'control' && !isInterruptSpell(s)) {
264
+ return { conditions: [cond('target', 'castingPresent', '==', 1)], phase: 'combat' };
265
+ }
266
+ // Hostile aura appliers (Sunder Armor, Faerie Fire, curses): a stacking aura reapplies below its
267
+ // cap, a single-instance one only when missing. Respects hostile:false — true enemy auras only.
268
+ if (shouldScheduleTargetDebuffKeepUp(s) && hostileAuraEffect(s)) {
269
+ const cap = hostileStackCap(s);
270
+ return {
271
+ conditions: cap
272
+ ? [cond('target', 'debuffStacks', '<', cap, s.id)]
273
+ : [cond('target', 'debuffPresent', '==', 0, s.id)],
274
+ phase: 'combat',
275
+ };
276
+ }
277
+
278
+ const role = s.role ?? inferRole(s);
279
+ const hasHeal = s.effects.some((e) => e.kind === 'heal' || e.kind === 'restore_health');
280
+ const conjured = s.effects.find((e) => e.kind === 'conjure');
281
+ switch (role) {
282
+ case 'pet_summon':
283
+ return { conditions: [cond('pet', 'alive', '==', 0)], phase: 'buffs' };
284
+ case 'pet_heal': {
285
+ // Health Funnel spends the owner's HP — don't start it inside the potion band.
286
+ const petHeal = [
287
+ cond('pet', 'alive', '==', 1),
288
+ cond('pet', 'healthPct', '<', DEFAULT_THRESHOLDS.petHealLowPct),
289
+ ...(s.powerType === 'health'
290
+ ? [cond('self', 'healthPct', '>', DEFAULT_THRESHOLDS.potionLowPct)]
291
+ : []),
292
+ ];
293
+ return { conditions: petHeal, phase: 'recovery' };
294
+ }
295
+ case 'conjure': {
296
+ return {
297
+ conditions: conjured
298
+ ? [cond('self', 'itemCount', '<', conjureItemCountThreshold(s), conjured.item)]
299
+ : [],
300
+ phase: 'buffs',
301
+ };
302
+ }
303
+ case 'dot':
304
+ // Mana drains are situational — not permanent missing-debuff keep-ups (KI-324).
305
+ if (isManaDrainKeepUp(s)) {
306
+ return { conditions: [], phase: 'combat' };
307
+ }
308
+ // Channeled multi-target damage (Blizzard / RoF / Hellfire / …) is multi-gated,
309
+ // never single-target DoT keep-up (KI-216 family).
310
+ if (isChanneledAoeDamageSpell(s)) {
311
+ return { conditions: channeledAoeDamageConditions(s), phase: 'combat' };
312
+ }
313
+ // Refresh the DoT only when the target doesn't already have it.
314
+ return { conditions: [cond('target', 'debuffPresent', '==', 0, s.id)], phase: 'combat' };
315
+ case 'control':
316
+ // Situational CC (Polymorph, Fear, Sap, Bash, …) is not a solo trash keep-up.
317
+ // Editor prefill: empty conditions (user can add gates); never force target.debuffPresent.
318
+ return { conditions: [], phase: 'combat' };
319
+ case 'buff':
320
+ if (isBloodrageSpell(s)) {
321
+ return {
322
+ conditions: [cond('self', 'powerPct', '<', DEFAULT_THRESHOLDS.bloodrageBelowPowerPct)],
323
+ phase: 'pull',
324
+ };
325
+ }
326
+ if (isLongArmorKeepUp(s) || isHostileSelfUtilityBuff(s) || isNonKeepUpUtility(s)) {
327
+ if (isHostileSelfUtilityBuff(s) || isNonKeepUpUtility(s)) {
328
+ return { conditions: [], phase: 'combat' }; // editor hint only; builder omits from auto
329
+ }
330
+ return {
331
+ conditions: [cond('self', 'buffPresent', '==', 0, s.id)],
332
+ phase: 'buffs',
333
+ };
334
+ }
335
+ if (hasHeal && !isLongArmorKeepUp(s)) {
336
+ // Pure HoTs (Renew/Rejuv): also require buff missing — never thrash while ticking.
337
+ if (isPureHotSpell(s)) {
338
+ return { conditions: pureHotSelfConditions(s.id), phase: 'recovery' };
339
+ }
340
+ return { conditions: [cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.healLowPct)], phase: 'recovery' };
341
+ }
342
+ if (isMaxHealthPctEmergency(s)) {
343
+ return {
344
+ conditions: [cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct)],
345
+ phase: 'recovery',
346
+ };
347
+ }
348
+ // Keep-up buffs recast only once the buff has fallen off. Long-cooldown self mitigation is
349
+ // already handled above, and long-CD arms default to combat rather than pull thrash.
350
+ const longPartyBuff = s.effects.some(
351
+ (e) =>
352
+ e.kind === 'buff'
353
+ && (e as { applyTo?: string }).applyTo === 'party'
354
+ && (e.durationMs ?? 0) >= 300_000,
355
+ );
356
+ return {
357
+ conditions: [cond('self', 'buffPresent', '==', 0, s.id)],
358
+ phase: s.outOfCombatOnly
359
+ || s.effects.some((e) => e.kind === 'damage_shield' || e.kind === 'proc_trigger_spell')
360
+ || longPartyBuff
361
+ ? 'buffs'
362
+ : shouldSkipBuffKeepUp(s)
363
+ ? 'combat'
364
+ : 'pull',
365
+ };
366
+ case 'charge':
367
+ return { conditions: [], phase: 'pull' };
368
+ case 'shoot':
369
+ return {
370
+ conditions: classPowerType(char.cls) === 'mana'
371
+ ? [cond('self', 'powerPct', '<', DEFAULT_THRESHOLDS.shootBelowPowerPct)]
372
+ : [],
373
+ phase: 'combat',
374
+ };
375
+ default:
376
+ return { conditions: [], phase: 'combat' };
377
+ }
378
+ }
379
+
380
+ // Default conditions for a manually added ITEM rule — potions, gems, healthstones, elixirs. An
381
+ // apply_buff item defaults to COMBAT so a mid-fight re-apply works; the OOC path stays unusable.
382
+ export function defaultItemRuleConditions(
383
+ effectKind: 'restore_health' | 'restore_power' | 'apply_buff' | 'hostile_cast'
384
+ | 'friendly_cast' | 'weapon_imbue' | string,
385
+ opts?: {
386
+ itemEntry?: number; buffSpellId?: number; hand?: 'mainhand' | 'offhand';
387
+ // The on-use SpellDef of a `friendly_cast` item, so the gate can read what it actually lands.
388
+ // Absent keeps the pre-payload behaviour (the CC gate), which is right for the trinkets.
389
+ spell?: SpellDef; spellId?: number; ds?: DataStore;
390
+ },
391
+ ): { conditions: Condition[]; phase: RotationPhase; autoOff?: boolean; neverAuto?: boolean } {
392
+ if (effectKind === 'restore_health') {
393
+ return { conditions: [cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.potionLowPct)], phase: 'recovery' };
394
+ }
395
+ if (effectKind === 'restore_power') {
396
+ return { conditions: [cond('self', 'powerPct', '<', DEFAULT_THRESHOLDS.manaRestoreLowPct)], phase: 'recovery' };
397
+ }
398
+ if (effectKind === 'apply_buff') {
399
+ const ref = opts?.buffSpellId ?? opts?.itemEntry;
400
+ return {
401
+ conditions: ref != null
402
+ ? [cond('self', 'buffPresent', '==', 0, ref)]
403
+ : [],
404
+ phase: 'combat',
405
+ };
406
+ }
407
+ // Grenades / bombs / Tidal Charm. The old catch-all put these in recovery with NO
408
+ // conditions, i.e. "throw a bomb whenever off cooldown, ahead of everything else".
409
+ if (effectKind === 'hostile_cast') {
410
+ return { conditions: [cond('target', 'healthPct', '>', 30)], phase: 'combat' };
411
+ }
412
+ // `friendly_cast` is six unrelated shapes (immunity, cure, summon, absorb, speed, damage coat).
413
+ // With the payload in hand each gets its own gate; without it the CC gate is the old default.
414
+ if (effectKind === 'friendly_cast') {
415
+ if (opts?.spell && opts.spellId != null) {
416
+ return friendlyCastItemGate(opts.spell, opts.spellId, opts.ds);
417
+ }
418
+ return { conditions: [cond('self', 'controlled', '==', 1)], phase: 'combat' };
419
+ }
420
+ // Temp weapon imbue (stone, oil, poison), buffs phase only: EffectEnchantItemTmp is
421
+ // out-of-combat, so this is pre-pull prep. ref 0 is mainhand and 1 is offhand.
422
+ if (effectKind === 'weapon_imbue') {
423
+ const ref = opts?.hand === 'offhand' ? 1 : 0;
424
+ return { conditions: [cond('self', 'weaponImbuePresent', '==', 0, ref)], phase: 'buffs' };
425
+ }
426
+ return { conditions: [], phase: 'recovery' };
427
+ }
428
+
429
+ // True when the label tokens match the DB name exactly, or as a consecutive token run, so
430
+ // "healing potion" fits "minor healing potion". No inside-token match: "Rip" is not "Riptide".
431
+ function labelMatchKind(dbName: string, label: string): 'exact' | 'fuzzy' | null {
432
+ const a = normalizeSpellName(dbName);
433
+ const b = normalizeSpellName(label);
434
+ if (!a || !b) return null;
435
+ if (a === b) return 'exact';
436
+ const aTok = a.split(' ').filter(Boolean);
437
+ const bTok = b.split(' ').filter(Boolean);
438
+ if (!bTok.length || bTok.length > aTok.length) return null;
439
+ // Single-token labels only match as a full token (not "rip" inside "riptide").
440
+ for (let i = 0; i <= aTok.length - bTok.length; i++) {
441
+ if (bTok.every((t, j) => aTok[i + j] === t)) return 'fuzzy';
442
+ }
443
+ return null;
444
+ }
445
+
446
+ /** True when two free-text labels refer to the same spell/item (exact or token containment). */
447
+ function labelMatchesName(dbName: string, label: string): boolean {
448
+ return labelMatchKind(dbName, label) != null;
449
+ }
450
+
451
+ function spellRankScore(s: SpellDef): number {
452
+ if ((s.rank ?? 0) > 0) return s.rank ?? 0;
453
+ return s.spellLevel ?? s.reqLevel ?? 0;
454
+ }
455
+
456
+ function betterKnownRank(a: SpellDef, b: SpellDef): boolean {
457
+ // Prefer weapon_imbue apply spells over create-item / empty same-name ranks (poison vials).
458
+ const aImbue = a.effects?.some((e) => e.kind === 'weapon_imbue') ? 1 : 0;
459
+ const bImbue = b.effects?.some((e) => e.kind === 'weapon_imbue') ? 1 : 0;
460
+ if (aImbue !== bImbue) return aImbue > bImbue;
461
+ // Prefer castable parent (trigger_spell Charge/Bloodrage) over aura/stun companion of the same name.
462
+ const aTrig = a.effects?.some((e) => e.kind === 'trigger_spell') ? 1 : 0;
463
+ const bTrig = b.effects?.some((e) => e.kind === 'trigger_spell') ? 1 : 0;
464
+ if (aTrig !== bTrig) return aTrig > bTrig;
465
+ const ra = spellRankScore(a);
466
+ const rb = spellRankScore(b);
467
+ return ra > rb || (ra === rb && a.id > b.id);
468
+ }
469
+
470
+ // Best known rank for any of `labels`. Rank parentheses (Create Healthstone (Major)) share a base
471
+ // name; "Charge" still does not bind "Charge Stun".
472
+ export function resolveBestKnownSpell(
473
+ labels: readonly string[],
474
+ known: readonly SpellDef[],
475
+ level: number,
476
+ ): SpellDef | null {
477
+ for (const label of labels) {
478
+ const labelBase = rankBaseSpellName(label);
479
+ let best: SpellDef | null = null;
480
+ for (const s of known) {
481
+ if ((s.reqLevel ?? 0) > level) continue;
482
+ const exact = labelMatchKind(s.name, label) === 'exact';
483
+ const sameRank = !exact && labelBase.length > 0 && rankBaseSpellName(s.name) === labelBase;
484
+ if (!exact && !sameRank) continue;
485
+ if (!best || betterKnownRank(s, best)) best = s;
486
+ }
487
+ if (best) return best;
488
+ }
489
+ return null;
490
+ }
491
+
492
+ /** Catalog inject for trainer-gap identities only — not talent-gated (Stormstrike/Holy Shield = KI-889). */
493
+ function isIdentityCatalogLabel(label: string): boolean {
494
+ const n = normalizeSpellName(label);
495
+ return n === 'judgement' || n === 'auto shot';
496
+ }
497
+
498
+ // Prefer the player castable over LEARN_SPELL stubs and creature clones when resolving identity
499
+ // labels from the full catalog. Cite: LEARN_SPELL Effect1=36 is never the castable action.
500
+ function betterCatalogIdentity(a: SpellDef, b: SpellDef): boolean {
501
+ const raw0 = (s: SpellDef) => s.rawEffects?.[0]?.effect ?? 0;
502
+ const score = (s: SpellDef): number => {
503
+ const e0 = raw0(s);
504
+ if (e0 === 36) return -100; // LEARN_SPELL wrapper — not the castable
505
+ let sc = 0;
506
+ if ((s.spellFamilyName ?? 0) > 0) sc += 10;
507
+ sc += (s.rank ?? 0);
508
+ sc += (s.reqLevel ?? 0);
509
+ sc -= s.id * 1e-9;
510
+ return sc;
511
+ };
512
+ return score(a) > score(b);
513
+ }
514
+
515
+ /** Classic player Judgement (seal unleash). Cite: spell_template Id 20271, Effect1=77 SCRIPT_EFFECT. */
516
+ const PLAYER_JUDGEMENT_ID = 20271;
517
+
518
+ // Best catalog rank for identity labels, beyond the current spellbook: exact name, reqLevel at or
519
+ // under the character's, no LEARN stubs. Judgement always resolves to player spell 20271.
520
+ export function resolveBestCatalogSpell(
521
+ labels: readonly string[],
522
+ ds: DataStore,
523
+ level: number,
524
+ ): SpellDef | null {
525
+ for (const label of labels) {
526
+ if (!isIdentityCatalogLabel(label)) continue;
527
+ // Player Judgement is a single unranked SCRIPT_EFFECT spell — never pick LEARN/creature clones.
528
+ if (normalizeSpellName(label) === 'judgement') {
529
+ const j = ds.spell(PLAYER_JUDGEMENT_ID);
530
+ if (j && !j.passive && (j.reqLevel ?? 0) <= level) return j;
531
+ continue;
532
+ }
533
+ let bestExact: SpellDef | null = null;
534
+ let bestFuzzy: SpellDef | null = null;
535
+ for (const s of ds.allSpells()) {
536
+ if (s.passive) continue;
537
+ if ((s.reqLevel ?? 0) > level) continue;
538
+ // Never schedule a LEARN_SPELL wrapper as the combat action.
539
+ if ((s.rawEffects?.[0]?.effect ?? 0) === 36) continue;
540
+ const kind = labelMatchKind(s.name, label);
541
+ if (kind === 'exact') {
542
+ if (!bestExact || betterCatalogIdentity(s, bestExact)) bestExact = s;
543
+ } else if (kind === 'fuzzy') {
544
+ if (!bestFuzzy || betterCatalogIdentity(s, bestFuzzy)) bestFuzzy = s;
545
+ }
546
+ }
547
+ if (bestExact) return bestExact;
548
+ if (bestFuzzy) return bestFuzzy;
549
+ }
550
+ return null;
551
+ }
552
+
553
+ // Authored potion, healthstone and bandage rows are stored as `spell:` but resolve to catalog
554
+ // items. A real ability label must never fall through to gear, librams, tomes or scrolls.
555
+ function suggestionAllowsItemResolve(sug: SuggestionRule): boolean {
556
+ if (sug.kind === 'item') return true;
557
+ return sug.labels.some((l) => {
558
+ const n = normalizeSpellName(l);
559
+ return /\bpotion\b/.test(n) || /\bhealthstone\b/.test(n) || /\bbandage\b/.test(n);
560
+ });
561
+ }
562
+
563
+ // All non-passive known spells at their highest rank, including bake "utility" stubs like Life Tap
564
+ // that have empty effects. For suggestion name resolve only — session usability is still the gate.
565
+ export function knownSpellsForSuggestionResolve(
566
+ char: Character,
567
+ sb: SpellBook,
568
+ ds: DataStore,
569
+ ): SpellDef[] {
570
+ // level gating for RANKS is in resolveBestKnownSpell; `char.level` here only feeds the
571
+ // interpreter so level-scaled scripted effects resolve before the inert-phantom check.
572
+ const byName = new Map<string, SpellDef>();
573
+ for (const id of sb.known) {
574
+ const s = ds.spell(id);
575
+ if (!s || s.passive) continue;
576
+ // Client-hidden internals (Opening / Grovel / Duel / Attacking) are never rotation actions.
577
+ if (isHiddenInternalSpell(s)) continue;
578
+ // Never resolve authored "Charge"/"Intercept"/"Pounce" labels onto stun/bleed payloads
579
+ // (fuzzy token match would otherwise prefer "Charge Stun" over a missing Charge).
580
+ if (isTriggeredSecondarySpell(s, ds)) continue;
581
+ const key = rankBaseSpellName(s.name) || s.name;
582
+ const prev = byName.get(key);
583
+ if (!prev || betterKnownRank(s, prev)) byName.set(key, s);
584
+ }
585
+ return [...byName.values()];
586
+ }
587
+
588
+ // Labels that may intentionally resolve to bag or catalog items when authored as spell rows. An
589
+ // unlearned class spell must NOT fall through to a training scroll (KI-278 / KI-334).
590
+ function isConsumableItemLabel(label: string): boolean {
591
+ const n = normalizeSpellName(label);
592
+ if (!n) return false;
593
+ return (
594
+ /\b(potion|elixir|flask|healthstone|bandage|food|water|drink|oil|juju|salt|mana gem|mana agate|mana jade|mana citrine|mana ruby)\b/.test(n)
595
+ || /^(minor|lesser|greater|superior|major|mighty)\s+healing\b/.test(n)
596
+ || /^healing\s+potion$/.test(n)
597
+ || /^health\s+potion$/.test(n)
598
+ || /^potion$/.test(n)
599
+ );
600
+ }
601
+
602
+ /** Training / recipe items that fuzzy-match spell names but are not combat consumables. */
603
+ function isSpellTrainingItemName(itemName: string): boolean {
604
+ const n = normalizeSpellName(itemName);
605
+ return (
606
+ /\b(scroll|tablet|tome|grimoire|libram|codex|book of|manual of|formula:|plans:|pattern:|schematic:|recipe:)\b/.test(n)
607
+ );
608
+ }
609
+
610
+ // The catalog half of one label resolve, memoized per store (0 caches "no match") — every label
611
+ // comparison runs through normalizeSpellName, so the normalized string is a complete key.
612
+ const catalogItemMemo = new WeakMap<DataStore, Map<string, number>>();
613
+
614
+ function resolveCatalogItem(
615
+ label: string,
616
+ ds: DataStore,
617
+ level: number,
618
+ spellFallthrough: boolean,
619
+ ): number | null {
620
+ let memo = catalogItemMemo.get(ds);
621
+ if (!memo) { memo = new Map(); catalogItemMemo.set(ds, memo); }
622
+ const key = `${normalizeSpellName(label)}|${level}|${spellFallthrough ? 1 : 0}`;
623
+ const hit = memo.get(key);
624
+ if (hit !== undefined) return hit === 0 ? null : hit;
625
+ let found = 0;
626
+ for (const it of ds.allItems()) {
627
+ // HJ1: catalog fallthrough must never pick classic-db GM/QA/test junk (e.g. "Monster - …" weapons
628
+ // matching short combat labels). Bags still win above — only the unowned catalog scan is filtered.
629
+ if (it.notObtainable) continue;
630
+ if (
631
+ labelMatchesName(it.name, label)
632
+ && (it.requiredLevel ?? 0) <= level
633
+ && !(spellFallthrough && isSpellTrainingItemName(it.name))
634
+ ) {
635
+ found = it.entry;
636
+ break;
637
+ }
638
+ }
639
+ memo.set(key, found);
640
+ return found === 0 ? null : found;
641
+ }
642
+
643
+ // Resolve an item label to an entry: bags first, else the first catalog name match at or under the
644
+ // character's level. With spellFallthrough, only consumable-style names are accepted.
645
+ export function resolveBestItem(
646
+ labels: readonly string[],
647
+ ds: DataStore,
648
+ level: number,
649
+ inventory?: Inventory,
650
+ opts?: { spellFallthrough?: boolean },
651
+ ): number | null {
652
+ const spellFallthrough = opts?.spellFallthrough === true;
653
+ for (const label of labels) {
654
+ if (spellFallthrough && !isConsumableItemLabel(label)) continue;
655
+ if (inventory) {
656
+ for (const stack of inventory.stacks) {
657
+ const it = ds.item(stack.item);
658
+ if (
659
+ it
660
+ && labelMatchesName(it.name, label)
661
+ && (it.requiredLevel ?? 0) <= level
662
+ && !(spellFallthrough && isSpellTrainingItemName(it.name))
663
+ ) {
664
+ return it.entry;
665
+ }
666
+ }
667
+ }
668
+ const fromCatalog = resolveCatalogItem(label, ds, level, spellFallthrough);
669
+ if (fromCatalog != null) return fromCatalog;
670
+ }
671
+ return null;
672
+ }
673
+
674
+ // Resolve a pack item label to a ROW: the entry to show plus its rank ladder. The packs author the
675
+ // bottom rung by name, so a level 60 character drank 70 HP until the row carried the family.
676
+ export function resolveBestItemRow(
677
+ labels: readonly string[],
678
+ ds: DataStore,
679
+ char: Character,
680
+ inventory?: Inventory,
681
+ opts?: { spellFallthrough?: boolean },
682
+ ): { itemEntry: number; family?: string } | null {
683
+ const entry = resolveBestItem(labels, ds, char.level, inventory, opts);
684
+ if (entry == null) return null;
685
+ const family = consumableFamilyOfEntry(entry, ds);
686
+ if (!family) return { itemEntry: entry };
687
+ const best = bestFamilyItem(family, ds, { char, ...(inventory ? { inventory } : {}) });
688
+ return { itemEntry: best?.entry ?? entry, family };
689
+ }
690
+
691
+ // ---- Role-bucket default (pre-suggestion fallback) ------------------------
692
+
693
+ // Classify known spells into role buckets — control, DoT, nuke and the rest. The full default when
694
+ // no suggestion pack applies, and the fallback tail after suggestion rules.
695
+ export function buildRoleBucketRotation(
696
+ char: Character,
697
+ sb: SpellBook,
698
+ ds: DataStore,
699
+ inventory?: Inventory,
700
+ ): Rotation {
701
+ const spells = knownUsableSpells(char, sb, ds);
702
+ const buffs: RotationRule[] = [];
703
+ const pull: RotationRule[] = [];
704
+ const combat: RotationRule[] = [];
705
+ const recovery: RotationRule[] = [];
706
+ let n = 0;
707
+ const rule = (action: RuleAction, conditions: Condition[]): RotationRule => ({ id: `d${n++}`, action, conditions, enabled: true });
708
+ const spellRule = (s: SpellDef, conditions: Condition[]): RotationRule => rule({ kind: 'spell', spellId: s.id }, conditions);
709
+
710
+ // Combat abilities are ordered by role priority so higher-value actions sit earlier in the list.
711
+ const combatByRole: Record<string, RotationRule[]> = { control: [], dot: [], instant_nuke: [], nuke: [], strike: [], shoot: [] };
712
+ // Spells whose rules are enemyCount>=2-gated (Cleave/Whirlwind/Arcane Explosion…)
713
+ // — emitted before unconditional single-target rules in the same bucket.
714
+ const multiTargetSpellIds = new Set<number>();
715
+ const seals: SpellDef[] = []; // paladin seals (only ONE is kept up — added after the loop)
716
+ const judgements: SpellDef[] = []; // paladin Judgement (unleashes the active seal)
717
+ const finishers: RotationRule[] = []; // rogue finishers (CP-gated)
718
+ const builders: RotationRule[] = []; // rogue combo builders
719
+ const reacts: RotationRule[] = []; // post-dodge Overpower etc.
720
+ const interrupts: RotationRule[] = []; // Kick/Pummel/Counterspell… fire only while the enemy casts
721
+ const dispels: RotationRule[] = []; // Cleanse/Cure/Remove Curse/Purge/Dispel Magic — fire only when a matching aura is up
722
+ const cooldowns: RotationRule[] = []; // damage CDs + racials (Death Wish, Blood Fury, Arcane Power…) and CC breaks
723
+ // Spec for seal kind priority (Ret prefers Command). Guard missing talentTrees in unit stubs.
724
+ const sealSpec = typeof (ds as { talentTrees?: unknown }).talentTrees === 'function'
725
+ ? primarySpec(char, ds)
726
+ : null;
727
+
728
+ for (const s of spells) {
729
+ if (isResurrectSpell(s)) {
730
+ // Rebirth is the one resurrection that belongs in a rotation: near-instant, castable in
731
+ // combat, and wasted if unused. Every other rez is a 10 s OOC cast and stays out.
732
+ if (isBattleRezSpell(s)) {
733
+ combat.push(spellRule(s, resurrectGateConditions()));
734
+ }
735
+ continue;
736
+ }
737
+ // Paladin seals are an exclusive family, collected for single-seal combat upkeep. Bake residual:
738
+ // a seal often lacks role 'seal' and would otherwise land in buffs via proc_trigger_spell.
739
+ if (isSealSpell(s)) {
740
+ seals.push(s);
741
+ continue;
742
+ }
743
+ // True weapon imbues (shaman Rockbiter/WF/…): OOC keep-up while any eligible hand open.
744
+ // Poison *conjure* vials are NOT imbues — they fall through to role:'conjure' (KI-369/388).
745
+ if (isTrueWeaponImbueSpell(s)) {
746
+ buffs.push(spellRule(s, [cond('self', 'weaponImbuePresent', '==', 0, 2)]));
747
+ continue;
748
+ }
749
+ // Interrupts sit at the very top of combat, gated on the enemy actually casting —
750
+ // never wasted as a filler nuke, always ready when the cast bar appears.
751
+ if (isInterruptSpell(s)) {
752
+ interrupts.push(spellRule(s, [cond('target', 'castingPresent', '==', 1)]));
753
+ continue;
754
+ }
755
+ // Dispels sit next to interrupts, gated on a matching aura being present — one rule per
756
+ // category, so a multi-cure fires on poison OR disease OR magic, and only when it would act.
757
+ if (isDispelSpell(s)) {
758
+ for (const conds of dispelGateConditions(s)) dispels.push(spellRule(s, conds));
759
+ continue;
760
+ }
761
+ // Damage cooldowns and racials are placed HERE, ahead of the role switch: most bake empty, and
762
+ // isNonKeepUpUtility drops every empty-effect long CD, so the role path could only discard them.
763
+ if (isOffensiveBurstCooldown(s, (id) => ds.spell(id))) {
764
+ cooldowns.push(spellRule(s, burstCooldownConditions(s)));
765
+ continue;
766
+ }
767
+ // Pure mechanic-immunity self casts (Berserker Rage): cast to shed the control, never on
768
+ // low health. One rule per mechanic the snapshot can see — conditions are ANDed.
769
+ {
770
+ const ccBreaks = ccBreakGateConditions(s);
771
+ if (ccBreaks.length) {
772
+ for (const conds of ccBreaks) cooldowns.push(spellRule(s, conds));
773
+ continue;
774
+ }
775
+ }
776
+
777
+ const role = s.role ?? inferRole(s);
778
+ // Direct heals + HoTs (Renew / Rejuvenation are restore_health in bake).
779
+ const hasHeal = s.effects.some((e) => e.kind === 'heal' || e.kind === 'restore_health');
780
+ const conjured = s.effects.find((e) => e.kind === 'conjure');
781
+ // Overpower: dodge react — not a CP finisher (bake wrongly flags requiresComboPoints).
782
+ if (isOverpowerSpell(s)) {
783
+ reacts.push(spellRule(s, [cond('target', 'dodgedRecently', '==', 1)]));
784
+ continue;
785
+ }
786
+ // Revenge / Riposte: self defense-react only — never bare strike/control thrash (KI-251).
787
+ if (isDefenseReactSpell(s)) {
788
+ reacts.push(spellRule(s, [cond('self', 'defenseReactRecently', '==', 1)]));
789
+ continue;
790
+ }
791
+ // Counterattack: the hunter's own parry (AURA_STATE_HUNTER_PARRY), not the defense window.
792
+ if (isHunterParryReactSpell(s)) {
793
+ reacts.push(spellRule(s, [cond('self', 'hunterParryRecently', '==', 1)]));
794
+ continue;
795
+ }
796
+ // Combo-point kit: gate finishers on CP and leave builders as free fillers, so Eviscerate is
797
+ // not cast at 0 CP ahead of Sinister Strike. Real energy consumers only, not rage bake noise.
798
+ if (isComboPointConsumer(s)) {
799
+ const isSnd = s.effects.some((e) => e.kind === 'buff' && e.mod === 'haste');
800
+ if (isSnd) {
801
+ finishers.push(spellRule(s, [
802
+ cond('self', 'comboPoints', '>=', 1),
803
+ cond('self', 'buffPresent', '==', 0, s.id),
804
+ ]));
805
+ } else {
806
+ finishers.push(spellRule(s, [cond('self', 'comboPoints', '>=', 4)]));
807
+ }
808
+ continue;
809
+ }
810
+ if (s.comboPointsGenerated && !isComboPointConsumer(s)) {
811
+ builders.push(spellRule(s, []));
812
+ continue;
813
+ }
814
+ switch (role) {
815
+ case 'pet_summon':
816
+ // Summon/Call when there is no living pet. (Summon Imp is free; the other demons cost a
817
+ // soul shard, but those aren't in the current data slice — no shard gate here.)
818
+ buffs.push(spellRule(s, [cond('pet', 'alive', '==', 0)]));
819
+ break;
820
+ case 'totem_summon': {
821
+ // Drop totem on pull when the element slot is empty (Wave 1D). Slot gate stops thrash
822
+ // across ranks/families (Searing vs Magma both fire), not only the same spell id.
823
+ const slot = totemSlotOfSpell(s);
824
+ if (slot && slot !== 'none') {
825
+ pull.push(spellRule(s, [cond('self', 'totemPresent', '==', 0, totemSlotIndex(slot))]));
826
+ }
827
+ break;
828
+ }
829
+ case 'pet_heal':
830
+ recovery.push(spellRule(s, [
831
+ cond('pet', 'alive', '==', 1),
832
+ cond('pet', 'healthPct', '<', DEFAULT_THRESHOLDS.petHealLowPct),
833
+ ...(s.powerType === 'health'
834
+ ? [cond('self', 'healthPct', '>', DEFAULT_THRESHOLDS.potionLowPct)]
835
+ : []),
836
+ ]));
837
+ break;
838
+ case 'pet_tame':
839
+ // Taming is a deliberate, target-specific action — not part of the auto rotation by default.
840
+ break;
841
+ case 'conjure': {
842
+ // Conjure Food/Water stock a few (conjureBelow=5); stones/gems/reagents hold one (KI-225/311/370/376).
843
+ buffs.push(spellRule(s, conjured
844
+ ? [cond('self', 'itemCount', '<', conjureItemCountThreshold(s), conjured.item)]
845
+ : []));
846
+ break;
847
+ }
848
+ case 'buff': {
849
+ // knownUsableSpells may fill effects via the interpreter while the bake row is still
850
+ // empty — never schedule a permanent missing-debuff keep-up on an empty bake (KI-359).
851
+ const bakeHasEffects = ((ds.spell(s.id)?.effects?.length) ?? 0) > 0;
852
+ if (
853
+ bakeHasEffects
854
+ && shouldScheduleTargetDebuffKeepUp(s)
855
+ && hostileAuraEffect(s)
856
+ ) {
857
+ // A hostile aura applier keeps it ON THE TARGET — reapply below the stack cap or when
858
+ // missing — never as a self-buff. hostile:false self mods fall through to the buff paths.
859
+ const hostileCap = hostileStackCap(s);
860
+ combatByRole.control.push(spellRule(s, hostileCap
861
+ ? [cond('target', 'debuffStacks', '<', hostileCap, s.id)]
862
+ : [cond('target', 'debuffPresent', '==', 0, s.id)]));
863
+ } else if (isHostileSelfUtilityBuff(s) || isNonKeepUpUtility(s)) {
864
+ // Amplify Magic (raises damage taken), Blink/Cold Snap/PoM empty keep-ups: omit from auto.
865
+ break;
866
+ } else if (isBloodrageSpell(s)) {
867
+ // Rage generator — only when low on rage (KI-330), never unconditional always.
868
+ pull.push(spellRule(s, [cond('self', 'powerPct', '<', DEFAULT_THRESHOLDS.bloodrageBelowPowerPct)]));
869
+ } else if (isLongArmorKeepUp(s) || isMageArmorSpell(s) || isHunterAspectSpell(s)) {
870
+ // Long armor keep-ups belong here, never recovery-on-low-HP (KI-398/412). Mage Armor is six
871
+ // resists and Monkey a bare dodge, so the exclusion family (group 13 / 10) is the real test.
872
+ buffs.push(spellRule(s, [cond('self', 'buffPresent', '==', 0, s.id)]));
873
+ } else if (hasHeal && !isLongArmorKeepUp(s)) {
874
+ // A heal is a recovery action (fire below the health threshold).
875
+ // Pure HoTs also require the buff missing — never thrash while already ticking.
876
+ recovery.push(spellRule(
877
+ s,
878
+ isPureHotSpell(s)
879
+ ? pureHotSelfConditions(s.id)
880
+ : [cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.healLowPct)],
881
+ ));
882
+ } else if (isMaxHealthPctEmergency(s)) {
883
+ // Last Stand (+30% HP): emergency recovery, never pull buffPresent thrash (KI-375).
884
+ recovery.push(spellRule(s, [
885
+ cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct),
886
+ ]));
887
+ } else if (s.effects.some((e) => e.kind === 'weapon_imbue')) {
888
+ // Weapon imbues: exclusive TEMP hand gate, OOC keep-up.
889
+ buffs.push(spellRule(s, [cond('self', 'weaponImbuePresent', '==', 0, 0)]));
890
+ } else if (isCombatDamageShield(s)) {
891
+ // Short combat damage shields (Holy Shield) are tank identity in combat, not OOC prep
892
+ // thrash; long shields stay OOC below. Protection knows it while the packs omit it.
893
+ combatByRole.control.push(spellRule(s, [cond('self', 'buffPresent', '==', 0, s.id)]));
894
+ } else if (isSelfDefensiveSpell(s)) {
895
+ // Divine Shield / Shield Wall / similar: panic self CD in combat (health gate),
896
+ // never enemy debuff / pull thrash (KI-213 / KI-259).
897
+ combatByRole.control.push(spellRule(s, [
898
+ cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct),
899
+ ]));
900
+ } else if (shouldSkipBuffKeepUp(s)) {
901
+ // Long-CD combat CDs / next-cast arms / pure immunity bursts (Recklessness,
902
+ // Fel Domination, Berserker Rage) — not maintainable keep-ups (KI-373/374/377).
903
+ break;
904
+ } else if (
905
+ s.effects.some((e) => e.kind === 'damage_shield' || e.kind === 'proc_trigger_spell')
906
+ || s.outOfCombatOnly
907
+ // Long party blessings (Salvation/Dampen ≥5m) — OOC buffs phase, not enemy debuff.
908
+ // Short combat shouts (Battle Shout 2m) stay pull keep-ups.
909
+ || s.effects.some(
910
+ (e) =>
911
+ e.kind === 'buff'
912
+ && (e as { applyTo?: string }).applyTo === 'party'
913
+ && (e.durationMs ?? 0) >= 300_000,
914
+ )
915
+ ) {
916
+ // Keep-up OOC: Lightning Shield / long proc auras / armor buffs / long party blessings.
917
+ // Gate on buffPresent so we never thrash-cast every prep tick (weapon imbues handled above).
918
+ buffs.push(spellRule(s, [cond('self', 'buffPresent', '==', 0, s.id)]));
919
+ } else {
920
+ // A combat buff (Battle Shout): fire once during the opener.
921
+ pull.push(spellRule(s, [cond('self', 'buffPresent', '==', 0, s.id)]));
922
+ }
923
+ break;
924
+ }
925
+ case 'charge':
926
+ // Charge Stun / Intercept Stun are trigger companions — never pull openers.
927
+ if (normalizeSpellName(s.name).includes('stun')) break;
928
+ pull.push(spellRule(s, []));
929
+ break;
930
+ case 'control':
931
+ // Self defensives mis-roled as control (Ice Block, Divine Intervention: self CC+immunity).
932
+ // Combat + self.healthPct so panic CDs stay in the fight list (KI-395 / KI-407).
933
+ if (isSelfDefensiveSpell(s)) {
934
+ combatByRole.control.push(spellRule(s, [
935
+ cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct),
936
+ ]));
937
+ break;
938
+ }
939
+ // Threat dumps / taunts / pure threat — never enemy debuff keep-ups (KI-268/271/277…).
940
+ if (isThreatOrTauntUtility(s)) break;
941
+ // Frost Nova: root whoever is in the 10 yd disc, then walk out. Other CC stays
942
+ // off the default list (poly/fear/sap are not trash keep-ups).
943
+ if (isNovaKiteSpell(s)) {
944
+ combatByRole.control.push({
945
+ ...spellRule(s, novaKiteConditions(s)),
946
+ after: { stepBackYd: NOVA_KITE_STEP_BACK_YD },
947
+ });
948
+ break;
949
+ }
950
+ // Situational CC is not a permanent solo keep-up, but dropping it left the generated
951
+ // rotation with none at all. Emit the authored packs' shape: hold a SECOND mob in a pack.
952
+ if (isDamagelessHardCc(s)) {
953
+ combatByRole.control.push(
954
+ rule(
955
+ { kind: 'spell', spellId: s.id, target: 'other' },
956
+ [cond('self', 'enemyCount', '>=', 2)],
957
+ ),
958
+ );
959
+ }
960
+ break;
961
+ case 'dot':
962
+ // Pure mana drains (Drain Mana) — situational, not missing-debuff keep-ups (KI-324).
963
+ if (isManaDrainKeepUp(s)) break;
964
+ // Channeled multi-target damage → radius/pack gate only (never ST debuff keep-up).
965
+ // True single-target DoTs still reapply when missing on the primary target.
966
+ if (isChanneledAoeDamageSpell(s)) {
967
+ combatByRole.dot.push(spellRule(s, channeledAoeDamageConditions(s)));
968
+ } else {
969
+ combatByRole.dot.push(spellRule(s, [cond('target', 'debuffPresent', '==', 0, s.id)]));
970
+ }
971
+ break;
972
+ case 'instant_nuke':
973
+ if (isMultiTargetCombatSpell(s)) multiTargetSpellIds.add(s.id);
974
+ combatByRole.instant_nuke.push(spellRule(s, []));
975
+ break;
976
+ case 'nuke':
977
+ if (isMultiTargetCombatSpell(s)) multiTargetSpellIds.add(s.id);
978
+ combatByRole.nuke.push(spellRule(s, []));
979
+ break;
980
+ case 'strike':
981
+ if (isMultiTargetCombatSpell(s)) multiTargetSpellIds.add(s.id);
982
+ combatByRole.strike.push(spellRule(s, []));
983
+ break;
984
+ case 'seal':
985
+ seals.push(s);
986
+ break;
987
+ case 'judgement':
988
+ judgements.push(s);
989
+ break;
990
+ case 'form':
991
+ // Form and stance enter is authored by suggestion bands with formId gates. Role-bucket does
992
+ // NOT auto-list every known form — Travel and Aquatic would thrash the combat forms.
993
+ break;
994
+ case 'shoot':
995
+ // Wand Shoot is a caster's out-of-mana filler. Hunters also get Auto Shot as combat filler,
996
+ // since packs often omit it. Rage and energy Throw is NOT auto-cast — it flips to ranged.
997
+ if (classUsesRangedAuto(char.cls)) {
998
+ combatByRole.shoot.push(spellRule(s, []));
999
+ } else if (classPowerType(char.cls) === 'mana') {
1000
+ combatByRole.shoot.push(spellRule(s, [cond('self', 'powerPct', '<', DEFAULT_THRESHOLDS.shootBelowPowerPct)]));
1001
+ }
1002
+ break;
1003
+ default:
1004
+ break; // utility etc. — not auto-used by default
1005
+ }
1006
+ }
1007
+
1008
+ // Interrupts first (casting-gated, so they cost nothing when the enemy isn't casting),
1009
+ // then dodge reacts (Overpower), then SnD/finishers (already CP-gated), then control/dots.
1010
+ for (const r of interrupts) combat.push(r);
1011
+ for (const r of dispels) combat.push(r);
1012
+ for (const r of reacts) combat.push(r);
1013
+ // Cooldowns above the damage buckets: a burst is worth more than the filler it displaces, and
1014
+ // once spent its own cooldown makes it unusable, so the pick falls straight through.
1015
+ for (const r of cooldowns) combat.push(r);
1016
+ for (const r of finishers) combat.push(r);
1017
+ // Within each offensive bucket, multi-target rules go FIRST: they fire only on packs, and behind
1018
+ // an unconditional single-target rule the pick would starve them on every GCD.
1019
+ const isMultiRule = (r: RotationRule): boolean =>
1020
+ r.action.kind === 'spell' && multiTargetSpellIds.has(r.action.spellId);
1021
+ for (const key of ['control', 'dot', 'instant_nuke', 'nuke', 'strike', 'shoot'] as const) {
1022
+ const bucket = combatByRole[key];
1023
+ for (const r of bucket) if (isMultiRule(r)) combat.push(r);
1024
+ for (const r of bucket) if (!isMultiRule(r)) combat.push(r);
1025
+ }
1026
+ for (const r of builders) combat.push(r);
1027
+
1028
+ // Paladin seal-and-Judgement loop: only ONE seal can be up, so keep a single spec-aware seal
1029
+ // rather than one rule per seal. Seal upkeep is top priority; Judgement fires while it is up.
1030
+ if (seals.length) {
1031
+ const pri = sealKindPriority(sealSpec);
1032
+ const best = [...seals].sort(
1033
+ (a, b) =>
1034
+ (pri[sealKindOf(b)] ?? -1) - (pri[sealKindOf(a)] ?? -1)
1035
+ || b.rank - a.rank,
1036
+ )[0]!;
1037
+ combat.unshift(spellRule(best, [cond('self', 'buffPresent', '==', 0, best.id)]));
1038
+ // Judgement(s) right after seal upkeep: only when the (best) seal is up.
1039
+ let at = 1;
1040
+ for (const j of judgements) combat.splice(at++, 0, spellRule(j, [cond('self', 'buffPresent', '==', 1, best.id)]));
1041
+ }
1042
+
1043
+ // In-combat RESTORE items become recovery rules: a potion or healthstone as an emergency backup
1044
+ // below the heals, a mana item when mana runs low. Appended AFTER heals so a real heal wins.
1045
+ // Every other kit kind is seeded by syncCompanionBagItemRules with its own gate: folded here, a
1046
+ // Skull of Impending Doom (4984, friendly_cast) read "use when mana is low" and drained the mana.
1047
+ if (inventory) {
1048
+ // A conjured stone is FREE and sits on its own cooldown category, so spending it first costs
1049
+ // nothing and keeps the bought potion for the next spike. Stable — everything else keeps order.
1050
+ const kit = combatItems(char, inventory, ds)
1051
+ .filter((it) => it.effect.kind === 'restore_health' || it.effect.kind === 'restore_power');
1052
+ const stones = kit.filter((it) => isHealthstoneItem(ds.item(it.entry)));
1053
+ const rest = kit.filter((it) => !isHealthstoneItem(ds.item(it.entry)));
1054
+ for (const it of [...stones, ...rest]) {
1055
+ const health = it.effect.kind === 'restore_health';
1056
+ recovery.push(rule({ kind: 'item', itemEntry: it.entry }, [
1057
+ cond('self', health ? 'healthPct' : 'powerPct', '<', health ? DEFAULT_THRESHOLDS.potionLowPct : DEFAULT_THRESHOLDS.manaRestoreLowPct),
1058
+ ]));
1059
+ }
1060
+ }
1061
+
1062
+ // Collapse exclusive families (aspects, auras, stings, curses, weapon imbues, …) so
1063
+ // role-bucket never ships multi keep-ups that thrash every GCD (KI-246+ cluster).
1064
+ const resolve = (id: number) => spells.find((s) => s.id === id) ?? ds.spell(id);
1065
+ return {
1066
+ version: 1,
1067
+ cls: char.cls,
1068
+ phases: {
1069
+ prebuff: [],
1070
+ buffs: collapseExclusiveFamilyRules(buffs, resolve),
1071
+ pull: collapseExclusiveFamilyRules(pull, resolve),
1072
+ combat: collapseExclusiveFamilyRules(combat, resolve),
1073
+ recovery: collapseExclusiveFamilyRules(recovery, resolve),
1074
+ },
1075
+ };
1076
+ }
1077
+
1078
+ /** Vanilla execute-style finishers (tooltip ≤20% HP or long-cast execute filler). */
1079
+ const EXECUTE_SPELL_NAMES = new Set([
1080
+ 'hammer of wrath',
1081
+ 'execute',
1082
+ 'shadowburn',
1083
+ 'soul fire',
1084
+ 'kill shot',
1085
+ ]);
1086
+
1087
+ /** Stealth openers that must gate formId==STEALTH even when Stances is unset in bake. */
1088
+ const STEALTH_OPENER_NAMES = new Set([
1089
+ 'cheap shot',
1090
+ 'ambush',
1091
+ 'garrote',
1092
+ 'sap',
1093
+ 'premeditation',
1094
+ 'pick pocket',
1095
+ 'garrote - silence',
1096
+ ]);
1097
+
1098
+ // Dodge-reactive melee. Counterattack is NOT here: its CasterAuraState is 7 HUNTER_PARRY, so a hunter's
1099
+ // parry arms it and a dodge does not (isHunterParryReactSpell). Cite: UnitAuraProcHandler.cpp:310-316.
1100
+ const DODGE_REACT_NAMES = new Set([
1101
+ 'overpower',
1102
+ 'mongoose bite',
1103
+ ]);
1104
+
1105
+ // Snares / slows that often bake as direct_damage-only (no speed effect), so hostile-aura inference cannot
1106
+ // attach a keep-up. Name list is the shared if-statement source.
1107
+ const SNARE_KEEPUP_NAMES = new Set([
1108
+ 'hamstring',
1109
+ 'wing clip',
1110
+ 'frost shock',
1111
+ 'concussive shot',
1112
+ 'crippling poison',
1113
+ ]);
1114
+
1115
+ // Self haste / enrage CDs that may have empty effects in bake still need buffPresent keep-up so they never
1116
+ // sit as unconditional ALWAYS combat noise.
1117
+ const SELF_CD_BUFF_NAMES = new Set([
1118
+ 'rapid fire',
1119
+ 'bestial wrath',
1120
+ 'adrenaline rush',
1121
+ 'inner focus',
1122
+ 'bloodlust',
1123
+ 'heroism',
1124
+ 'iced veins',
1125
+ // Ice Block is a long-CD self immunity (isSelfDefensiveSpell) — health gate, not buff thrash.
1126
+ 'evasion',
1127
+ 'sprint',
1128
+ 'blade flurry',
1129
+ ]);
1130
+
1131
+ function hasMetric(conditions: readonly Condition[], metric: CondMetric, subject?: CondSubject): boolean {
1132
+ return conditions.some(
1133
+ (c) => c.metric === metric && (subject == null || c.subject === subject),
1134
+ );
1135
+ }
1136
+
1137
+ function spellNameKey(spell: { name?: string }): string {
1138
+ return normalizeSpellName(spell.name ?? '');
1139
+ }
1140
+
1141
+ // One row per single-target party buff becomes two: the caster's keep-up, then the same spell aimed
1142
+ // at whoever still lacks it. Out of combat only — buffing four people is a between-pulls act.
1143
+ export function ensurePartyBuffRows(rot: Rotation, ds: DataStore): Rotation {
1144
+ const buffs = rot.phases.buffs;
1145
+ const already = new Set<number>();
1146
+ for (const r of buffs) {
1147
+ const t = r.action.kind === 'spell' ? r.action.target : undefined;
1148
+ if (t && typeof t === 'object' && 'allyMissingAura' in t) already.add(t.allyMissingAura);
1149
+ }
1150
+ const out: RotationRule[] = [];
1151
+ for (const r of buffs) {
1152
+ out.push(r);
1153
+ if (r.action.kind !== 'spell' || r.enabled === false || r.action.target != null) continue;
1154
+ const id = r.action.spellId;
1155
+ if (already.has(id)) continue;
1156
+ const sp = ds.spell(id);
1157
+ if (!sp || !isSingleTargetPartyBuff(sp)) continue;
1158
+ already.add(id);
1159
+ out.push({
1160
+ // Deterministic, like the `g-item-` bag rules: a re-sync of a stored list must not churn ids.
1161
+ id: `pb-ally-${id}`,
1162
+ action: { kind: 'spell', spellId: id, target: { allyMissingAura: id } },
1163
+ conditions: [cond('self', 'inCombat', '==', 0)],
1164
+ enabled: true,
1165
+ });
1166
+ }
1167
+ return out.length === buffs.length ? rot : { ...rot, phases: { ...rot.phases, buffs: out } };
1168
+ }
1169
+
1170
+ // Shared semantic gates for auto-battler rules. Authored "usable" rows and role-bucket tails leave
1171
+ // no conditions, which spams GCDs on execute tools, keep-ups and interrupts. Only appends.
1172
+ export function applySemanticSpellGates(
1173
+ spell: SpellDef,
1174
+ conditions: Condition[],
1175
+ opts?: {
1176
+ immolateSpellId?: number;
1177
+ /** Kit/rotation knows Cleave — HS must not dump rage on packs (classic swap). */
1178
+ hasCleave?: boolean;
1179
+ /** Kit/rotation knows Swipe — Maul must not dump rage on packs. */
1180
+ hasSwipe?: boolean;
1181
+ /** Kit has a cooldown-bearing rage spender (MS/BT/Shield Slam/WW) — HS pools rage. */
1182
+ hasRageSignature?: boolean;
1183
+ /** Rotation carries a single-target taunt, i.e. this is a tank build. */
1184
+ hasTaunt?: boolean;
1185
+ },
1186
+ ): Condition[] {
1187
+ let c = bindConjureStockGate(spell, conditions);
1188
+ const name = spellNameKey(spell);
1189
+
1190
+ // A Stances bit does NOT mean "only in that form": Balance nukes carry the Moonkin bit and the
1191
+ // allow-while-not-shapeshifted attr. Ask the engine's own classifier rather than the raw mask.
1192
+ const stanceForm = requiredFormIdFromStances(spell.stances ?? 0);
1193
+ const refusedUnshifted = shapeshiftCastError(spell, 0) !== 'ok';
1194
+ if (stanceForm != null && refusedUnshifted && !hasMetric(c, 'formId')) {
1195
+ c = ensureCondition(c, cond('self', 'formId', '==', stanceForm));
1196
+ } else if (STEALTH_OPENER_NAMES.has(name) && !hasMetric(c, 'formId')) {
1197
+ c = ensureCondition(c, cond('self', 'formId', '==', SHAPESHIFT_FORM.STEALTH));
1198
+ }
1199
+
1200
+ // Heroic Strike and Cleave compete for the same next swing and rage, so with both in the kit HS
1201
+ // is 1v1 only — otherwise the cheaper HS spends every 12–19 rage and Cleave never affords.
1202
+ if (name === 'heroic strike' && opts?.hasCleave && !hasMetric(c, 'enemyCount')) {
1203
+ c = ensureCondition(c, cond('self', 'enemyCount', '<', 2));
1204
+ }
1205
+ if (name === 'maul' && opts?.hasSwipe && !hasMetric(c, 'enemyCount')) {
1206
+ c = ensureCondition(c, cond('self', 'enemyCount', '<', 2));
1207
+ }
1208
+ if (name === 'slam' && opts?.hasCleave && !hasMetric(c, 'enemyCount')) {
1209
+ c = ensureCondition(c, cond('self', 'enemyCount', '<', 2));
1210
+ }
1211
+ // Sunder's ST gate lives in classes/warrior.ts, the only pass that knows the spec. Here it fired
1212
+ // before tankize inserted Taunt, so Protection was gated too and stopped generating threat.
1213
+
1214
+ // Pure interrupts only. Earth Shock is also the shaman instant filler, and injecting
1215
+ // castingPresent onto an authored always-copy made both rows interrupt-gated.
1216
+ if (isPureInterruptSpell(spell) && !hasMetric(c, 'castingPresent')) {
1217
+ c = ensureCondition(c, cond('target', 'castingPresent', '==', 1));
1218
+ }
1219
+
1220
+ // Reacts (Revenge, Riposte) lived only in the suggestion path, so a spec that picked the spell up
1221
+ // from the role-bucket tail got it as bare unconditional filler.
1222
+ c = applyDefenseReactGate(spell, c);
1223
+
1224
+ // The shipped packs carry a low-health gate on Recklessness and Berserker Rage, authored when
1225
+ // fear immunity read as a panic button. Repair the row here so the fix lands on packs on disk.
1226
+ const stripLowHealthGate = (conds: readonly Condition[]): Condition[] => conds.filter(
1227
+ (x) => !(x.subject === 'self' && x.metric === 'healthPct' && (x.op === '<' || x.op === '<=')),
1228
+ );
1229
+ // A burst and a CC break both OWN their gate: neither may fall through to the self-defensive
1230
+ // block below, which would re-add `healthPct < 35` precisely because this pass just removed it.
1231
+ let gateOwnedHere = false;
1232
+ if (isOffensiveBurstCooldown(spell)) {
1233
+ c = stripLowHealthGate(c);
1234
+ for (const g of burstCooldownConditions(spell)) {
1235
+ if (!hasMetric(c, g.metric, g.subject)) c = ensureCondition(c, g);
1236
+ }
1237
+ gateOwnedHere = true;
1238
+ } else {
1239
+ const ccBreaks = ccBreakGateConditions(spell);
1240
+ const first = ccBreaks[0]?.[0];
1241
+ if (first) {
1242
+ // Berserker Rage: health has nothing to do with it — the cast strips the fear.
1243
+ c = stripLowHealthGate(c);
1244
+ // A row that already names ANY mechanic this spell breaks owns its gate. Checking only the
1245
+ // highest-priority one ANDed a second condition onto an authored row, so it never fired.
1246
+ const named = ccBreaks.some((g) => g[0] != null && hasMetric(c, g[0].metric, 'self'));
1247
+ if (!named) c = ensureCondition(c, first);
1248
+ gateOwnedHere = true;
1249
+ }
1250
+ }
1251
+
1252
+ // Self defensives: a self-stun plus school immunity bakes as `cc` and used to pick up an enemy
1253
+ // debuff keep-up. Strip that thrash and require a self health panic gate.
1254
+ if (!gateOwnedHere && isSelfDefensiveSpell(spell)) {
1255
+ c = c.filter(
1256
+ (x) => !(x.subject === 'target' && (x.metric === 'debuffPresent' || x.metric === 'debuffStacks')),
1257
+ );
1258
+ if (!hasMetric(c, 'healthPct', 'self')) {
1259
+ c = ensureCondition(
1260
+ c,
1261
+ cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct),
1262
+ );
1263
+ }
1264
+ }
1265
+ // Defense-react strikes (Riposte/Revenge) may have been authored as missing-debuff keep-ups
1266
+ // because of a secondary disarm/stun rider (KI-251). Keep only the react window.
1267
+ if (isDefenseReactSpell(spell)) {
1268
+ c = c.filter(
1269
+ (x) => !(x.subject === 'target' && (x.metric === 'debuffPresent' || x.metric === 'debuffStacks')),
1270
+ );
1271
+ }
1272
+
1273
+ // Non-stacking hostile debuff keep-up. Armor shreds bake as a hostile buff with a negative mod,
1274
+ // so the dot bucket's gate never reached them. Sunder is excluded — its stacks are the point.
1275
+ const stacks = spell.stackAmount ?? 0;
1276
+ const hostileDebuff = (spell.effects ?? []).some(
1277
+ (e) => e.kind === 'dot'
1278
+ || (e.kind === 'buff' && (e.hostile === true || ((e as { pointsPerComboPoint?: number }).pointsPerComboPoint ?? 0) < 0)),
1279
+ );
1280
+ if (
1281
+ hostileDebuff
1282
+ && stacks <= 1
1283
+ && !isSelfDefensiveSpell(spell)
1284
+ && !isDefenseReactSpell(spell)
1285
+ && !isChanneledAoeDamageSpell(spell)
1286
+ && !multiTargetNeedsPackDensity(spell)
1287
+ && !hasMetric(c, 'debuffPresent')
1288
+ && !hasMetric(c, 'debuffStacks')
1289
+ ) {
1290
+ c = ensureCondition(c, cond('target', 'debuffPresent', '==', 0, spell.id));
1291
+ }
1292
+
1293
+ // ---- Creature-type restricted (Exorcism / Holy Wrath / Turn …) ----------
1294
+ const bakedMask = spell.targetCreatureTypeMask ?? 0;
1295
+ const undeadDemonByName =
1296
+ name === 'exorcism' ||
1297
+ name === 'holy wrath' ||
1298
+ name === 'turn undead' ||
1299
+ name === 'turn evil';
1300
+ const typeMask = bakedMask || (undeadDemonByName ? UNDEAD_DEMON_CREATURE_MASK : 0);
1301
+ if (typeMask && !hasMetric(c, 'creatureTypeMask')) {
1302
+ c = ensureCondition(c, cond('target', 'creatureTypeMask', '==', 1, typeMask));
1303
+ }
1304
+
1305
+ // ---- Execute window -----------------------------------------------------
1306
+ if (EXECUTE_SPELL_NAMES.has(name) && !hasMetric(c, 'healthPct', 'target')) {
1307
+ c = ensureCondition(
1308
+ c,
1309
+ cond('target', 'healthPct', '<', DEFAULT_THRESHOLDS.executeHealthPct),
1310
+ );
1311
+ }
1312
+
1313
+ // ---- Reagents (Soul Shard on Shadowburn / Soul Fire / …) ----------------
1314
+ for (const r of spell.reagents ?? []) {
1315
+ if (!r?.item) continue;
1316
+ const need = Math.max(1, r.count ?? 1);
1317
+ if (!c.some((x) => x.metric === 'itemCount' && x.ref === r.item)) {
1318
+ c = ensureCondition(c, cond('self', 'itemCount', '>=', need, r.item));
1319
+ }
1320
+ }
1321
+ // Shadowburn/Soul Fire must not dump shards mid-fight even if reagents[] is empty in a stub.
1322
+ if (
1323
+ (name === 'shadowburn' || name === 'soul fire') &&
1324
+ !c.some((x) => x.metric === 'itemCount' && x.ref === SOUL_SHARD_ITEM)
1325
+ ) {
1326
+ c = ensureCondition(c, cond('self', 'itemCount', '>=', 1, SOUL_SHARD_ITEM));
1327
+ }
1328
+
1329
+ // Gap snare, not a melee keep-up: rangeMax 0 makes Howl always "in range", and an own-id
1330
+ // debuffPresent gate retried every GCD while Hamstring already snared the target.
1331
+ if (isPiercingHowlSpell(spell)) {
1332
+ c = c.filter(
1333
+ (x) =>
1334
+ x.metric !== 'inMelee'
1335
+ && x.metric !== 'enemiesWithinYd'
1336
+ && x.metric !== 'enemyCount'
1337
+ && x.metric !== 'debuffPresent'
1338
+ && x.metric !== 'snared'
1339
+ && x.metric !== 'targetDistanceYd'
1340
+ && x.metric !== 'castingPresent',
1341
+ );
1342
+ for (const g of piercingHowlGapConditions(spell)) c = ensureCondition(c, g);
1343
+ }
1344
+
1345
+ // Skip multi when a creature-type gate already restricts the spell: Holy Wrath must still fire on
1346
+ // a single undead. Radius-aware — a PBAoE counts enemies within its own radius, not fight-wide.
1347
+ if (
1348
+ multiTargetNeedsPackDensity(spell) &&
1349
+ !hasMetric(c, 'enemyCount') &&
1350
+ !hasMetric(c, 'enemiesWithinYd') &&
1351
+ !hasMetric(c, 'creatureTypeMask')
1352
+ ) {
1353
+ c = applyMultiTargetCombatGates(spell, c);
1354
+ }
1355
+
1356
+ // Rage pooling: HS and Cleave spend rage off the GCD, so ungated they pin rage in the 20–29 band
1357
+ // and a 30-rage signature never casts. Pool at 50%+ so Bloodthirst can still fire.
1358
+ if (
1359
+ (name === 'heroic strike' || name === 'cleave')
1360
+ && opts?.hasRageSignature
1361
+ && !hasMetric(c, 'powerPct')
1362
+ ) {
1363
+ c = ensureCondition(c, cond('self', 'powerPct', '>=', name === 'cleave' ? 50 : 40));
1364
+ }
1365
+
1366
+ // ---- Conflagrate requires Immolate on target (vanilla 1.12) --------------
1367
+ if (name === 'conflagrate' && !hasMetric(c, 'debuffPresent', 'target')) {
1368
+ const immolateId = opts?.immolateSpellId;
1369
+ if (immolateId) {
1370
+ c = ensureCondition(c, cond('target', 'debuffPresent', '==', 1, immolateId));
1371
+ } else {
1372
+ // No Immolate rank in the kit — still express the if-statement so the rule is
1373
+ // never unconditional ALWAYS (repro gate). Runtime will not match a foreign id.
1374
+ c = ensureCondition(c, cond('target', 'debuffPresent', '==', 1, spell.id));
1375
+ }
1376
+ }
1377
+
1378
+ // ---- Pet-sourced drains (Dark Pact) -------------------------------------
1379
+ if (name === 'dark pact' && !hasMetric(c, 'alive', 'pet')) {
1380
+ c = ensureCondition(c, cond('pet', 'alive', '==', 1));
1381
+ }
1382
+
1383
+ // ---- Dodge-reactive -----------------------------------------------------
1384
+ if (DODGE_REACT_NAMES.has(name) && !hasMetric(c, 'dodgedRecently')) {
1385
+ // Overpower: after the *target* dodges your swing. Mongoose Bite: after *you* dodge
1386
+ // (a hunter's dodge sets AURA_STATE_DEFENSE). combatSnapshot fills both subjects.
1387
+ const subject: CondSubject = name === 'overpower' ? 'target' : 'self';
1388
+ c = ensureCondition(c, cond(subject, 'dodgedRecently', '==', 1));
1389
+ }
1390
+ if (isHunterParryReactSpell(spell) && !hasMetric(c, 'hunterParryRecently')) {
1391
+ c = ensureCondition(c, cond('self', 'hunterParryRecently', '==', 1));
1392
+ }
1393
+
1394
+ // Keep-ups for hostile auras, CC, DoTs and snares. Self immunities and parry reacts carry
1395
+ // secondary `cc` effects that must NOT become target debuff thrash.
1396
+ const hasTargetDebuffGate =
1397
+ hasMetric(c, 'debuffPresent', 'target') || hasMetric(c, 'debuffStacks', 'target');
1398
+ const skipEnemyDebuffKeepUp =
1399
+ isSelfDefensiveSpell(spell) || isDefenseReactSpell(spell) || isThreatOrTauntUtility(spell);
1400
+
1401
+ // Channeled multi-target damage is multi-gated, never a single-target debuff keep-up, even though
1402
+ // it carries `dot` effects. Skipping the keep-up branch is what keeps that strip in place.
1403
+ if (!hasTargetDebuffGate && !isChanneledAoeDamageSpell(spell) && !skipEnemyDebuffKeepUp) {
1404
+ const hostile = hostileAuraEffect(spell);
1405
+ if (hostile) {
1406
+ const cap = (spell.stackAmount ?? 0) > 1 ? spell.stackAmount! : 0;
1407
+ c = ensureCondition(
1408
+ c,
1409
+ cap
1410
+ ? cond('target', 'debuffStacks', '<', cap, spell.id)
1411
+ : cond('target', 'debuffPresent', '==', 0, spell.id),
1412
+ );
1413
+ } else if (spell.effects.some((e) => e.kind === 'cc')) {
1414
+ c = ensureCondition(c, cond('target', 'debuffPresent', '==', 0, spell.id));
1415
+ } else if (spell.effects.some((e) => e.kind === 'dot')) {
1416
+ c = ensureCondition(c, cond('target', 'debuffPresent', '==', 0, spell.id));
1417
+ } else if (SNARE_KEEPUP_NAMES.has(name) && auraSidesOfSpell(spell).has('hostile')) {
1418
+ // By NAME, so the rogue's Crippling Poison VIAL (a conjure) matched too and got a gate on an
1419
+ // id that never lands on anyone — the row could not fire at all.
1420
+ c = ensureCondition(c, cond('target', 'debuffPresent', '==', 0, spell.id));
1421
+ }
1422
+ }
1423
+
1424
+ // ---- Empty / self CD buff keep-up ---------------------------------------
1425
+ // Blade Flurry is also multi-gated above; still wants buff keep-up so it does not thrash.
1426
+ if (SELF_CD_BUFF_NAMES.has(name) && !hasMetric(c, 'buffPresent', 'self')) {
1427
+ c = ensureCondition(c, cond('self', 'buffPresent', '==', 0, spell.id));
1428
+ }
1429
+
1430
+ // Mana Burn: empty-effects stub + Shadowform-only. Stances usually applied formId already;
1431
+ // fallback so Disc/Holy never list it as unconditional ALWAYS.
1432
+ if (name === 'mana burn' && c.length === 0) {
1433
+ c = ensureCondition(c, cond('self', 'formId', '==', SHAPESHIFT_FORM.SHADOWFORM));
1434
+ }
1435
+
1436
+ // Party rez: rewrite buffPresent / ALWAYS thrash into deadAllyCount>=1 (custom saves).
1437
+ if (isResurrectSpell(spell)) {
1438
+ const thrash =
1439
+ c.length === 0
1440
+ || isPresenceKeepUpOnly(c)
1441
+ || c.every((x) => x.metric === 'buffPresent' || x.metric === 'debuffPresent');
1442
+ if (thrash) c = resurrectGateConditions();
1443
+ else if (!hasMetric(c, 'deadAllyCount')) {
1444
+ c = ensureCondition(c, cond('self', 'deadAllyCount', '>=', 1));
1445
+ }
1446
+ }
1447
+
1448
+ return c;
1449
+ }
1450
+
1451
+ // Final safety pass: every spell rule gets the shared semantic gates. Walks all phases so packs and
1452
+ // role-bucket tails never ship an unconditional ALWAYS for a gated tool.
1453
+ export function ensureSemanticSpellGates(rot: Rotation, ds: DataStore): Rotation {
1454
+ // Prefer an Immolate already listed in this rotation so Conflagrate matches the live rank.
1455
+ let immolateSpellId: number | undefined;
1456
+ let hasCleave = false;
1457
+ let hasSwipe = false;
1458
+ let hasRageSignature = false;
1459
+ let hasTaunt = false;
1460
+ for (const phase of ROTATION_PHASES) {
1461
+ for (const r of rot.phases[phase] ?? []) {
1462
+ if (r.action.kind !== 'spell') continue;
1463
+ const sp = ds.spell(r.action.spellId);
1464
+ if (!sp) continue;
1465
+ const key = spellNameKey(sp);
1466
+ if (key === 'immolate' && immolateSpellId == null) immolateSpellId = sp.id;
1467
+ if (key === 'cleave') hasCleave = true;
1468
+ if (key === 'swipe') hasSwipe = true;
1469
+ if (key === 'taunt' || key === 'growl') hasTaunt = true;
1470
+ // A cooldown-bearing rage spender (Mortal Strike, Shield Slam, Whirlwind) means HS and Cleave
1471
+ // must pool rage rather than spam. Those often bake empty effects, so count them too.
1472
+ if (
1473
+ sp.powerType === 'rage'
1474
+ && (sp.cooldownMs ?? 0) > 0
1475
+ && key !== 'heroic strike'
1476
+ && key !== 'cleave'
1477
+ && (
1478
+ sp.effects.some((e) => e.kind === 'direct_damage' || e.kind === 'weapon_percent_damage')
1479
+ || (sp.strikeBonus ?? 0) > 0
1480
+ || sp.role === 'strike'
1481
+ || key === 'bloodthirst'
1482
+ || key === 'mortal strike'
1483
+ || key === 'whirlwind'
1484
+ || key === 'shield slam'
1485
+ )
1486
+ ) {
1487
+ hasRageSignature = true;
1488
+ }
1489
+ }
1490
+ }
1491
+ const gateOpts = { immolateSpellId, hasCleave, hasSwipe, hasRageSignature, hasTaunt };
1492
+
1493
+ const mapRules = (rules: RotationRule[]): RotationRule[] =>
1494
+ rules.flatMap((r) => {
1495
+ if (r.action.kind !== 'spell') return [r];
1496
+ const spell = ds.spell(r.action.spellId);
1497
+ if (!spell) return [r];
1498
+ // Drop party rez from auto-generated lists entirely: the role bucket already skips it, and
1499
+ // this catches thrash that would spend costPct with no corpse. Manual adds keep their gate.
1500
+ if (isResurrectSpell(spell) && (r.conditions.length === 0 || isPresenceKeepUpOnly(r.conditions))) {
1501
+ return [];
1502
+ }
1503
+ // Form enter stays on applyFormEnterGate (formId != form); do not add formId == form.
1504
+ // Never stamp emergency healthPct on form ENTER (leave-to-heal keeps formId== + health).
1505
+ if (isFormSpell(spell) || formIdFromLabel(spell.name) != null) {
1506
+ const fid = formIdForRotationSpell(spell);
1507
+ const isLeave =
1508
+ fid > 0
1509
+ && r.conditions.some((c) => c.metric === 'formId' && c.op === '==' && c.value === fid);
1510
+ let next = applySemanticSpellGates(
1511
+ { ...spell, stances: 0 }, // enter spells must not get formId==
1512
+ r.conditions,
1513
+ gateOpts,
1514
+ );
1515
+ if (!isLeave) {
1516
+ // Only the gate this pass STAMPED goes: a health term the row was authored with is the
1517
+ // author saying which form to be in at what health, and dropping it made the pvp feral
1518
+ // band's "bear when it is going badly" fire at any health at all.
1519
+ const authored = new Set(
1520
+ r.conditions.filter((c) => c.subject === 'self' && c.metric === 'healthPct')
1521
+ .map((c) => `${c.op}${c.value}`),
1522
+ );
1523
+ next = next.filter(
1524
+ (c) => !(c.subject === 'self' && c.metric === 'healthPct' && !authored.has(`${c.op}${c.value}`)),
1525
+ );
1526
+ }
1527
+ if (next === r.conditions) return [r];
1528
+ return [{ ...r, conditions: next }];
1529
+ }
1530
+ const next = applySemanticSpellGates(spell, r.conditions, gateOpts);
1531
+ if (next === r.conditions) return [r];
1532
+ return [{ ...r, conditions: next }];
1533
+ });
1534
+
1535
+ return {
1536
+ ...rot,
1537
+ phases: {
1538
+ prebuff: mapRules(rot.phases.prebuff ?? []),
1539
+ buffs: mapRules(rot.phases.buffs),
1540
+ pull: mapRules(rot.phases.pull),
1541
+ combat: mapRules(rot.phases.combat),
1542
+ recovery: mapRules(rot.phases.recovery),
1543
+ },
1544
+ ...(rot.overrides
1545
+ ? {
1546
+ overrides: Object.fromEntries(
1547
+ Object.entries(rot.overrides).map(([k, rules]) => [k, mapRules(rules)]),
1548
+ ),
1549
+ }
1550
+ : {}),
1551
+ };
1552
+ }
1553
+
1554
+ // Every category a dispel spell can actually cure gets its own rule. Each pack authored ONE gate
1555
+ // per cure, so 8 Molten Core seats knew Dispel Magic and none could strip a magic debuff.
1556
+ // A pure `buffPresent == 0` keep-up in a UNIQUE_PER_CASTER family must also require the family's
1557
+ // SITUATIONAL rows to be absent. Applying one member strips the others (applySpell.ts
1558
+ // dropExclusionGroupSiblings; spell_group 10 Aspects), so a buffs-phase Aspect of the Hawk and a
1559
+ // combat Aspect of the Monkey traded the GCD every 1.5 s forever — 27 aspect casts in 40 s.
1560
+ export function ensureExclusiveFamilyKeepUpYields(rot: Rotation, ds: DataStore): Rotation {
1561
+ const situationalByFamily = new Map<string, Set<number>>();
1562
+ const pureByFamily = new Map<string, Set<number>>();
1563
+ for (const phase of ROTATION_PHASES) {
1564
+ for (const r of rot.phases[phase] ?? []) {
1565
+ if (r.action.kind !== 'spell') continue;
1566
+ const sp = ds.spell(r.action.spellId);
1567
+ if (!sp) continue;
1568
+ const fam = exclusiveKeepupFamily(sp);
1569
+ if (fam == null) continue;
1570
+ const bucket = isPresenceKeepUpOnly(r.conditions) ? pureByFamily : situationalByFamily;
1571
+ let ids = bucket.get(fam);
1572
+ if (!ids) bucket.set(fam, (ids = new Set()));
1573
+ ids.add(r.action.spellId);
1574
+ }
1575
+ }
1576
+ if (pureByFamily.size === 0) return rot;
1577
+ const mapRules = (rules: RotationRule[]): RotationRule[] => rules.map((r) => {
1578
+ if (r.action.kind !== 'spell') return r;
1579
+ if (!isPresenceKeepUpOnly(r.conditions)) return r;
1580
+ const sp = ds.spell(r.action.spellId);
1581
+ const fam = sp ? exclusiveKeepupFamily(sp) : null;
1582
+ if (fam == null) return r;
1583
+ const siblings = situationalByFamily.get(fam);
1584
+ if (!siblings) return r;
1585
+ let conditions = r.conditions;
1586
+ for (const sid of siblings) {
1587
+ if (sid === r.action.spellId) continue;
1588
+ // The sibling's OWN side. Hardcoding the caster's bar was right for Aspects and wrong for
1589
+ // Curses, which land on the target: Curse of Agony's pull row could never close.
1590
+ const sib = ds.spell(sid);
1591
+ if (!sib) continue;
1592
+ conditions = ensureCondition(conditions, keepUpPresenceCondition(sib));
1593
+ }
1594
+ return conditions === r.conditions ? r : { ...r, conditions };
1595
+ });
1596
+ return {
1597
+ ...rot,
1598
+ phases: {
1599
+ prebuff: mapRules(rot.phases.prebuff ?? []),
1600
+ buffs: mapRules(rot.phases.buffs),
1601
+ pull: mapRules(rot.phases.pull),
1602
+ combat: mapRules(rot.phases.combat),
1603
+ recovery: mapRules(rot.phases.recovery),
1604
+ },
1605
+ };
1606
+ }
1607
+
1608
+ export function ensureDispelGates(rot: Rotation, ds: DataStore): Rotation {
1609
+ let n = 0;
1610
+ const mapRules = (rules: RotationRule[]): RotationRule[] => {
1611
+ // (spellId, subject, category) already present anywhere in this phase — including the
1612
+ // authored row itself, so a re-run adds nothing.
1613
+ const have = new Set<string>();
1614
+ for (const r of rules) {
1615
+ if (r.action.kind !== 'spell') continue;
1616
+ for (const c of r.conditions) {
1617
+ if (c.metric !== 'dispellablePresent') continue;
1618
+ have.add(`${r.action.spellId}|${c.subject}|${c.ref ?? -1}`);
1619
+ }
1620
+ }
1621
+ return rules.flatMap((r) => {
1622
+ if (r.action.kind !== 'spell') return [r];
1623
+ const spell = ds.spell(r.action.spellId);
1624
+ if (!spell || !isDispelSpell(spell)) return [r];
1625
+ if (!r.conditions.some((c) => c.metric === 'dispellablePresent')) return [r];
1626
+ const spellId = r.action.spellId;
1627
+ // Anything the author gated on besides the category (health, enemy count) rides along.
1628
+ const carried = r.conditions.filter((c) => c.metric !== 'dispellablePresent');
1629
+ // An OR group cannot share an AND factor (conditionsPass has no parentheses), so a row that
1630
+ // carries other gates takes ONE category per row; an ungated one keeps the group OR-joined.
1631
+ const groups = carried.length
1632
+ ? dispelGateConditions(spell).flatMap((g) => g.map((c) => {
1633
+ const { join: _drop, ...one } = c;
1634
+ return [one];
1635
+ }))
1636
+ : dispelGateConditions(spell);
1637
+ const key = (c: Condition): string => `${spellId}|${c.subject}|${c.ref ?? -1}`;
1638
+ let head = r;
1639
+ const out: RotationRule[] = [];
1640
+ for (const conds of groups) {
1641
+ const fresh = conds.filter((c) => !have.has(key(c)));
1642
+ if (!fresh.length) continue;
1643
+ for (const c of fresh) have.add(key(c));
1644
+ // Same subject, nothing else on the row: fold the categories in. Purify shipped "disease"
1645
+ // and "poison" as two rows the player then kept in sync by hand.
1646
+ if (!carried.length && head.conditions.some(
1647
+ (c) => c.metric === 'dispellablePresent' && c.subject === fresh[0]!.subject,
1648
+ )) {
1649
+ head = {
1650
+ ...head,
1651
+ conditions: [...head.conditions, ...fresh.map((c) => ({ ...c, join: 'or' as const }))],
1652
+ };
1653
+ continue;
1654
+ }
1655
+ // Dropping a covered category off the front would leave an `or` leading the group.
1656
+ const { join: _lead, ...lead } = fresh[0]!;
1657
+ out.push({
1658
+ ...r,
1659
+ id: `dg${n++}_${r.id}`,
1660
+ conditions: [...carried, lead, ...fresh.slice(1)],
1661
+ });
1662
+ }
1663
+ return [head, ...out];
1664
+ });
1665
+ };
1666
+ return {
1667
+ ...rot,
1668
+ phases: {
1669
+ prebuff: mapRules(rot.phases.prebuff ?? []),
1670
+ buffs: mapRules(rot.phases.buffs),
1671
+ pull: mapRules(rot.phases.pull),
1672
+ combat: mapRules(rot.phases.combat),
1673
+ recovery: mapRules(rot.phases.recovery),
1674
+ },
1675
+ ...(rot.overrides
1676
+ ? {
1677
+ overrides: Object.fromEntries(
1678
+ Object.entries(rot.overrides).map(([k, rules]) => [k, mapRules(rules)]),
1679
+ ),
1680
+ }
1681
+ : {}),
1682
+ };
1683
+ }
1684
+
1685
+ // A known cure with no authored pair gets a combat row. Same re-sync as party buffs: a stored
1686
+ // list predates learning Tranq 19801, and only a level/respec rebuilds a default.
1687
+ export function ensureKnownDispelRows(rot: Rotation, sb: SpellBook, ds: DataStore): Rotation {
1688
+ // Never a pvp band: those omissions are decisions. Filling them moved
1689
+ // pvpBands.rowsThatCancelTheirOwnBandsForm 22 -> 27 (Shadowform dropped for Abolish Disease).
1690
+ if (rot.autoSig?.mode === 'pvp') return rot;
1691
+ // Pairs any phase already gates on, so this adds nothing to a list the packs covered.
1692
+ const have = new Set<string>();
1693
+ for (const phase of ROTATION_PHASES) {
1694
+ for (const r of rot.phases[phase] ?? []) {
1695
+ for (const c of r.conditions) {
1696
+ if (c.metric === 'dispellablePresent') have.add(`${c.subject}|${c.ref ?? -1}`);
1697
+ }
1698
+ }
1699
+ }
1700
+ // Highest-level cure wins the pair: Abolish Poison (32) supersedes Cure Poison (16).
1701
+ const known: SpellDef[] = [];
1702
+ for (const id of sb.known) {
1703
+ const s = ds.spell(id);
1704
+ if (s && isDispelSpell(s)) known.push(s);
1705
+ }
1706
+ known.sort((a, b) => (b.spellLevel ?? 0) - (a.spellLevel ?? 0) || (b.rank ?? 0) - (a.rank ?? 0) || a.id - b.id);
1707
+ const add: RotationRule[] = [];
1708
+ for (const s of known) {
1709
+ for (const conds of dispelGateConditions(s)) {
1710
+ // Only the categories nothing covers yet. Dropping one off the front of an OR-joined group
1711
+ // would leave the group led by an `or`, which conditionsPass reads as a new group.
1712
+ const fresh = conds.filter((c) => !have.has(`${c.subject}|${c.ref ?? -1}`));
1713
+ if (!fresh.length) continue;
1714
+ for (const c of fresh) have.add(`${c.subject}|${c.ref ?? -1}`);
1715
+ const { join: _lead, ...head } = fresh[0]!;
1716
+ add.push({
1717
+ // Deterministic, like the `pb-ally-` party rows: a re-sync must not churn ids.
1718
+ id: `dispel-${s.id}-${head.subject}-${fresh.map((c) => c.ref ?? -1).join('-')}`,
1719
+ action: { kind: 'spell', spellId: s.id },
1720
+ conditions: [head, ...fresh.slice(1)],
1721
+ enabled: true,
1722
+ });
1723
+ }
1724
+ }
1725
+ if (!add.length) return rot;
1726
+ // hoistDispelRules owns WHERE a dispel row sits; a covered list returned above, so nothing moves.
1727
+ return {
1728
+ ...rot,
1729
+ phases: { ...rot.phases, combat: hoistDispelRules([...rot.phases.combat, ...add]) },
1730
+ };
1731
+ }
1732
+
1733
+ // Same gates as the role-bucket defaults: 4+ so a low-CP dump cannot jump the builders, and Slice
1734
+ // and Dice at the top rung (finisherLadder.ts SND_MIN_COMBO_POINTS says why).
1735
+ function applyComboPointGates(spell: SpellDef, conditions: Condition[]): Condition[] {
1736
+ if (!isComboPointConsumer(spell)) return conditions;
1737
+ const isSnd = spell.effects.some((e) => e.kind === 'buff' && e.mod === 'haste');
1738
+ return ensureCondition(
1739
+ conditions,
1740
+ cond('self', 'comboPoints', '>=', isSnd ? SND_MIN_COMBO_POINTS : 4),
1741
+ );
1742
+ }
1743
+
1744
+ // Overpower (and any post-dodge react): ensure target.dodgedRecently even when role-bucket/fallback or
1745
+ // partial when-map omitted it.
1746
+ function applyOverpowerDodgeGate(spell: SpellDef, conditions: Condition[]): Condition[] {
1747
+ if (!isOverpowerSpell(spell)) return conditions;
1748
+ // Strip bogus CP gates left from older defaults / hand-edited rules.
1749
+ const cleaned = conditions.filter((c) => c.metric !== 'comboPoints');
1750
+ return ensureCondition(cleaned, cond('target', 'dodgedRecently', '==', 1));
1751
+ }
1752
+
1753
+ // Drop keep-up debuffPresent/debuffStacks gates when the spell never applies a target aura (Scorch is
1754
+ // direct_damage only without Improved Scorch talent wiring — gate never clears).
1755
+ function stripFalseDebuffKeepUp(spell: SpellDef, conditions: Condition[]): Condition[] {
1756
+ if (spellAppliesTargetAura(spell)) return conditions;
1757
+ const next = conditions.filter(
1758
+ (c) => !(c.subject === 'target' && (c.metric === 'debuffPresent' || c.metric === 'debuffStacks') && c.ref === spell.id),
1759
+ );
1760
+ return next;
1761
+ }
1762
+
1763
+ // Final safety pass for a default build: every form or stance enter rule gets `formId != form`.
1764
+ // Walks all phases so no path leaves a form cast as a bare always-when-ready toggle thrash.
1765
+ export function ensureFormEnterGates(rot: Rotation, ds: DataStore): Rotation {
1766
+ const mapRules = (rules: RotationRule[]): RotationRule[] =>
1767
+ rules.map((r) => {
1768
+ if (r.action.kind !== 'spell') return r;
1769
+ const spell = ds.spell(r.action.spellId);
1770
+ if (!spell) return r;
1771
+ const formId = formIdForRotationSpell(spell);
1772
+ if (!formId && !isFormSpell(spell)) return r;
1773
+ if (!formId) return r;
1774
+ const next = applyFormEnterGate(spell, r.conditions);
1775
+ if (next === r.conditions) return r;
1776
+ return { ...r, conditions: next };
1777
+ });
1778
+ return {
1779
+ ...rot,
1780
+ phases: {
1781
+ prebuff: mapRules(rot.phases.prebuff ?? []),
1782
+ buffs: mapRules(rot.phases.buffs),
1783
+ pull: mapRules(rot.phases.pull),
1784
+ combat: mapRules(rot.phases.combat),
1785
+ recovery: mapRules(rot.phases.recovery),
1786
+ },
1787
+ ...(rot.overrides
1788
+ ? {
1789
+ overrides: Object.fromEntries(
1790
+ Object.entries(rot.overrides).map(([k, rules]) => [k, mapRules(rules)]),
1791
+ ),
1792
+ }
1793
+ : {}),
1794
+ };
1795
+ }
1796
+
1797
+ // Expand a multi-label combat suggestion into one rule per distinct known spell, preserving author
1798
+ // order and skipping labels that do not resolve. With none resolving, fall back to first-match.
1799
+ function expandCombatAlternativeRules(
1800
+ sug: SuggestionRule,
1801
+ known: readonly SpellDef[],
1802
+ char: Character,
1803
+ ds: DataStore,
1804
+ inventory: Inventory | undefined,
1805
+ nextId: () => string,
1806
+ ): RotationRule[] {
1807
+ const out: RotationRule[] = [];
1808
+ const seen = new Set<number>();
1809
+ for (const label of sug.labels) {
1810
+ const spell = resolveBestKnownSpell([label], known, char.level);
1811
+ if (!spell || seen.has(spell.id)) continue;
1812
+ seen.add(spell.id);
1813
+ const rule = suggestionRuleToRotationRule(
1814
+ { ...sug, label: spell.name, labels: [label] },
1815
+ known,
1816
+ char,
1817
+ ds,
1818
+ inventory,
1819
+ nextId(),
1820
+ );
1821
+ if (rule) out.push(rule);
1822
+ }
1823
+ if (out.length) return out;
1824
+ const fallback = suggestionRuleToRotationRule(sug, known, char, ds, inventory, nextId());
1825
+ return fallback ? [fallback] : [];
1826
+ }
1827
+
1828
+ // Pack-baked conditions → live Condition[]. `ACTION_SPELL` refs bind to the resolved spell id so rank
1829
+ // upgrades stay correct inside a level band.
1830
+ function bakeSuggestionConditions(
1831
+ baked: NonNullable<SuggestionRule['conditions']>,
1832
+ opts: { actionSpellId?: number; itemEntry?: number },
1833
+ ): Condition[] {
1834
+ const out: Condition[] = [];
1835
+ for (const c of baked) {
1836
+ const metric = c.metric as Condition['metric'];
1837
+ const op = c.op as Condition['op'];
1838
+ if (!metric || !op) continue;
1839
+ const subject = (c.subject ?? 'self') as Condition['subject'];
1840
+ let ref: number | undefined;
1841
+ if (c.ref === 'ACTION_SPELL') {
1842
+ if (opts.actionSpellId == null) continue;
1843
+ ref = opts.actionSpellId;
1844
+ } else if (c.ref === 'ITEM') {
1845
+ if (opts.itemEntry == null) continue;
1846
+ ref = opts.itemEntry;
1847
+ } else if (typeof c.ref === 'number') {
1848
+ ref = c.ref;
1849
+ }
1850
+ out.push({
1851
+ subject,
1852
+ metric,
1853
+ op,
1854
+ value: c.value | 0,
1855
+ ...(ref != null ? { ref } : {}),
1856
+ });
1857
+ }
1858
+ return out;
1859
+ }
1860
+
1861
+ function suggestionRuleToRotationRule(
1862
+ sug: SuggestionRule,
1863
+ known: readonly SpellDef[],
1864
+ char: Character,
1865
+ ds: DataStore,
1866
+ inventory: Inventory | undefined,
1867
+ id: string,
1868
+ ): RotationRule | null {
1869
+ // A `move` row resolves no spell, so it is settled before the label guards below. Cite:
1870
+ // RuleAction `{ kind: 'move' }` (types.ts:227), executed at the top of advanceGapClose.
1871
+ if (sug.kind === 'move') {
1872
+ if (!sug.move) return null;
1873
+ const authored = Array.isArray(sug.conditions) ? sug.conditions : [];
1874
+ return {
1875
+ id,
1876
+ action: { kind: 'move', move: sug.move },
1877
+ conditions: authored.length ? bakeSuggestionConditions(authored, {}) : [],
1878
+ enabled: true,
1879
+ };
1880
+ }
1881
+ if (sug.kind === 'noop' || sug.labels.length === 0) return null;
1882
+
1883
+ const hasBaked = Array.isArray(sug.conditions) && sug.conditions.length > 0;
1884
+
1885
+ // ---- Fully authored pack row: pack conditions win, no invent -----------------
1886
+ if (hasBaked) {
1887
+ const wantsHealthstoneItem = isUseHealthstoneLabel(sug.label, sug.labels);
1888
+ if (wantsHealthstoneItem || sug.kind === 'item') {
1889
+ const row = resolveBestItemRow(sug.labels, ds, char, inventory);
1890
+ if (row == null) return null;
1891
+ return {
1892
+ id,
1893
+ action: { kind: 'item', ...row },
1894
+ conditions: bakeSuggestionConditions(sug.conditions!, { itemEntry: row.itemEntry }),
1895
+ enabled: true,
1896
+ };
1897
+ }
1898
+ let spell =
1899
+ resolveBestKnownSpell(sug.labels, known, char.level)
1900
+ ?? resolveBestCatalogSpell(sug.labels, ds, char.level);
1901
+ if (!spell) return null;
1902
+ if (!spell.effects.some((e) => e.kind === 'trigger_spell')) {
1903
+ const parent = known.find(
1904
+ (k) =>
1905
+ k.id !== spell!.id
1906
+ && k.effects.some((e) => e.kind === 'trigger_spell' && e.spellId === spell!.id),
1907
+ );
1908
+ if (parent) spell = parent;
1909
+ }
1910
+ // Pack rows used fight-wide enemyCount and a debuff keep-up, so Nova fired at bolt range. Only
1911
+ // those two gates are dropped now, so an authored root-DR or proximity gate survives.
1912
+ if (isNovaKiteSpell(spell)) {
1913
+ const authored = bakeSuggestionConditions(sug.conditions!, { actionSpellId: spell.id });
1914
+ return {
1915
+ id,
1916
+ action: { kind: 'spell', spellId: spell.id },
1917
+ conditions: [
1918
+ ...novaKiteConditions(spell),
1919
+ ...authored.filter((c) => c.metric !== 'enemyCount'
1920
+ && !(c.subject === 'target'
1921
+ && (c.metric === 'debuffPresent' || c.metric === 'debuffStacks'))),
1922
+ ],
1923
+ enabled: true,
1924
+ after: { stepBackYd: NOVA_KITE_STEP_BACK_YD },
1925
+ };
1926
+ }
1927
+ // G15: every pvp band row is pre-baked, so this is the branch an authored retreat must ride.
1928
+ const bakedStep = sanitizeStepBackYd(sug.after?.stepBackYd);
1929
+ return {
1930
+ id,
1931
+ // `target: 'other'` is the CC selector — sheep the SECOND mob, not the tank's target.
1932
+ action: {
1933
+ kind: 'spell',
1934
+ spellId: spell.id,
1935
+ ...(sug.target === 'other' ? { target: 'other' as const } : {}),
1936
+ },
1937
+ conditions: bakeSuggestionConditions(sug.conditions!, { actionSpellId: spell.id }),
1938
+ enabled: true,
1939
+ ...(bakedStep ? { after: { stepBackYd: bakedStep } } : {}),
1940
+ };
1941
+ }
1942
+
1943
+ const whenResult = mapWhen(sug.when, sug.label);
1944
+ // Unmapped with zero conditions and hard engine gap (no partial gates) — skip.
1945
+ if (whenResult.status === 'unmapped' && whenResult.conditions.length === 0) {
1946
+ return null;
1947
+ }
1948
+ if (whenResult.status === 'noop') return null;
1949
+
1950
+ // "Use Healthstone" / plain healthstone labels → consumable item, never Create Healthstone (KI-397/405).
1951
+ const wantsHealthstoneItem = isUseHealthstoneLabel(sug.label, sug.labels);
1952
+ if (wantsHealthstoneItem) {
1953
+ const row = resolveBestItemRow(sug.labels, ds, char, inventory);
1954
+ if (row != null) {
1955
+ let conditions = materializeConditions(whenResult.conditions, { itemEntry: row.itemEntry });
1956
+ if (!conditions.some((c) => c.metric === 'healthPct')) {
1957
+ conditions = [...conditions, cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.potionLowPct)];
1958
+ }
1959
+ return {
1960
+ id,
1961
+ action: { kind: 'item', ...row },
1962
+ conditions,
1963
+ enabled: true,
1964
+ };
1965
+ }
1966
+ // No item in catalog/bags — drop rather than bind Create Healthstone as a "heal".
1967
+ return null;
1968
+ }
1969
+
1970
+ // Known first; catalog only for trainer-gap identities (Judgement/Auto Shot). Talent rows need knows() (KI-889).
1971
+ // Item fallthrough restricted to consumable labels (KI-227 / KI-278 / KI-334).
1972
+ let spell =
1973
+ resolveBestKnownSpell(sug.labels, known, char.level)
1974
+ ?? resolveBestCatalogSpell(sug.labels, ds, char.level);
1975
+ if (spell) {
1976
+ // Create Healthstone* with no conjure effect cannot stock bags (empty bake thrash).
1977
+ if (
1978
+ /^create healthstone/i.test(spell.name) &&
1979
+ !spell.effects.some((e) => e.kind === 'conjure')
1980
+ ) {
1981
+ return null;
1982
+ }
1983
+ // Prefer castable parent over trigger companion (Charge Stun / Bloodrage tick).
1984
+ if (!spell.effects.some((e) => e.kind === 'trigger_spell')) {
1985
+ const parent = known.find(
1986
+ (k) =>
1987
+ k.id !== spell!.id
1988
+ && k.effects.some((e) => e.kind === 'trigger_spell' && e.spellId === spell!.id),
1989
+ );
1990
+ if (parent) spell = parent;
1991
+ }
1992
+ // Drop solo-hostile / thrash keep-ups even if a band listed them (KI-289, KI-408–410, KI-418).
1993
+ {
1994
+ const n = normalizeSpellName(spell.name);
1995
+ if (
1996
+ n === 'amplify magic'
1997
+ || n === 'blink'
1998
+ || n === 'cold snap'
1999
+ || n === 'presence of mind'
2000
+ || n === 'amplify curse'
2001
+ ) {
2002
+ return null;
2003
+ }
2004
+ }
2005
+ const slot = totemSlotOfSpell(spell);
2006
+ const conjured = spell.effects.find((e) => e.kind === 'conjure');
2007
+ const conjureItem = conjured && conjured.kind === 'conjure' ? conjured.item : undefined;
2008
+ // KI-249: mapWhen emits itemCount with ref:'ITEM'; resolve via conjure output entry so
2009
+ // Conjure Food / Mana Agate keep their stocked-bag gate (role-bucket already did this).
2010
+ let conditions = materializeConditions(whenResult.conditions, {
2011
+ actionSpellId: spell.id,
2012
+ itemEntry: conjureItem,
2013
+ totemSlot: slot && slot !== 'none' ? slot : undefined,
2014
+ actionStackAmount: spell.stackAmount,
2015
+ });
2016
+ // Create Healthstone/Soulstone: always gate on missing product even if when materialize dropped ITEM.
2017
+ if (conjured && conjured.kind === 'conjure' && /create (health|soul)stone/i.test(spell.name)) {
2018
+ if (!conditions.some((c) => c.metric === 'itemCount')) {
2019
+ conditions = [
2020
+ ...conditions,
2021
+ cond('self', 'itemCount', '<', DEFAULT_THRESHOLDS.conjureConsumableBelow, conjured.item),
2022
+ ];
2023
+ }
2024
+ }
2025
+ // Bloodrage: situational low-rage gate even when pack says "always" (KI-330).
2026
+ if (isBloodrageSpell(spell)) {
2027
+ conditions = ensureCondition(
2028
+ conditions,
2029
+ cond('self', 'powerPct', '<', DEFAULT_THRESHOLDS.bloodrageBelowPowerPct),
2030
+ );
2031
+ }
2032
+ // Long armor keep-ups must never inherit healthPct recovery gates from fuzzy when.
2033
+ if (isLongArmorKeepUp(spell)) {
2034
+ conditions = conditions.filter((c) => c.metric !== 'healthPct');
2035
+ conditions = ensureCondition(conditions, cond('self', 'buffPresent', '==', 0, spell.id));
2036
+ }
2037
+ // True weapon imbues only — never gate conjure-poison on weaponImbuePresent (KI-369/388).
2038
+ if (isTrueWeaponImbueSpell(spell)) {
2039
+ if (!conditions.some((c) => c.metric === 'weaponImbuePresent')) {
2040
+ conditions = [...conditions, cond('self', 'weaponImbuePresent', '==', 0, 2)];
2041
+ }
2042
+ } else if (
2043
+ conjureItem != null &&
2044
+ !conditions.some((c) => c.metric === 'itemCount')
2045
+ ) {
2046
+ // Conjure without a resolved itemCount clause (when "always" / dropped ITEM) → stock gate.
2047
+ conditions = [
2048
+ cond('self', 'itemCount', '<', DEFAULT_THRESHOLDS.conjureBelow, conjureItem),
2049
+ ...conditions,
2050
+ ];
2051
+ }
2052
+ // Wave 2-ROG: CP finishers + multi gates (Blade Flurry / channeled AoE).
2053
+ conditions = applyComboPointGates(spell, conditions);
2054
+ // Overpower: dodge react only (never CP; bake flags requiresComboPoints wrongly).
2055
+ conditions = applyOverpowerDodgeGate(spell, conditions);
2056
+ // Scorch-class: do not keep-up on a debuff the spell never applies.
2057
+ conditions = stripFalseDebuffKeepUp(spell, conditions);
2058
+ // Form/stance: never toggle-thrash (formId != form), even if when was "always"/empty.
2059
+ conditions = applyFormEnterGate(spell, conditions);
2060
+ // Revenge: never unconditional filler (must follow dodge/parry/block).
2061
+ conditions = applyDefenseReactGate(spell, conditions);
2062
+ // Multi-target: enemiesWithinYd (PBAoE) / enemyCount (ground). Channeled AoE damage
2063
+ // also strips ST debuff keep-up (Hellfire/Hurricane/RoF/Blizzard/Volley — KI-216).
2064
+ conditions = applyMultiTargetCombatGates(spell, conditions);
2065
+ if (isNovaKiteSpell(spell)) {
2066
+ // The disc gate exists to correct exactly two authored mistakes on a self-centred root: a
2067
+ // debuff keep-up and a fight-wide enemyCount. It used to replace the WHOLE list.
2068
+ conditions = [
2069
+ ...novaKiteConditions(spell),
2070
+ ...conditions.filter((c) => c.metric !== 'enemyCount'
2071
+ && !(c.subject === 'target'
2072
+ && (c.metric === 'debuffPresent' || c.metric === 'debuffStacks'))),
2073
+ ];
2074
+ }
2075
+ // Empty-bake ALWAYS / presence keep-ups burn GCDs on inert CDs (KI-364 Holy Shock, …).
2076
+ if (shouldDropEmptyEffectRule(spell, conditions)) return null;
2077
+ // Long-CD / next-cast / immunity arms authored as pull keep-ups → drop (KI-373–377).
2078
+ if (
2079
+ isPresenceKeepUpOnly(conditions)
2080
+ && (isMaxHealthPctEmergency(spell) || shouldSkipBuffKeepUp(spell))
2081
+ ) {
2082
+ return null;
2083
+ }
2084
+ // G15: an authored retreat wins over the nova-kite injection, which stays the default.
2085
+ const authoredStep = sanitizeStepBackYd(sug.after?.stepBackYd);
2086
+ const after = authoredStep
2087
+ ? { stepBackYd: authoredStep }
2088
+ : isNovaKiteSpell(spell) ? { stepBackYd: NOVA_KITE_STEP_BACK_YD } : null;
2089
+ return {
2090
+ id,
2091
+ action: { kind: 'spell', spellId: spell.id },
2092
+ conditions,
2093
+ enabled: true,
2094
+ ...(after ? { after } : {}),
2095
+ };
2096
+ }
2097
+
2098
+ if (!suggestionAllowsItemResolve(sug)) {
2099
+ return null; // unlearned / phantom spell label — drop, do not catalog-fuzzy to items
2100
+ }
2101
+
2102
+ // Authored item rows: full catalog resolve. Spell rows: consumable fallthrough only (block scrolls/tomes).
2103
+ const row = resolveBestItemRow(sug.labels, ds, char, inventory, {
2104
+ spellFallthrough: sug.kind !== 'item',
2105
+ });
2106
+ if (row != null) {
2107
+ const conditions = materializeConditions(whenResult.conditions, { itemEntry: row.itemEntry });
2108
+ return {
2109
+ id,
2110
+ action: { kind: 'item', ...row },
2111
+ conditions,
2112
+ enabled: true,
2113
+ };
2114
+ }
2115
+
2116
+ return null; // unknown / unlearned — drop
2117
+ }
2118
+
2119
+ // Build a rotation from the authored suggestion bands for this level and talent tree, keeping list
2120
+ // order as priority. Known combat spells the bands omit are appended from the role buckets.
2121
+ export function buildSuggestionRotation(
2122
+ char: Character,
2123
+ sb: SpellBook,
2124
+ ds: DataStore,
2125
+ inventory?: Inventory,
2126
+ classKey?: SuggestionClassKey,
2127
+ specOverride?: string | null,
2128
+ opts?: RotationBandOptions,
2129
+ ): Rotation {
2130
+ const key = classKey ?? suggestionClassKeyFromClassId(char.cls);
2131
+ if (!key) return buildRoleBucketRotation(char, sb, ds, inventory);
2132
+
2133
+ const pack = getClassSuggestions(key);
2134
+ const spec = primarySpec(char, ds, specOverride);
2135
+ const band = selectBand(
2136
+ pack, char.level, spec,
2137
+ opts?.mode ? { mode: opts.mode, ...(opts.variant ? { variant: opts.variant } : {}) } : undefined,
2138
+ );
2139
+ if (!band) return buildRoleBucketRotation(char, sb, ds, inventory);
2140
+
2141
+ // Wave 2-LOCK: resolve suggestions from full known book (Life Tap / Dark Pact utility stubs).
2142
+ const known = knownSpellsForSuggestionResolve(char, sb, ds);
2143
+ const knownById = new Map(known.map((s) => [s.id, s]));
2144
+ const phases: Record<RotationPhase, RotationRule[]> = {
2145
+ prebuff: [],
2146
+ buffs: [],
2147
+ pull: [],
2148
+ combat: [],
2149
+ recovery: [],
2150
+ };
2151
+ let n = 0;
2152
+ /** Per-phase used sets: a pull opener (LB) must still be eligible as combat filler (KI-278). */
2153
+ const usedSpellsByPhase: Record<RotationPhase, Set<number>> = {
2154
+ prebuff: new Set(),
2155
+ buffs: new Set(),
2156
+ pull: new Set(),
2157
+ combat: new Set(),
2158
+ recovery: new Set(),
2159
+ };
2160
+ const usedItemsByPhase: Record<RotationPhase, Set<number>> = {
2161
+ prebuff: new Set(),
2162
+ buffs: new Set(),
2163
+ pull: new Set(),
2164
+ combat: new Set(),
2165
+ recovery: new Set(),
2166
+ };
2167
+ /** Suggestion already picked a tree armor — suppress rival mage armors in fallback. */
2168
+ let suggestionHasMageArmor = false;
2169
+
2170
+ for (const phase of SUGGESTION_PHASES) {
2171
+ const rotPhase: RotationPhase = phase;
2172
+ for (const sug of band.phases[phase as SuggestionPhase]) {
2173
+ // Combat slash-alts must expand to one rule per known alternative: first-label-wins collapsed
2174
+ // Assassination into Combat's kit. Buffs and pull keep first-match so upgrades do not thrash.
2175
+ const rules =
2176
+ phase === 'combat' && sug.kind === 'spell' && sug.labels.length > 1
2177
+ ? expandCombatAlternativeRules(sug, known, char, ds, inventory, () => `s${n++}`)
2178
+ : (() => {
2179
+ const r = suggestionRuleToRotationRule(sug, known, char, ds, inventory, `s${n++}`);
2180
+ return r ? [r] : [];
2181
+ })();
2182
+ for (const rule of rules) {
2183
+ phases[rotPhase].push(rule);
2184
+ if (rule.action.kind === 'spell') {
2185
+ usedSpellsByPhase[rotPhase].add(rule.action.spellId);
2186
+ const def = knownById.get(rule.action.spellId) ?? ds.spell(rule.action.spellId);
2187
+ if (def && isMageArmorSpell(def)) suggestionHasMageArmor = true;
2188
+ } else if (rule.action.kind === 'item') {
2189
+ usedItemsByPhase[rotPhase].add(rule.action.itemEntry);
2190
+ }
2191
+ }
2192
+ }
2193
+ }
2194
+
2195
+ let f = 0;
2196
+ const nextFallbackId = () => `f${f++}`;
2197
+ const classCtx = (): FinalizeContext => ({
2198
+ char,
2199
+ ds,
2200
+ sb,
2201
+ inventory,
2202
+ spec,
2203
+ classKey: key,
2204
+ known,
2205
+ knownById,
2206
+ phases,
2207
+ usedSpellsByPhase,
2208
+ usedItemsByPhase,
2209
+ nextFallbackId,
2210
+ ...(opts?.mode === 'pvp' ? { mode: 'pvp' as const } : {}),
2211
+ });
2212
+
2213
+ // Per-class post-pass after authored rows (e.g. paladin seal collapse).
2214
+ applyClassAfterSuggestion(classCtx());
2215
+
2216
+ // Role-bucket fallback is OFF by default — packs are the sole source of default rows. The
2217
+ // materialize script flips PH_ROTATION_INVENT once to snapshot invent into packs, then reverts.
2218
+ const invent = typeof process !== 'undefined' && process.env?.PH_ROTATION_INVENT === '1';
2219
+ if (invent) {
2220
+ const authoredPull = band.phases.pull.length > 0;
2221
+ const authoredMeleeFiller = band.phases.combat.some(
2222
+ (r) => /auto-?attack/i.test(r.label ?? '') || r.labels.some((l) => /auto-?attack/i.test(l)),
2223
+ );
2224
+ const isCastTimeNukeRule = (r: RotationRule): boolean => {
2225
+ if (r.action.kind !== 'spell' || r.conditions.length > 0) return false;
2226
+ const def = knownById.get(r.action.spellId) ?? ds.spell(r.action.spellId);
2227
+ return !!def && (def.castTimeMs ?? 0) > 0 && def.effects.some((e) => e.kind === 'direct_damage' || e.kind === 'dot');
2228
+ };
2229
+ const fallback = buildRoleBucketRotation(char, sb, ds, inventory);
2230
+ for (const phase of ROTATION_PHASES) {
2231
+ if (phase === 'pull' && authoredPull) continue;
2232
+ for (const r of fallback.phases[phase] ?? []) {
2233
+ if (r.action.kind === 'spell' && usedSpellsByPhase[phase].has(r.action.spellId)) continue;
2234
+ if (r.action.kind === 'item' && usedItemsByPhase[phase].has(r.action.itemEntry)) continue;
2235
+ if (phase === 'combat' && authoredMeleeFiller && isCastTimeNukeRule(r)) continue;
2236
+ if (suggestionHasMageArmor && r.action.kind === 'spell') {
2237
+ const def = knownById.get(r.action.spellId) ?? ds.spell(r.action.spellId);
2238
+ if (def && isMageArmorSpell(def)) continue;
2239
+ }
2240
+ if (phase === 'recovery' && r.action.kind === 'spell') {
2241
+ const def = knownById.get(r.action.spellId) ?? ds.spell(r.action.spellId);
2242
+ if (def && isLongArmorKeepUp(def)) continue;
2243
+ if (def && /^create healthstone/i.test(def.name)) continue;
2244
+ }
2245
+ if (r.action.kind === 'spell' && (phase === 'combat' || phase === 'buffs' || phase === 'pull')) {
2246
+ const def = knownById.get(r.action.spellId) ?? ds.spell(r.action.spellId);
2247
+ if (def && !spellAllowedInRoleBucketFallback(def, key, spec)) continue;
2248
+ }
2249
+ let rule = { ...r, id: nextFallbackId() };
2250
+ if (rule.action.kind === 'spell') {
2251
+ const def = knownById.get(rule.action.spellId) ?? ds.spell(rule.action.spellId);
2252
+ if (def && isOverpowerSpell(def)) {
2253
+ rule = { ...rule, conditions: applyOverpowerDodgeGate(def, rule.conditions) };
2254
+ }
2255
+ }
2256
+ phases[phase].push(rule);
2257
+ if (r.action.kind === 'spell') usedSpellsByPhase[phase].add(r.action.spellId);
2258
+ else if (r.action.kind === 'item') usedItemsByPhase[phase].add(r.action.itemEntry);
2259
+ }
2260
+ }
2261
+ }
2262
+
2263
+ // Casting-gated interrupt rules must sit ABOVE unconditional damage rules or first-match means
2264
+ // they never fire. Authored pack rules for the same spell keep their author's slot.
2265
+ const isInterruptRule = (r: RotationRule): boolean =>
2266
+ r.conditions.some((c) => c.metric === 'castingPresent' && c.op === '==' && c.value === 1);
2267
+ const hoisted = phases.combat.filter(isInterruptRule);
2268
+ if (hoisted.length) {
2269
+ phases.combat = [...hoisted, ...phases.combat.filter((r) => !isInterruptRule(r))];
2270
+ }
2271
+
2272
+ // Stance reconcile (global) + class post-pass (hunter Auto Shot, mage PoM, paladin re-seal, …).
2273
+ applyClassAfterFallback(classCtx());
2274
+
2275
+ // Rebirth: the packs never list it and invent is off, so nothing else puts a battle rez in a
2276
+ // druid rotation. Before the defensive pass, which also unshifts a low-health panic CD.
2277
+ ensureBattleRezCombatRow(classCtx());
2278
+
2279
+ // Self immunities and defense-reacts are often missing from authored packs, and invent is off in
2280
+ // product, so inject the known ones with correct gates — never as enemy-debuff thrash.
2281
+ ensureKnownDefensiveAndReactCombat(classCtx());
2282
+
2283
+ // Potions the packs never list. Gated on itemCount so an empty bag leaves them inert.
2284
+ ensureCombatConsumableRows(classCtx());
2285
+
2286
+ // Pre-buff phase: the HoTs / shields this unit knows, for the in-dungeon Pre-buff order.
2287
+ // Button-driven only — no automatic loop reads this phase.
2288
+ ensurePrebuffRows(classCtx());
2289
+
2290
+ // Exclusive keep-up collapse (all phases): one Aspect / Aura / Seal / Sting / Curse /
2291
+ // weapon imbue / poison conjure / mage armor. Suggestion order wins over fallback (KI-246+).
2292
+ const resolveExcl = (id: number) => knownById.get(id) ?? ds.spell(id);
2293
+ for (const phase of ROTATION_PHASES) {
2294
+ phases[phase] = collapseExclusiveFamilyRules(phases[phase], resolveExcl);
2295
+ }
2296
+
2297
+ // Form enter gates plus the shared semantic gates for execute, multi, keep-up, creature type,
2298
+ // interrupt and reagent, so no tail ships an unconditional ALWAYS for a gated tool.
2299
+ let rot: Rotation = { version: 1, cls: char.cls, phases };
2300
+ rot = ensureFormEnterGates(rot, ds);
2301
+ rot = ensureSemanticSpellGates(rot, ds);
2302
+ // Every category a cure can strip gets its own rule — the packs authored one gate each.
2303
+ rot = ensureDispelGates(rot, ds);
2304
+ // The PARTY half of every single-target keep-up: `applyTo: 'friendly'` routes to the caster
2305
+ // (applySpell friendBuffTarget). After the semantic pass, so no self buffPresent gate is stamped.
2306
+ rot = ensurePartyBuffRows(rot, ds);
2307
+ // After the semantic pass attached enemyCount gates, hoist multi-target rules, empty-slot totems
2308
+ // and DoT upkeep above the unconditional filler that first-match would starve them behind.
2309
+ const defOf = (id: number) => knownById.get(id) ?? ds.spell(id);
2310
+ rot = {
2311
+ ...rot,
2312
+ phases: {
2313
+ ...rot.phases,
2314
+ combat: hoistDispelRules(
2315
+ hoistDotUpkeepAboveFiller(
2316
+ hoistEmptySlotTotemsAboveFiller(
2317
+ hoistMultiTargetAboveFiller(rot.phases.combat, defOf),
2318
+ defOf,
2319
+ ),
2320
+ defOf,
2321
+ ),
2322
+ ),
2323
+ },
2324
+ };
2325
+ return rot;
2326
+ }
2327
+
2328
+ // Dispels sit next to interrupts. Authored packs list them under the wand, so first-match never
2329
+ // strips a curse. After ensureDispelGates so the extra category rows move with the authored one.
2330
+ function hoistDispelRules(combat: RotationRule[]): RotationRule[] {
2331
+ const isInterrupt = (r: RotationRule): boolean =>
2332
+ r.conditions.some((c) => c.metric === 'castingPresent' && c.op === '==' && c.value === 1);
2333
+ const isDispel = (r: RotationRule): boolean =>
2334
+ r.action.kind === 'spell'
2335
+ && r.enabled !== false
2336
+ && r.conditions.some((c) => c.metric === 'dispellablePresent');
2337
+ const dispels = combat.filter((r) => isDispel(r) && !isInterrupt(r));
2338
+ if (!dispels.length) return combat;
2339
+ return [
2340
+ ...combat.filter(isInterrupt),
2341
+ ...dispels,
2342
+ ...combat.filter((r) => !isInterrupt(r) && !isDispel(r)),
2343
+ ];
2344
+ }
2345
+
2346
+ // The same first-match starvation one bucket over: authored bands put the spammable nuke above the
2347
+ // DoTs. Only self-limiting DoT upkeep and mana rules move — CC, wand Shoot and spenders stay put.
2348
+ function hoistDotUpkeepAboveFiller(
2349
+ combat: RotationRule[],
2350
+ defOf: (id: number) => SpellDef | undefined,
2351
+ ): RotationRule[] {
2352
+ const wallIdx = combat.findIndex(
2353
+ (r) => r.action.kind === 'spell' && r.enabled !== false && r.conditions.length === 0
2354
+ && ((defOf(r.action.spellId)?.cooldownMs ?? 0) <= 0),
2355
+ );
2356
+ if (wallIdx < 0) return combat;
2357
+ const shouldHoist = (r: RotationRule): boolean => {
2358
+ if (r.action.kind !== 'spell') return false;
2359
+ const sp = defOf(r.action.spellId);
2360
+ if (!sp || sp.role === 'shoot') return false;
2361
+ const appliesDot = (sp.effects ?? []).some((e) => e.kind === 'dot');
2362
+ if (appliesDot && r.conditions.some((c) => c.metric === 'debuffPresent' && (c.value | 0) === 0)) return true;
2363
+ return r.conditions.some((c) => c.subject === 'self' && c.metric === 'powerPct' && c.op === '<');
2364
+ };
2365
+ const toHoist = combat.slice(wallIdx + 1).filter(shouldHoist);
2366
+ if (!toHoist.length) return combat;
2367
+ const kept = combat.filter((r, i) => i <= wallIdx || !shouldHoist(r));
2368
+ return [...kept.slice(0, wallIdx), ...toHoist, ...kept.slice(wallIdx)];
2369
+ }
2370
+
2371
+ // Elemental packs list unconditional Lightning Bolt above the totems, so first-match never leaves
2372
+ // the filler. Empty-slot totemPresent gates are self-limiting, so hoist them above that wall.
2373
+ function hoistEmptySlotTotemsAboveFiller(
2374
+ combat: RotationRule[],
2375
+ defOf: (id: number) => SpellDef | undefined,
2376
+ ): RotationRule[] {
2377
+ const wallIdx = combat.findIndex(
2378
+ (r) => r.action.kind === 'spell' && r.enabled !== false && r.conditions.length === 0
2379
+ && ((defOf(r.action.spellId)?.cooldownMs ?? 0) <= 0),
2380
+ );
2381
+ if (wallIdx < 0) return combat;
2382
+ const shouldHoist = (r: RotationRule): boolean => {
2383
+ if (r.action.kind !== 'spell' || r.enabled === false) return false;
2384
+ if (!r.conditions.some((c) =>
2385
+ c.subject === 'self' && c.metric === 'totemPresent' && c.op === '==' && (c.value | 0) === 0
2386
+ )) {
2387
+ return false;
2388
+ }
2389
+ const sp = defOf(r.action.spellId);
2390
+ if (!sp) return false;
2391
+ const role = sp.role ?? inferRole(sp);
2392
+ if (role === 'totem_summon') return true;
2393
+ const slot = totemSlotOfSpell(sp);
2394
+ return !!slot && slot !== 'none';
2395
+ };
2396
+ const toHoist = combat.slice(wallIdx + 1).filter(shouldHoist);
2397
+ if (!toHoist.length) return combat;
2398
+ const kept = combat.filter((r, i) => i <= wallIdx || !shouldHoist(r));
2399
+ return [...kept.slice(0, wallIdx), ...toHoist, ...kept.slice(wallIdx)];
2400
+ }
2401
+
2402
+ // First-match starvation fix: authored bands list the unconditional single-target filler above the
2403
+ // enemyCount-gated pack tools, so Cleave never fires even on six mobs. Hoist above the filler.
2404
+ function hoistMultiTargetAboveFiller(
2405
+ combat: RotationRule[],
2406
+ defOf: (id: number) => SpellDef | undefined,
2407
+ ): RotationRule[] {
2408
+ // The wall is the first unconditional SPAMMABLE rule — the true filler. Cooldown spenders are
2409
+ // high-value per cast and stay above the hoisted pack tools, or the rage budget goes to AoE.
2410
+ const wallIdx = combat.findIndex(
2411
+ (r) => r.action.kind === 'spell' && r.enabled !== false && r.conditions.length === 0
2412
+ && ((defOf(r.action.spellId)?.cooldownMs ?? 0) <= 0),
2413
+ );
2414
+ if (wallIdx < 0) return combat;
2415
+ const isGatedMulti = (r: RotationRule): boolean =>
2416
+ r.action.kind === 'spell'
2417
+ && isMultiTargetCombatSpell(defOf(r.action.spellId))
2418
+ && r.conditions.some((c) => c.metric === 'enemyCount' && (c.op === '>=' || c.op === '>'));
2419
+ const toHoist = combat.slice(wallIdx + 1).filter(isGatedMulti);
2420
+ if (!toHoist.length) return combat;
2421
+ const kept = combat.filter((r, i) => i <= wallIdx || !isGatedMulti(r));
2422
+ return [...kept.slice(0, wallIdx), ...toHoist, ...kept.slice(wallIdx)];
2423
+ }
2424
+
2425
+ // When packs omit long-cooldown self immunities, mitigation or defense reacts, put them into combat
2426
+ // with the correct gate. Also the damage cooldowns and racials, which no pack lists. Idempotent.
2427
+ export function ensureKnownDefensiveAndReactCombat(ctx: FinalizeContext): void {
2428
+ const combatIds = ctx.usedSpellsByPhase.combat;
2429
+ const inject = (spellId: number, conditions: Condition[]): void => {
2430
+ if (combatIds.has(spellId)) return;
2431
+ // Prefer near the head so panic CDs / reacts outrank unconditional fillers.
2432
+ ctx.phases.combat.unshift({
2433
+ id: ctx.nextFallbackId(),
2434
+ action: { kind: 'spell', spellId },
2435
+ conditions,
2436
+ enabled: true,
2437
+ });
2438
+ combatIds.add(spellId);
2439
+ };
2440
+
2441
+ const resolve = (id: number): SpellDef | null | undefined =>
2442
+ ctx.knownById.get(id) ?? ctx.ds.spell(id);
2443
+
2444
+ for (const s of ctx.known) {
2445
+ // Forms are never panic injects (mechanic_immunity on Bear/Cat/Travel fooled isSelfDefensive).
2446
+ if (isFormSpell(s) || formIdFromLabel(s.name) != null) continue;
2447
+ // Bursts first: Death Wish and Recklessness carry fear immunity, so the defensive branch
2448
+ // below would otherwise claim them and stamp the low-health gate this replaces.
2449
+ if (isOffensiveBurstCooldown(s, resolve)) {
2450
+ inject(s.id, burstCooldownConditions(s));
2451
+ continue;
2452
+ }
2453
+ {
2454
+ // Berserker Rage: one row per mechanic the snapshot can see (conditions are ANDed).
2455
+ // `inject` is id-keyed, so only the first lands here; the rest come from the pack.
2456
+ const ccBreaks = ccBreakGateConditions(s);
2457
+ if (ccBreaks.length) {
2458
+ for (const conds of ccBreaks) inject(s.id, conds);
2459
+ continue;
2460
+ }
2461
+ }
2462
+ if (isSelfDefensiveSpell(s)) {
2463
+ inject(s.id, [
2464
+ cond('self', 'healthPct', '<', DEFAULT_THRESHOLDS.emergencyDefensivePct),
2465
+ ]);
2466
+ continue;
2467
+ }
2468
+ if (isDefenseReactSpell(s)) {
2469
+ inject(s.id, [cond('self', 'defenseReactRecently', '==', 1)]);
2470
+ continue;
2471
+ }
2472
+ }
2473
+ }
2474
+
2475
+ // Battle rez (druid Rebirth) into combat when the authored pack never listed it — no band in
2476
+ // druid.json names it, so no druid has ever carried one. Highest usable rank; idempotent.
2477
+ export function ensureBattleRezCombatRow(ctx: FinalizeContext): void {
2478
+ let best: SpellDef | null = null;
2479
+ for (const id of ctx.sb.known) {
2480
+ const s = ctx.ds.spell(id);
2481
+ if (!s || !isBattleRezSpell(s)) continue;
2482
+ if ((s.spellLevel ?? 0) > (ctx.char.level | 0)) continue;
2483
+ if (!best || (s.rank ?? 0) > (best.rank ?? 0)) best = s;
2484
+ }
2485
+ if (!best || ctx.usedSpellsByPhase.combat.has(best.id)) return;
2486
+ ctx.phases.combat.unshift({
2487
+ id: ctx.nextFallbackId(),
2488
+ action: { kind: 'spell', spellId: best.id },
2489
+ conditions: resurrectGateConditions(),
2490
+ enabled: true,
2491
+ });
2492
+ ctx.usedSpellsByPhase.combat.add(best.id);
2493
+ }
2494
+
2495
+ // The health band an emergency ITEM may fire at: below every heal SPELL the list already casts, by
2496
+ // EMERGENCY_ITEM_GAP_PCT. A potion tied with a heal at the same 35% raced it for the GCD and won
2497
+ // half the time, burning a 2-minute category-4 lock (itemUseCooldown.ts) on damage a free cast
2498
+ // covered. Returns the default when the list has no heal spell — a warrior's potion IS its heal.
2499
+ const EMERGENCY_ITEM_GAP_PCT = 10;
2500
+ // A potion is instant and costs no form drop, so a shifted druid or a bear tank reaches for it before
2501
+ // its 25% caster heals. That makes 25 the floor, not a number to keep subtracting past.
2502
+ const EMERGENCY_ITEM_FLOOR_PCT = 25;
2503
+
2504
+ function emergencyItemHpBand(ctx: FinalizeContext): number {
2505
+ let lowestHeal = Number.POSITIVE_INFINITY;
2506
+ for (const r of ctx.phases.recovery) {
2507
+ if (!r.enabled || r.action.kind !== 'spell') continue;
2508
+ const s = ctx.knownById.get(r.action.spellId) ?? ctx.ds.spell(r.action.spellId);
2509
+ // Same heal test build.ts:279 uses; a HoT counts, since it is the cast that beats the potion.
2510
+ if (!s?.effects.some((e) => e.kind === 'heal' || e.kind === 'restore_health')) continue;
2511
+ for (const c of r.conditions) {
2512
+ if (c.subject !== 'self' || c.metric !== 'healthPct') continue;
2513
+ if (c.op !== '<' && c.op !== '<=') continue;
2514
+ lowestHeal = Math.min(lowestHeal, c.value);
2515
+ }
2516
+ }
2517
+ if (!Number.isFinite(lowestHeal)) return DEFAULT_THRESHOLDS.potionLowPct;
2518
+ return Math.max(
2519
+ EMERGENCY_ITEM_FLOOR_PCT,
2520
+ Math.min(DEFAULT_THRESHOLDS.potionLowPct, lowestHeal - EMERGENCY_ITEM_GAP_PCT),
2521
+ );
2522
+ }
2523
+
2524
+ // Lower an existing self-health item row onto that band. Only ever LOWERS: a pack that deliberately
2525
+ // authored a potion under the band keeps its number.
2526
+ function lowerEmergencyItemRows(ctx: FinalizeContext, band: number): void {
2527
+ for (const r of ctx.phases.recovery) {
2528
+ if (r.action.kind !== 'item') continue;
2529
+ if (r.action.target != null && r.action.target !== 'self') continue;
2530
+ const it = ctx.ds.item(r.action.itemEntry);
2531
+ if (!it?.useInCombat || it.useInCombat.effect !== 'restore_health') continue;
2532
+ for (const c of r.conditions) {
2533
+ if (c.subject !== 'self' || c.metric !== 'healthPct') continue;
2534
+ if (c.op !== '<' && c.op !== '<=') continue;
2535
+ if (c.value > band) c.value = band;
2536
+ }
2537
+ }
2538
+ }
2539
+
2540
+ // Standing consumable rows — a health potion and the one that refills this class's power — gated on
2541
+ // itemCount, so the row is visible while the bag is empty and starts working when you pick one up.
2542
+ export function ensureCombatConsumableRows(ctx: FinalizeContext): void {
2543
+ const usedItems = ctx.usedItemsByPhase.recovery;
2544
+ // Dedup by LADDER as well as by entry: a pack potion row and this one are the same row even
2545
+ // when they resolved to different rungs, and two rows off one family is a duplicate.
2546
+ const usedFamilies = new Set<string>();
2547
+ for (const r of ctx.phases.recovery) {
2548
+ if (r.action.kind === 'item' && r.action.family) usedFamilies.add(r.action.family);
2549
+ }
2550
+ const band = emergencyItemHpBand(ctx);
2551
+ lowerEmergencyItemRows(ctx, band);
2552
+ for (const c of bestCombatConsumables(ctx.char, ctx.ds)) {
2553
+ if (usedItems.has(c.entry)) continue;
2554
+ if (c.family && usedFamilies.has(c.family)) continue;
2555
+ // Appended, not unshifted: a real heal spell should always be preferred to a potion.
2556
+ ctx.phases.recovery.push({
2557
+ id: ctx.nextFallbackId(),
2558
+ action: { kind: 'item', itemEntry: c.entry, ...(c.family ? { family: c.family } : {}) },
2559
+ conditions: combatConsumableConditions(c, c.kind === 'health' ? band : undefined),
2560
+ enabled: true,
2561
+ });
2562
+ usedItems.add(c.entry);
2563
+ if (c.family) usedFamilies.add(c.family);
2564
+ }
2565
+ }
2566
+
2567
+ // What the player did to this raid's row set on the Raid tab.
2568
+ export interface RaidRowOptions {
2569
+ /** Baked row ids switched OFF (`AutopilotConfig.raidRowsOff`). */
2570
+ off?: readonly string[];
2571
+ /** Rows the player wrote (`AutopilotConfig.raidRows`); any raid, filtered by slug here. */
2572
+ own?: readonly RaidCustomRow[];
2573
+ /** Distances the player retuned on a shipped row (`AutopilotConfig.raidRowYd`). */
2574
+ yd?: Readonly<Record<string, number>>;
2575
+ }
2576
+
2577
+ // The row options carried on a save, for the two composers. Reads state.autopilot structurally so
2578
+ // this module keeps its one-way imports, and normalizes here since a hand-edited save is any shape.
2579
+
2580
+ export function raidRowOptionsOf(state: {
2581
+ autopilot?: { raidRowsOff?: unknown; raidRows?: unknown; raidRowYd?: unknown } | null;
2582
+ } | null | undefined): RaidRowOptions {
2583
+ const ap = state?.autopilot;
2584
+ if (!ap) return {};
2585
+ const off = Array.isArray(ap.raidRowsOff)
2586
+ ? ap.raidRowsOff.filter((id): id is string => typeof id === 'string' && id.length > 0)
2587
+ : [];
2588
+ const own = normalizeRaidCustomRows(ap.raidRows);
2589
+ const yd: Record<string, number> = {};
2590
+ if (ap.raidRowYd && typeof ap.raidRowYd === 'object' && !Array.isArray(ap.raidRowYd)) {
2591
+ for (const [id, v] of Object.entries(ap.raidRowYd)) {
2592
+ if (id && typeof v === 'number' && Number.isFinite(v) && v > 0) yd[id] = Math.round(v);
2593
+ }
2594
+ }
2595
+ return {
2596
+ ...(off.length ? { off } : {}),
2597
+ ...(own.length ? { own } : {}),
2598
+ ...(Object.keys(yd).length ? { yd } : {}),
2599
+ };
2600
+ }
2601
+
2602
+
2603
+ // Raid-response rows for ONE raid, scoped by `slug` rather than built into every save — a level-10
2604
+ // warrior used to carry the Grobbulus run-out forever. Scoped by RAID, not by focus.
2605
+ export function raidResponseRows(
2606
+ slug: string | undefined,
2607
+ char: Character,
2608
+ sb: SpellBook,
2609
+ ds: DataStore,
2610
+ opts?: RaidRowOptions,
2611
+ ): readonly RotationRule[] {
2612
+ if (!slug) return [];
2613
+ const tactics = raidTacticsForSlug(slug);
2614
+ if (!tactics.length) return [];
2615
+ const rows: RotationRule[] = [];
2616
+ const seen = new Set<string>();
2617
+ // Content-derived ids, so two bosses sharing an aura contribute one row (Mortal Wound 25646
2618
+ // is Kurinnaxx, Fankriss and Gluth) and the Raid tab can name every fight it covers.
2619
+ const tuned = opts?.yd;
2620
+ const add = (id: string, action: RuleAction, conditions: Condition[]): void => {
2621
+ if (seen.has(id)) return;
2622
+ seen.add(id);
2623
+ rows.push({ id, action, conditions, enabled: true });
2624
+ };
2625
+
2626
+ // Priest MC on the adds that ARE the mechanic (Faerlina Worshipper, Razuvious Understudy):
2627
+ // 605 already charms; this chooses to cast it.
2628
+ if (char.cls === CLASS.PRIEST) {
2629
+ const known = knownUsableSpells(char, sb, ds);
2630
+ let mc = known.find(
2631
+ (s) => s.id === SPELL_MIND_CONTROL || (s.name ?? '').toLowerCase() === 'mind control',
2632
+ );
2633
+ // Fallback: spellbook id present even when the usable filter drops it (fixture stubs).
2634
+ if (!mc && sb.known.includes(SPELL_MIND_CONTROL) && ds.spell(SPELL_MIND_CONTROL)) {
2635
+ mc = ds.spell(SPELL_MIND_CONTROL) ?? undefined;
2636
+ }
2637
+ if (mc) {
2638
+ for (const t of tactics) {
2639
+ for (const m of t.mcEntries ?? []) {
2640
+ const conditions: Condition[] = [
2641
+ cond('self', 'inCombat', '==', 1),
2642
+ cond('self', 'charmedHostileCount', '==', 0),
2643
+ ];
2644
+ if (m.requireTargetBuff != null) {
2645
+ conditions.push(cond('target', 'buffPresent', '==', 1, m.requireTargetBuff));
2646
+ }
2647
+ add(
2648
+ `raid_resp_mc_${m.entry}`,
2649
+ { kind: 'spell', spellId: mc.id, target: { creatureEntry: m.entry } },
2650
+ conditions,
2651
+ );
2652
+ }
2653
+ }
2654
+ }
2655
+ }
2656
+
2657
+ // The off-tank taunt needs THIS unit's taunt spell, which is why these rows cannot be a
2658
+ // static table: warrior Taunt and druid Growl are different ids.
2659
+ const known = knownUsableSpells(char, sb, ds);
2660
+ const taunt = known.find((s) => s.effects.some((e) => e.kind === 'taunt'));
2661
+ // Same shape as the taunt: a kick row can only compose on a body that knows one.
2662
+ const kick = known.find((s) => s.effects.some((e) => e.kind === 'interrupt_cast'));
2663
+ // `cc` is an incapacitate that BREAKS on damage, or a banish — not a 3 s stun and not a fear.
2664
+ // Longest wins. Narrowed in the ternary: a boolean filter callback does not narrow the union.
2665
+ const ccSpell = known
2666
+ .flatMap((sp) => sp.effects.flatMap((e) => (
2667
+ e.kind === 'cc'
2668
+ && ((e.control === 'incapacitate' && e.breakOnDamage === true) || e.mechanic === 'banish')
2669
+ ? [{ id: sp.id, ms: (e.durationMs ?? 0) | 0 }]
2670
+ : []
2671
+ )))
2672
+ .sort((a, b) => b.ms - a.ms)[0];
2673
+ if (taunt) {
2674
+ for (const t of tactics) {
2675
+ if (t.tankSwapAura == null) continue;
2676
+ add(`raid_resp_swap_${t.tankSwapAura}`, { kind: 'spell', spellId: taunt.id }, [
2677
+ cond('self', 'isOffTank', '==', 1),
2678
+ cond('self', 'targetAttackingMe', '==', 0),
2679
+ cond('target', 'aggroHolderDebuffPresent', '==', 1, t.tankSwapAura),
2680
+ ]);
2681
+ }
2682
+ }
2683
+
2684
+ // Own-debuff bomb run-outs, highest priority: walking out beats any filler, at the BAKED payload
2685
+ // radius. The alliesWithinYd half is what ENDS the run-out, so the holder resumes casting.
2686
+ for (const t of tactics) {
2687
+ for (const f of t.fleeAuras ?? []) {
2688
+ // Run PAST the blast, not to its edge: parking on the baked radius let drift walk the bomb
2689
+ // back into the raid, costing 15.0 bodies a run against 12.8. The margin is hysteresis.
2690
+ const id = `raid_resp_flee_${f.aura}`;
2691
+ // A retuned distance has to move the GATE with the walk, since alliesWithinYd at clearYd is
2692
+ // what ends the run-out. Rewriting only the action leaves the two halves disagreeing.
2693
+ const clearYd = tuned?.[id] ?? f.radiusYd + FLEE_CLEAR_MARGIN_YD;
2694
+ add(
2695
+ id,
2696
+ { kind: 'move', move: { kind: 'flee_raid', yd: clearYd } },
2697
+ [
2698
+ cond('self', 'debuffPresent', '==', 1, f.aura),
2699
+ cond('self', 'alliesWithinYd', '>', 0, clearYd),
2700
+ ],
2701
+ );
2702
+ }
2703
+ }
2704
+ // Rows were added MC -> swap -> flee; combat reads top-down, so reverse to put flee first.
2705
+ rows.reverse();
2706
+
2707
+ // Own rows and shipped cureAuras compose per-body: a priest and a mage each press their own
2708
+ // spell. `dispel` is null when the aura is not dispellable or this unit has no cure.
2709
+ const dispelKitFor = (aura: number): { dispelSpellId: number | null; dispelCategory: number | null } => {
2710
+ const cat = aura > 0 ? (ds.spell(aura)?.dispel ?? 0) | 0 : 0;
2711
+ if (!(cat > 0)) return { dispelSpellId: null, dispelCategory: null };
2712
+ const cure = known.find((sp) => sp.effects.some(
2713
+ (e) => e.kind === 'dispel' && (e.dispelType | 0) === cat,
2714
+ ));
2715
+ return { dispelSpellId: cure ? cure.id : null, dispelCategory: cat };
2716
+ };
2717
+ for (const t of tactics) {
2718
+ for (const c of t.cureAuras ?? []) {
2719
+ const composed = raidCustomRowRule({
2720
+ id: `tmp_cure_${c.aura}`,
2721
+ slug,
2722
+ entry: t.entry,
2723
+ trigger: 'aura',
2724
+ aura: c.aura,
2725
+ on: c.on,
2726
+ response: 'dispel',
2727
+ enabled: true,
2728
+ }, { ...dispelKitFor(c.aura) });
2729
+ if (composed) add(`raid_resp_cure_${c.aura}`, composed.action, composed.conditions);
2730
+ }
2731
+ }
2732
+ const own: RotationRule[] = [];
2733
+ for (const r of opts?.own ?? []) {
2734
+ if (r.slug !== slug || !r.enabled) continue;
2735
+ const rule = raidCustomRowRule(r, {
2736
+ tauntSpellId: taunt ? taunt.id : null,
2737
+ interruptSpellId: kick ? kick.id : null,
2738
+ // `focus` wraps the shipped kill-order for the fight the row was written on.
2739
+ focusEntries: r.entry ? killOrderEntriesForBoss(r.entry) : [],
2740
+ clickGoEntries: r.entry ? useObjectEntriesForBoss(r.entry) : [],
2741
+ ccSpellId: ccSpell ? ccSpell.id : null,
2742
+ ...dispelKitFor(r.aura),
2743
+ });
2744
+ if (rule && !seen.has(rule.id)) {
2745
+ seen.add(rule.id);
2746
+ own.push(rule);
2747
+ }
2748
+ }
2749
+ const all = own.length ? [...own, ...rows] : rows;
2750
+ const off = opts?.off && opts.off.length ? new Set(opts.off) : null;
2751
+ return off ? all.filter((r) => !off.has(r.id)) : all;
2752
+ }
2753
+
2754
+ // The combat list this raid actually runs: any stored raid row dropped, then this raid's set
2755
+ // prepended. Never written back. The prune is what migrates old saves that still hold the old rows.
2756
+ export function withRaidTacticRows(
2757
+ rot: Rotation,
2758
+ slug: string | undefined,
2759
+ char: Character,
2760
+ sb: SpellBook,
2761
+ ds: DataStore,
2762
+ opts?: RaidRowOptions,
2763
+ ): Rotation {
2764
+ const stored = rot.phases.combat;
2765
+ const kept = stored.filter((r) => !isRaidRuleId(r.id));
2766
+ const add = raidResponseRows(slug, char, sb, ds, opts);
2767
+ // The per-fight rows are prepended later inside evaluate, off an index keyed on the live focus, so
2768
+ // the composer cannot filter them here. Carrying the two knobs is how they reach that prepend.
2769
+ const tuning: Pick<Rotation, 'raidRowsOff' | 'raidRowYd'> = {
2770
+ ...(opts?.off?.length ? { raidRowsOff: opts.off } : {}),
2771
+ ...(opts?.yd && Object.keys(opts.yd).length ? { raidRowYd: opts.yd } : {}),
2772
+ };
2773
+ const changed = Object.keys(tuning).length > 0;
2774
+ if (!add.length && kept.length === stored.length && !changed) return rot;
2775
+ return { ...rot, ...tuning, phases: { ...rot.phases, combat: [...add, ...kept] } };
2776
+ }
2777
+
2778
+ /** Which fight the caller is building for. Absent = PvE, and PvE never selects a pvp band. */
2779
+ export interface RotationBandOptions {
2780
+ mode?: BandMode;
2781
+ /** Names a pvp band variant; only meaningful with `mode: 'pvp'`. Absent = the default band. */
2782
+ variant?: string;
2783
+ }
2784
+
2785
+ export function buildDefaultRotation(
2786
+ char: Character,
2787
+ sb: SpellBook,
2788
+ ds: DataStore,
2789
+ inventory?: Inventory,
2790
+ specOverride?: string | null,
2791
+ opts?: RotationBandOptions,
2792
+ ): Rotation {
2793
+ const key = suggestionClassKeyFromClassId(char.cls);
2794
+ let rot = key
2795
+ ? buildSuggestionRotation(char, sb, ds, inventory, key, specOverride, opts)
2796
+ : buildRoleBucketRotation(char, sb, ds, inventory);
2797
+ // Always gate form/stance enter (Cat Form, Battle Stance, Shadowform, …). This is what
2798
+ // "Reset to recommended" must ship — bare form rules toggle off every GCD.
2799
+ rot = ensureFormEnterGates(rot, ds);
2800
+ // Shared semantic if-statements for execute / creature-type / multi / keep-up / …
2801
+ // (role-bucket-only path also needs this; suggestion path re-applies idempotently).
2802
+ rot = ensureSemanticSpellGates(rot, ds);
2803
+ // One exclusive-family aura at a time: the plain keep-up yields to the situational sibling.
2804
+ rot = ensureExclusiveFamilyKeepUpYields(rot, ds);
2805
+ // Raid rows are NOT built in here any more — they are composed per raid, so a save holds only what
2806
+ // the player authored. Stamp the signature so rotation() can auto-refresh this default.
2807
+ rot.autoSig = rotationAutoSig(char, sb, ds, specOverride, opts);
2808
+ return rot;
2809
+ }
2810
+
2811
+ // Bump when a change to the packs or a class hook must reach rotations ALREADY STORED in a save.
2812
+ // Untouched defaults rebuild once; a hand-edited rotation is never auto-wiped.
2813
+ export const ROTATION_PACK_REV = 13;
2814
+
2815
+ /** The build-time inputs an auto rotation depends on, for staleness detection. */
2816
+ export function rotationAutoSig(
2817
+ char: Character,
2818
+ sb: SpellBook,
2819
+ ds: DataStore,
2820
+ specOverride?: string | null,
2821
+ opts?: RotationBandOptions,
2822
+ ): RotationAutoSig {
2823
+ return {
2824
+ spec: primarySpec(char, ds, specOverride),
2825
+ level: char.level | 0,
2826
+ spells: sb.known.length,
2827
+ packRev: ROTATION_PACK_REV,
2828
+ ...(opts?.mode ? { mode: opts.mode } : {}),
2829
+ ...(opts?.mode && opts.variant ? { variant: opts.variant } : {}),
2830
+ };
2831
+ }
2832
+
2833
+ // True when the recommended default for the current level, spec and spellbook would differ from
2834
+ // what this rotation was generated for. Ignores `custom` — callers decide. No autoSig is drift.
2835
+ export function rotationSigDiffers(
2836
+ rot: Rotation,
2837
+ char: Character,
2838
+ sb: SpellBook,
2839
+ ds: DataStore,
2840
+ specOverride?: string | null,
2841
+ opts?: RotationBandOptions,
2842
+ ): boolean {
2843
+ const cur = rotationAutoSig(char, sb, ds, specOverride, opts);
2844
+ const s = rot.autoSig;
2845
+ if (!s || s.spec !== cur.spec || s.level !== cur.level || s.spells !== cur.spells) return true;
2846
+ // A PvE list stored where a pvp one is now wanted is drift: the four fields above describe
2847
+ // the character, and a pvp band changes only the LIST.
2848
+ if ((s.mode ?? undefined) !== (cur.mode ?? undefined)) return true;
2849
+ // Same for the variant: two pvp lists for one tree are different fighters (duel plan G1).
2850
+ if ((s.variant ?? undefined) !== (cur.variant ?? undefined)) return true;
2851
+ // Pack / hook revision: the three fields above all describe the character, so nothing here
2852
+ // could ever notice that the RECOMMENDED list changed under a save.
2853
+ return (s.packRev ?? 0) < ROTATION_PACK_REV;
2854
+ }
2855
+
2856
+ // Which LIST a stored rotation was built from. A refresh must rebuild the same list: every caller
2857
+ // below passes only the band, so a stored pvp list read as drift against the PvE ladder every time.
2858
+ export function rotationBandOptionsOf(rot: Rotation | null | undefined): RotationBandOptions | undefined {
2859
+ const sig = rot?.autoSig;
2860
+ if (!sig?.mode) return undefined;
2861
+ return { mode: sig.mode, ...(sig.variant ? { variant: sig.variant } : {}) };
2862
+ }
2863
+
2864
+ // Keep a player rotation live with the current kit: untouched defaults rebuild when level, spec,
2865
+ // known-spell count or squad role drifts, and hand-edited ones are left alone. Shared by three paths.
2866
+ export function ensureLivePlayerRotation(
2867
+ rot: Rotation | null | undefined,
2868
+ char: Character,
2869
+ sb: SpellBook,
2870
+ ds: DataStore,
2871
+ inventory: Inventory | undefined,
2872
+ opts?: {
2873
+ /** Spec band (rotationSpec or role→Holy/Resto/Protection). */
2874
+ band?: string | null;
2875
+ /** Squad role for autoSig.role drift. */
2876
+ role?: string | null;
2877
+ },
2878
+ ): Rotation {
2879
+ const band = opts?.band ?? undefined;
2880
+ const role = opts?.role === 'healer' || opts?.role === 'tank' ? opts.role : 'dps';
2881
+ // The stored list's own sig says which pool it came from; without it a `+ PvP` profile rebuilt
2882
+ // as the PvE default on the first read after it was made (rotation.ts liveRotation).
2883
+ const mode = rotationBandOptionsOf(rot);
2884
+ const build = (): Rotation => ({
2885
+ ...buildDefaultRotation(char, sb, ds, inventory, band, mode),
2886
+ autoSig: {
2887
+ ...rotationAutoSig(char, sb, ds, band ?? null, mode),
2888
+ role,
2889
+ },
2890
+ });
2891
+ let next: Rotation;
2892
+ if (!rot || !isRotationValid(rot, char.cls)) {
2893
+ next = build();
2894
+ } else if (!rot.custom) {
2895
+ const roleDrift = !!rot.autoSig && (rot.autoSig.role ?? 'dps') !== role;
2896
+ next = (rotationSigDiffers(rot, char, sb, ds, band, mode) || roleDrift) ? build() : rot;
2897
+ } else {
2898
+ next = rot;
2899
+ }
2900
+ // A dispel this character KNOWS but no row names — a hand-edited list, or a book read after the
2901
+ // list was built — on the same always-re-sync footing as the party buff rows.
2902
+ const dispelled = ensureKnownDispelRows(
2903
+ stripStoredRaidRows(stripThrashResurrectRules(withPrebuffPhase(next), ds)),
2904
+ sb,
2905
+ ds,
2906
+ );
2907
+ return repairConjureStockGates(dispelled, (id) => ds.spell(id));
2908
+ }
2909
+
2910
+ // True if a stored rotation is shaped right and still matches the character's class. Checks the
2911
+ // REQUIRED phases, not all of them — requiring `prebuff` would rebuild every save and lose edits.
2912
+ export function isRotationValid(rot: unknown, cls: number): rot is Rotation {
2913
+ if (!rot || typeof rot !== 'object') return false;
2914
+ const r = rot as Rotation;
2915
+ if (r.version !== 1 || r.cls !== cls || !r.phases) return false;
2916
+ return REQUIRED_ROTATION_PHASES.every((p) => Array.isArray(r.phases[p]));
2917
+ }
2918
+
2919
+ // Backfill any phase list a stored rotation predates, so every `ROTATION_PHASES` loop can index `phases[p]`
2920
+ // without an undefined. Runs on every read path before the strip/repair passes, which do exactly that.
2921
+ export function withPrebuffPhase(rot: Rotation): Rotation {
2922
+ let missing = false;
2923
+ for (const p of ROTATION_PHASES) if (!Array.isArray(rot.phases[p])) missing = true;
2924
+ if (!missing) return rot;
2925
+ const phases = { ...rot.phases };
2926
+ for (const p of ROTATION_PHASES) if (!Array.isArray(phases[p])) phases[p] = [];
2927
+ return { ...rot, phases };
2928
+ }
2929
+
2930
+ /** Return the stored rotation if it's valid for this class, else a freshly generated default. */
2931
+ export function normalizeRotation(stored: unknown, char: Character, sb: SpellBook, ds: DataStore, inventory?: Inventory): Rotation {
2932
+ return isRotationValid(stored, char.cls)
2933
+ ? withPrebuffPhase(stored)
2934
+ : buildDefaultRotation(char, sb, ds, inventory);
2935
+ }
2936
+
2937
+ // Drop every raid tactic row out of a STORED rotation. Raid rows are composed per raid and were
2938
+ // never meant to reach a save; invisible in the Combat list, they still shift every printed index.
2939
+ export function stripStoredRaidRows(rot: Rotation): Rotation {
2940
+ let changed = false;
2941
+ const phases = { ...rot.phases };
2942
+ for (const p of ROTATION_PHASES) {
2943
+ const cur = rot.phases[p] ?? [];
2944
+ const next = cur.filter((r) => !isRaidRuleId(r.id));
2945
+ if (next.length !== cur.length) {
2946
+ phases[p] = next;
2947
+ changed = true;
2948
+ }
2949
+ }
2950
+ // The two composed-only knobs are the config's, not the save's. A save that somehow holds
2951
+ // them would pin the Raid tab's switches to whatever they were when it was written.
2952
+ const stale = rot.raidRowsOff != null || rot.raidRowYd != null;
2953
+ if (!changed && !stale) return rot;
2954
+ const out: Rotation = { ...rot, phases };
2955
+ delete out.raidRowsOff;
2956
+ delete out.raidRowYd;
2957
+ return out;
2958
+ }
2959
+
2960
+ // Drop thrash party-rez rules from a stored rotation, so a custom save that listed Redemption stops
2961
+ // spending mana with no dead target. Rules already gated on deadAllyCount stay.
2962
+ export function stripThrashResurrectRules(rot: Rotation, ds: DataStore): Rotation {
2963
+ const strip = (rules: RotationRule[]): RotationRule[] =>
2964
+ rules.filter((r) => {
2965
+ if (r.action.kind !== 'spell') return true;
2966
+ const sp = ds.spell(r.action.spellId);
2967
+ if (!sp || !isResurrectSpell(sp)) return true;
2968
+ // Keep only rules that already require a dead ally.
2969
+ return r.conditions.some((c) => c.metric === 'deadAllyCount');
2970
+ });
2971
+ let changed = false;
2972
+ const phases = { ...rot.phases };
2973
+ for (const p of ROTATION_PHASES) {
2974
+ const cur = rot.phases[p] ?? [];
2975
+ const next = strip(cur);
2976
+ if (next.length !== cur.length) {
2977
+ phases[p] = next;
2978
+ changed = true;
2979
+ }
2980
+ }
2981
+ if (!changed) return rot;
2982
+ return { ...rot, phases };
2983
+ }
2984
+
2985
+ // Replace any rule actions that reference one of the `oldIds` with `newId`. Also migrate presence
2986
+ // refs and `{ allyMissingAura }` so a rank-up cannot leave the keep-up gate on the old id.
2987
+ const UPGRADE_PRESENCE_METRICS: ReadonlySet<string> = new Set([
2988
+ 'buffPresent', 'debuffPresent', 'buffStacks', 'debuffStacks',
2989
+ 'buffRemainingMs', 'debuffRemainingMs', 'canAfford',
2990
+ 'allyLowestBuffPresent', 'allyAuraPresent', 'allyAuraStacks',
2991
+ 'aggroHolderDebuffPresent', 'aggroHolderDebuffStacks',
2992
+ ]);
2993
+
2994
+ export function upgradeSpellInRotation(rot: Rotation, oldIds: number[], newId: number): Rotation {
2995
+ if (!oldIds.length) return rot;
2996
+ const oldSet = new Set(oldIds);
2997
+ const replaceAction = (a: RuleAction): RuleAction => {
2998
+ if (a.kind !== 'spell') return a;
2999
+ const spellId = oldSet.has(a.spellId) ? newId : a.spellId;
3000
+ const t = a.target;
3001
+ const target = t && typeof t === 'object' && 'allyMissingAura' in t && oldSet.has(t.allyMissingAura)
3002
+ ? { allyMissingAura: newId, ...('among' in t && t.among ? { among: t.among } : {}) }
3003
+ : t;
3004
+ if (spellId === a.spellId && target === t) return a;
3005
+ return { ...a, spellId, ...(target !== undefined ? { target } : {}) };
3006
+ };
3007
+ const upgradeRules = (rules: RotationRule[]): RotationRule[] =>
3008
+ rules.map((r) => {
3009
+ const action = replaceAction(r.action);
3010
+ let dirty = action !== r.action;
3011
+ const conditions = r.conditions.map((c) => {
3012
+ if (c.ref == null || !oldSet.has(c.ref) || !UPGRADE_PRESENCE_METRICS.has(c.metric)) return c;
3013
+ dirty = true;
3014
+ return { ...c, ref: newId };
3015
+ });
3016
+ return dirty ? { ...r, action, conditions } : r;
3017
+ });
3018
+ return {
3019
+ ...rot,
3020
+ phases: {
3021
+ prebuff: upgradeRules(rot.phases.prebuff ?? []),
3022
+ buffs: upgradeRules(rot.phases.buffs),
3023
+ pull: upgradeRules(rot.phases.pull),
3024
+ combat: upgradeRules(rot.phases.combat),
3025
+ recovery: upgradeRules(rot.phases.recovery),
3026
+ },
3027
+ overrides: rot.overrides
3028
+ ? Object.fromEntries(Object.entries(rot.overrides).map(([k, rs]) => [k, upgradeRules(rs)]))
3029
+ : undefined,
3030
+ };
3031
+ }