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,4222 @@
1
+ // Squad, dungeon and full-run facade methods plus the squad battle helpers. The helpers are created
2
+ // early so the fight paths can share them; combat re-entry goes through a late-bound bridge.
3
+
4
+ import { executeRunFullDungeon } from './runFullDungeon.ts';
5
+ import type { AllyActionProvider, CombatantSpec, CombatSession } from '../combat.ts';
6
+ import { unsummonTotemsOnLeaveWorld } from '../combat/sessionDeaths.ts';
7
+ import type { SquadUpgradeRow } from './types.ts';
8
+ import type { EquipSlot, GatherNodeDef, InstanceDef, SkillLine } from '../../data/schema.ts';
9
+ import { CLASS } from '../../data/schema.ts';
10
+ import { hasAttrEx2, SPELL_ATTR_EX2 } from '../spells/spellAttributes.ts';
11
+ import {
12
+ refuseIfDead,
13
+ refuseIfDeadOrInCombat,
14
+ refuseIfInCombat,
15
+ YOU_ARE_IN_COMBAT,
16
+ } from '../../domain/run/death.ts';
17
+ import {
18
+ type Companion,
19
+ addCompanion,
20
+ removeCompanion,
21
+ companionRaceFor,
22
+ companionCharacter,
23
+ companionSpec,
24
+ companionPetSpec,
25
+ companionSpellbook,
26
+ companionName,
27
+ companionGender,
28
+ companionLevel,
29
+ stableIdHash,
30
+ defaultXpLocked,
31
+ MAX_COMPANIONS,
32
+ MAX_ROSTER,
33
+ maxCompanionsFor,
34
+ placeHire,
35
+ rosterSize,
36
+ type SquadRole,
37
+ isSquadRole,
38
+ } from '../../domain/bots/squad.ts';
39
+ import { describeRaidComp, partyRaidSubgroups } from '../../domain/raid/raidComp.ts';
40
+ import {
41
+ clampRosterPicks,
42
+ fillOwnedInstanceRoster,
43
+ isRaidLineupSize,
44
+ raidLineupCap,
45
+ type RosterPickBody,
46
+ } from '../../domain/raid/instanceRoster.ts';
47
+ import { reconcileGamePartyAuras } from './partyAuras.ts';
48
+ import { restoreRaidPartySeating } from './raidParty.ts';
49
+ import { seatPartyForRun } from './squad/squadDungeonSeating.ts';
50
+ import { fanStopsAtSubgroup, type HealApplyTo } from '../combat/healTargets.ts';
51
+ import { sessionSeedFromWorldCooldowns } from '../spells/recovery.ts';
52
+ import { RAID_TIERS, RAID_TIER_IDS, raidRosterTemplate } from '../../domain/raid/raidRosterTemplate.ts';
53
+ import {
54
+ innSlate,
55
+ innZoneSalt,
56
+ pickProfile,
57
+ ensureSlateProfileMix,
58
+ searchWorldBots,
59
+ worldBot,
60
+ type BotSearchFilters,
61
+ type WorldBot,
62
+ } from '../../domain/bots/botPool.ts';
63
+ import {
64
+ DEFAULT_HIRE_GEAR_PROFILE,
65
+ GEAR_PROFILES,
66
+ generateSpawnGear,
67
+ isGearProfile,
68
+ type GearProfile,
69
+ } from '../../domain/bots/botGear.ts';
70
+ import {
71
+ hireCostCopper,
72
+ hirePriceLabel,
73
+ displayGearScore,
74
+ } from '../../domain/bots/companionHireCost.ts';
75
+ import { generateSpawnInventory } from '../../domain/bots/botInventory.ts';
76
+ import { SHARE_CATEGORIES, normalizeConsumableShare, planConsumableShare, shareMemberOf, shareMemberOfYou, shareRowsOf } from '../../domain/bots/consumableShare.ts';
77
+ import { sendGoodsUpgradeKey } from '../../domain/bots/sendTradeGoods.ts';
78
+ import {
79
+ initBotPet,
80
+ applyCompanionPetSync,
81
+ setCompanionPetCreature,
82
+ } from '../../domain/bots/botPet.ts';
83
+ import {
84
+ applyCompanionItemPrep,
85
+ applyPlayerItemPrep,
86
+ isWeaponImbueItem,
87
+ } from '../../domain/bots/companionPrep.ts';
88
+ import { writeCompanionStandings } from '../../domain/bots/companionRep.ts';
89
+ import {
90
+ companionQuestLog,
91
+ companionQuestOffers,
92
+ type CompanionQuestOffer,
93
+ type CompanionQuestPlace,
94
+ } from '../../domain/bots/companionQuests.ts';
95
+ import {
96
+ acceptCompanionQuest as acceptCompanionQuestAction,
97
+ turnInCompanionQuest as turnInCompanionQuestAction,
98
+ } from './companionQuestActions.ts';
99
+ import { companionBagHandle, overflowLine, partyOverflow } from './bagOverflow.ts';
100
+ import { abandon as abandonQuest } from '../../domain/quests/questlog.ts';
101
+ import { consume } from '../../progression/rest.ts';
102
+ import { resolveCombatSpell } from '../spells/interpreter/index.ts';
103
+ import { grantSpellReputationEffects } from './spellReputation.ts';
104
+ import { scanTempWeaponImbueHands } from '../../domain/professions/weaponImbueHand.ts';
105
+ import { routeWeaponImbueItem, type LiveImbueUnit } from './weaponImbueItem.ts';
106
+ import {
107
+ ensurePartyBuffRows,
108
+ ensureKnownDispelRows,
109
+ raidRowOptionsOf,
110
+ stripStoredRaidRows,
111
+ withRaidTacticRows,
112
+ } from '../../domain/rotation/rotation.ts';
113
+ import type { ItemRuleHand, RotationProfileId } from '../../domain/rotation/rotation.ts';
114
+ import { companionSkillMap } from '../../domain/bots/squad.ts';
115
+ import { announceCompanionRanks, syncCompanionProfessionTools } from './companionTraining.ts';
116
+ import { talentsFromLink } from '../../domain/character/talentLink.ts';
117
+ import {
118
+ companionTrainedRank,
119
+ professionSkillCap,
120
+ syncCompanionProfessionCatalog,
121
+ initInventorySkill,
122
+ } from '../../domain/bots/botProfessions.ts';
123
+ import {
124
+ canCompanionLearnProfession,
125
+ energyCostForHours,
126
+ gatherMissionSkillGain,
127
+ isMissionSkill,
128
+ missionWorkSkill,
129
+ MAX_MISSION_HOURS,
130
+ missionEtaLabel,
131
+ missionProgress,
132
+ normalizeCompanionMission,
133
+ partyEnergy,
134
+ recommendGatherZones,
135
+ spendPartyEnergy,
136
+ workableZoneNodes,
137
+ workableZoneSkinNodes,
138
+ zoneMissionYield,
139
+ type CompanionProfessionMap,
140
+ type MissionSkill,
141
+ } from '../../domain/bots/companionMissions.ts';
142
+ import {
143
+ partyDeposit,
144
+ partyMergedStacks,
145
+ partyTake,
146
+ partyTakeToBags,
147
+ stackBoundToHolder,
148
+ type PartyVaultCtx,
149
+ type VaultMutators,
150
+ } from '../../domain/raid/partyVault.ts';
151
+ import { vendorShortfall, type VendorReagentBuy } from '../../domain/professions/vendorReagents.ts';
152
+ import { buyNames } from './reagentBuyIn.ts';
153
+ import { craft as doCraft } from '../../domain/professions/professions.ts';
154
+ import { partyHeldTools } from '../../domain/professions/heldTools.ts';
155
+ import {
156
+ recipeCooldownRemainingMs, stampRecipeCooldown, craftCooldownLabel,
157
+ } from '../../domain/professions/craftCooldown.ts';
158
+ import { applyEnchant as doApplyEnchant } from '../../domain/professions/enchanting.ts';
159
+ import { zoneSpawns } from '../../world/world.ts';
160
+ import { itemTeachIntent } from '../../domain/items/itemTeaches.ts';
161
+ import { hashSeed, mulberry32 } from '../core/rng.ts';
162
+ import { seedDuelGeometry } from './combatSeed.ts';
163
+ import { playerFightActionProvider } from './rotation.ts';
164
+ import type { ActiveAura, Character, EquippedMap } from '../../domain/character/character.ts';
165
+ import { applyAura, buildCombatant, combatItems } from '../../domain/character/character.ts';
166
+ import { canChallenge, createDuelSession } from '../../domain/run/duel.ts';
167
+ import { canInteractWithCreature } from '../../domain/creature/faction.ts';
168
+ import { factionOf } from '../../domain/character/races.ts';
169
+ import {
170
+ isMultiTargetCombatSpell,
171
+ isRotationValid,
172
+ rotationBandOptionsOf,
173
+ type Rotation,
174
+ } from '../../domain/rotation/rotation.ts';
175
+ import {
176
+ applyCompanionRole,
177
+ botSpecIdxForRole,
178
+ specIdxForRole,
179
+ buildCompanionRotation,
180
+ companionHasPvpBand,
181
+ companionActionProvider,
182
+ } from '../../domain/bots/companionRotation.ts';
183
+ import { buildActionProvider, type ActionProviderSpec } from '../../domain/bots/actionProviderSpec.ts';
184
+ import {
185
+ isCompanionKeepUpSpell,
186
+ keepUpAuraDurationMs,
187
+ syncCompanionBagItemRules,
188
+ companionActiveProfile,
189
+ companionHasDpsProfile,
190
+ companionRotationSigDiffers,
191
+ companionRotationSlot,
192
+ companionRotationOf,
193
+ setCompanionRotationSlot,
194
+ } from '../../domain/bots/companionRotation.ts';
195
+ import {
196
+ activeProfile,
197
+ addProfile,
198
+ MAX_ROTATION_PROFILES,
199
+ migrateLegacyProfiles,
200
+ removeProfile,
201
+ renameProfile,
202
+ setActiveProfile,
203
+ syncActiveFromMirror,
204
+ } from '../../domain/rotation/rotationProfiles.ts';
205
+ import { vsClassVariant } from '../../domain/rotation/rotationSuggestions.ts';
206
+ import {
207
+ companionEligibleSpellIds,
208
+ ensureCompanionActionBars,
209
+ equippedOnUseEntries,
210
+ growBarsForAutofill,
211
+ resolveCompanionActionBars,
212
+ seedCompanionActionBars,
213
+ topUpCompanionActionBars,
214
+ } from '../../domain/bots/companionActionBar.ts';
215
+ import {
216
+ addBar as addActionBarPure,
217
+ removeBar as removeActionBarPure,
218
+ setSlot as setBarSlot,
219
+ setItemSlot as setBarItemSlot,
220
+ clearSlot as clearBarSlot,
221
+ autofill as autofillBars,
222
+ replaceSpell as replaceBarSpell,
223
+ } from '../../domain/character/actionbar.ts';
224
+ import { normalizeAutopilotConfig } from '../../autopilot/config.ts';
225
+ import {
226
+ conjurableProvisions,
227
+ planPrePull,
228
+ usableProvisionsFor,
229
+ type ConjurableProvision,
230
+ type PartyCaster,
231
+ type PlannedBuff,
232
+ type PlannedHandout,
233
+ } from '../../domain/bots/prePullBuffs.ts';
234
+ import { planSoulstone } from '../../domain/run/deathRecovery.ts';
235
+ import {
236
+ healthstoneRunLog,
237
+ runPrePullHealthstones,
238
+ stoneHolder,
239
+ } from './prePullHealthstones.ts';
240
+ import { className, SOUL_SHARD_ITEM } from '../../domain/core/ids.ts';
241
+ import { findUpgrades } from '../../domain/items/equipmentHelper.ts';
242
+ import {
243
+ equipItem, bagCapacity, DUAL_WIELD_SPELL_ID, type EquipOpts,
244
+ } from '../../domain/items/equipment.ts';
245
+ import {
246
+ characterPlayerbotSpecId,
247
+ companionPlayerbotSpecId,
248
+ playerbotWeightScale,
249
+ } from '../../domain/bots/playerbotStatWeight.ts';
250
+ import { paladinPartyMembers } from './paladinBoard.ts';
251
+ import { normalizePaladinAssignments } from '../../domain/rotation/paladinBuffs.ts';
252
+ import {
253
+ mergePaladinChips,
254
+ planPartyPaladinBuffs,
255
+ restampPaladinRotation,
256
+ type PaladinPartyMember,
257
+ } from '../../domain/bots/paladinPrePull.ts';
258
+ import { knows, resolveSpellId } from '../../domain/character/spellbook.ts';
259
+ import { petPowerSpec, petRepertoireHas } from '../../domain/pet/pet.ts';
260
+ import { lowerRanksFor } from '../../domain/character/characterSpells.ts';
261
+ import { isSpellFitByClassAndRace, spellHasClassRestrictingAbility, spellLearnDependents } from '../../domain/character/combatSkills.ts';
262
+ import { canFitWithBagFamily } from '../../domain/items/ammo.ts';
263
+ import type { Inventory } from '../../domain/items/inventory.ts';
264
+ import { ownedInventory } from '../../domain/items/keyring.ts';
265
+ import {
266
+ emptyInventory, addItem, addInstance, removeItem, removeStack, transferItem, takeStack, canFit,
267
+ countItem, usedSlots,
268
+ } from '../../domain/items/inventory.ts';
269
+ import { buyBack, sellStack } from '../../domain/economy/trading.ts';
270
+ import type { ItemInstance, EquippedValue } from '../../domain/items/itemInstance.ts';
271
+ import { equippedEntry, equippedInstance } from '../../domain/items/itemInstance.ts';
272
+ import { buildEntryLockInputs } from '../../domain/run/canEnterContext.ts';
273
+ import {
274
+ deposit, withdraw, depositStack, withdrawStack, bankCapacity, buyBankBagSlot,
275
+ equipBankBag, unequipBankBag,
276
+ } from '../../domain/items/bank.ts';
277
+ import {
278
+ enterRunWithRoute,
279
+ resetRun,
280
+ canEnter,
281
+ lockRemainingMs,
282
+ itemDropsInsideInstance,
283
+ saveRunOnLeave,
284
+ restoreSavedRun,
285
+ pruneInstanceSaves,
286
+ hasFlag,
287
+ } from '../../world/instances.ts';
288
+ import { entranceForInstance } from '../../data/dungeons/pack-from-clusters.ts';
289
+ import { DIRE_MAUL_WEST_FLAGS } from '../../data/dungeons/dire-maul-west.ts';
290
+ import {
291
+ applyInstanceOnPlayerEnter,
292
+ scriptDevByName,
293
+ INSTANCE_DATA,
294
+ } from '../../domain/scriptdev/index.ts';
295
+ import { TYPE_WARPWOOD } from '../../domain/scriptdev/direMaul.ts';
296
+ import {
297
+ persistScriptInstanceData,
298
+ scriptInstanceDataMap,
299
+ } from './scriptDevGoUse.ts';
300
+ import { canTakeQuestForCondition, lootDropAllowed, questConditionSnapshot } from '../../quests/quests.ts';
301
+ import { auraIdsForSnapshot } from '../spells/conditions.ts';
302
+ import {
303
+ DEFAULT_PULL_FUSION,
304
+ DEFAULT_ROUTE_DENSITY,
305
+ isPullFusion,
306
+ isRouteDensity,
307
+ } from '../../world/dungeonRoute.ts';
308
+ import { dungeonSpawnsForMap } from '../../data/dungeonSpawns.ts';
309
+ import {
310
+ DUNGEON_POOL_ROLL_SALT,
311
+ poolRollFromPersisted,
312
+ rollDungeonPools,
313
+ type DungeonPoolRoll,
314
+ } from '../../world/rareRoll.ts';
315
+ import {
316
+ DUNGEON_GO_POOL_ROLL_SALT,
317
+ rollDungeonGoPools,
318
+ } from '../../world/goPoolRoll.ts';
319
+ import type { BakedPoolRow } from '../../domain/world/pools.ts';
320
+ import type {
321
+ ActionResult,
322
+ BeginFight,
323
+ FightOutcome,
324
+ Game,
325
+ InnCandidate,
326
+ } from './types.ts';
327
+ import type { GameCtx } from './context.ts';
328
+ import { moneyMarked } from '../../domain/economy/money.ts';
329
+
330
+ // Helpers (early — before startFight)
331
+
332
+ export type SquadHelpers = {
333
+ ensureCompanionRotation: (comp: Companion, profile?: RotationProfileId) => Rotation;
334
+ ensureCompanionProfileList: (comp: Companion) => void;
335
+ syncCompanionPetAfterLevel: (comp: Companion, leveledTo: readonly number[]) => void;
336
+ buildSquadBattleParty: (targetEntry?: number) => {
337
+ allySpecs: CombatantSpec[];
338
+ allyActionProviders: (AllyActionProvider | null)[];
339
+ allyProviderSpecs: (ActionProviderSpec | null)[];
340
+ allyPetIndex: (number | null)[];
341
+ };
342
+ doApplyPrePullBuffs: () => { buffs: PlannedBuff[]; handouts: PlannedHandout[] };
343
+ doRefreshPartyWeaponCoats: () => number;
344
+ /** Cast the PallyPower board. `topUpOnly` casts only what is not already up. */
345
+ doApplyPaladinBuffs: (now: number, opts: { topUpOnly: boolean }) => number;
346
+ applyCompanionImbue: (compId: string, itemEntry: number, hand?: ItemRuleHand) => boolean;
347
+ innkeeperHere: () => { entry: number; name: string } | undefined;
348
+ spawnGearFor: (bot: WorldBot) => EquippedMap;
349
+ innCandidates: () => InnCandidate[];
350
+ searchInnCandidates: (filters: BotSearchFilters) => InnCandidate[];
351
+ resolveCandidate: (botId: string) => InnCandidate | undefined;
352
+ findCompanion: (id: string) => Companion | undefined;
353
+ equipOnCompanion: (
354
+ comp: Companion,
355
+ itemEntry: number,
356
+ preferred?: EquipSlot,
357
+ ) => { ok: boolean; reason?: string; slot?: EquipSlot };
358
+ };
359
+
360
+ export type SquadHelpersExtras = {
361
+ // The live unit that owns a companion's weapon while a session is open: buff-up runs BETWEEN pulls
362
+ // with the floor shell still open, so a stone applied there has to reach the ally Fighter.
363
+ liveImbueUnit?: LiveImbueUnit;
364
+ };
365
+
366
+ // Squad battle + inn/hire helpers. Create early (before startFight) so combat can share
367
+ // buildSquadBattleParty / syncCompanionPetAfterLevel.
368
+ export function createSquadHelpers(ctx: GameCtx, extras: SquadHelpersExtras = {}): SquadHelpers {
369
+ const { ds, state, log } = ctx;
370
+
371
+ // Subgroup per party seat - player at 0, companion `i` at `i + 1`. One call, so the combat specs, the
372
+ // pre-pull buff plan and the raid editor cannot disagree about who is in a group.
373
+ const partySubgroups = (squad: readonly Companion[]): number[] => partyRaidSubgroups(
374
+ { cls: state.char.cls, role: state.squadRole ?? 'dps' },
375
+ squad.map((c) => ({ cls: c.cls, role: c.role, key: c.id })),
376
+ state.autopilot?.raidSeating ?? null,
377
+ );
378
+
379
+ // A companion's rotation, lazily seeding and persisting the role-adapted class default when absent
380
+ // or class-mismatched; custom edits are kept. Below: seed its named list from the legacy slots.
381
+ const ensureCompanionProfileList = (comp: Companion): void => {
382
+ if (comp.rotationProfiles?.length) {
383
+ if (!comp.activeRotationProfileId) {
384
+ comp.activeRotationProfileId = comp.rotationProfiles[0]!.id;
385
+ comp.rotation = comp.rotationProfiles[0]!.rotation;
386
+ }
387
+ return;
388
+ }
389
+ ensureCompanionRotation(comp, 'primary');
390
+ const list = migrateLegacyProfiles(comp, {
391
+ primary: comp.role === 'tank' ? 'Tank' : comp.role === 'healer' ? 'Heal' : 'Main',
392
+ dps: 'Damage',
393
+ });
394
+ comp.rotationProfiles = list;
395
+ const wantDps = comp.rotationProfile === 'dps' && list.length > 1;
396
+ const act = wantDps ? list[1]! : list[0]!;
397
+ comp.activeRotationProfileId = act.id;
398
+ comp.rotation = act.rotation;
399
+ };
400
+
401
+ const ensureCompanionRotation = (
402
+ comp: Companion,
403
+ profile: RotationProfileId = companionActiveProfile(comp),
404
+ ): Rotation => {
405
+ const lvl = companionLevel(comp, state.char.level);
406
+ const cur = companionRotationSlot(comp, profile);
407
+ // A dps-slot list is stamped role 'dps'; only the primary slot tracks the squad role.
408
+ const wantRole = profile === 'dps' ? 'dps' : comp.role;
409
+ const roleDrift =
410
+ cur?.autoSig?.role != null
411
+ && cur.autoSig.role !== wantRole
412
+ && !cur.custom;
413
+ // Which pool the stored list came from. A `+ PvP` companion profile was drift against the PvE
414
+ // ladder on every read, so the refresh below rebuilt it as the PvE list.
415
+ const mode = rotationBandOptionsOf(cur);
416
+ let rebuilt = true;
417
+ if (!cur || !isRotationValid(cur, comp.cls) || roleDrift) {
418
+ // Role change or missing seed → pack default for the profile's band (same as player).
419
+ setCompanionRotationSlot(comp, profile, buildCompanionRotation(comp, lvl, ds, profile, mode));
420
+ } else if (!cur.custom && !cur.autoSig?.role
421
+ && (comp.role === 'tank' || comp.role === 'healer')) {
422
+ // Pre-role-stamp save: rebuild once so Arms kits become Protection packs.
423
+ setCompanionRotationSlot(comp, profile, buildCompanionRotation(comp, lvl, ds, profile, mode));
424
+ } else if (!cur.custom && companionRotationSigDiffers(comp, lvl, ds, cur, profile, mode)) {
425
+ // Kit drift: they levelled or respecced since this default was generated, so every trainer rank
426
+ // granted above the seed level was known and never cast. The same test the player gets.
427
+ setCompanionRotationSlot(comp, profile, buildCompanionRotation(comp, lvl, ds, profile, mode));
428
+ } else {
429
+ rebuilt = false;
430
+ }
431
+ // A new rank has to reach the FLOOR too, and bars have no custom flag to protect hand placement,
432
+ // so top up empty slots and swap superseded ranks in place, never reseed.
433
+ if (rebuilt && comp.actionBars && profile === companionActiveProfile(comp)) {
434
+ comp.actionBars = topUpCompanionActionBars(comp, state.char.level, ds);
435
+ }
436
+ // Always re-sync bag consumable rules: hire seeds once, gifts arrive later (M7 potions).
437
+ {
438
+ const char = companionCharacter(comp, lvl, ds);
439
+ const slot = companionRotationSlot(comp, profile)!;
440
+ // Legacy hires still hold the eight raid rows the old builder wrote into every save. They compose
441
+ // per raid now, so a stored one is a dead row that shifts every index the editor prints.
442
+ const pruned = stripStoredRaidRows(slot);
443
+ // The party half of each keep-up, on the same always-re-sync footing as the bag rules: a saved
444
+ // list predates the row, and a stored default only rebuilds on a level or a respec.
445
+ const withParty = ensurePartyBuffRows(pruned, ds);
446
+ // Same footing for a known dispel no row names — Tranq Shot on a list built before the book.
447
+ const withDispel = ensureKnownDispelRows(
448
+ withParty,
449
+ companionSpellbook(comp, lvl, ds),
450
+ ds,
451
+ );
452
+ const withItems = syncCompanionBagItemRules(withDispel, char, comp.bags, ds);
453
+ if (withItems !== slot) setCompanionRotationSlot(comp, profile, withItems);
454
+ }
455
+ // Raid rows ride on the way OUT, never into the slot: 39 of 40 bodies are companions, so
456
+ // skipping this makes the whole raid-scoped set invisible on the floor.
457
+ const composed = withRaidTacticRows(
458
+ companionRotationSlot(comp, profile)!,
459
+ state.instance?.dungeonId,
460
+ companionCharacter(comp, companionLevel(comp, state.char.level), ds),
461
+ companionSpellbook(comp, companionLevel(comp, state.char.level), ds),
462
+ ds,
463
+ raidRowOptionsOf(state),
464
+ );
465
+ // Threat watch, the same knob and rule shape as the player's. It was player-only, and 39 of 40
466
+ // bodies in a raid are companions, so it governed one unit and the raid peeled every boss.
467
+ if (normalizeAutopilotConfig(state.autopilot).threatWatch && comp.role === 'dps') {
468
+ const hasHalt = (['combat', 'recovery'] as const).some((ph) =>
469
+ composed.phases[ph].some(
470
+ (r) => r.action.kind === 'halt_dps' && r.conditions.some((c) => c.metric === 'hasAggro'),
471
+ ),
472
+ );
473
+ if (!hasHalt) {
474
+ return {
475
+ ...composed,
476
+ phases: {
477
+ ...composed.phases,
478
+ combat: [
479
+ {
480
+ id: 'g-comp-threat-watch',
481
+ action: { kind: 'halt_dps' as const },
482
+ conditions: [
483
+ { subject: 'self' as const, metric: 'hasAggro' as const, op: '==' as const, value: 1 },
484
+ ],
485
+ enabled: true,
486
+ },
487
+ ...composed.phases.combat,
488
+ ],
489
+ },
490
+ };
491
+ }
492
+ }
493
+ return composed;
494
+ };
495
+
496
+ /** Level-sync companion pet ranks (and revive) after XP grants. Logs when new ranks appear. */
497
+ const syncCompanionPetAfterLevel = (comp: Companion, leveledTo: readonly number[]): void => {
498
+ const lvl = companionLevel(comp, state.char.level);
499
+ const { ranksGrew } = applyCompanionPetSync(comp, lvl, ds);
500
+ if (ranksGrew && leveledTo.length > 0 && comp.pet) {
501
+ log(`${comp.name}'s ${comp.pet.name} learns new abilities.`);
502
+ }
503
+ // A primary opens at level 5, so the hire kit of a level 1-4 body carried no pick, knife or rod.
504
+ announceCompanionRanks(comp, lvl, ds, log);
505
+ syncCompanionProfessionTools(comp, lvl, ds);
506
+ };
507
+
508
+ // The engine-ready group-fight bundle for the active squad: companions first, index-aligned with the
509
+ // squad, then their pets. An empty squad gives empty arrays so a solo session stays byte-identical.
510
+ const buildSquadBattleParty = (targetEntry?: number): {
511
+ allySpecs: CombatantSpec[];
512
+ allyActionProviders: (AllyActionProvider | null)[];
513
+ /** The plain-data half of each provider, index-aligned, for a Worker to rebuild the same brain. */
514
+ allyProviderSpecs: (ActionProviderSpec | null)[];
515
+ /** Human ally index → pet ally index (for rotation pet subject). */
516
+ allyPetIndex: (number | null)[];
517
+ } => {
518
+ const allySpecs: CombatantSpec[] = [];
519
+ const allyActionProviders: (AllyActionProvider | null)[] = [];
520
+ const allyProviderSpecs: (ActionProviderSpec | null)[] = [];
521
+ const allyPetIndex: (number | null)[] = [];
522
+ const squad = state.squad ?? [];
523
+ // Long CDs ride in from the world clock, same as the player's (combatSession initSpellCds).
524
+ const cdNow = Date.now();
525
+ // Drink, eat, coat from bags NOW so the spec that follows already carries the aura. Buff-up
526
+ // only ran at dungeon zone-in, so a world pull left every shared elixir sitting unused.
527
+ {
528
+ const drunk = applyPlayerItemPrep(state.char, state.inventory, ds, cdNow, log);
529
+ if (drunk.spent) { state.char = drunk.char; state.inventory = drunk.inventory; }
530
+ for (const c of squad) {
531
+ const lvl = companionLevel(c, state.char.level);
532
+ applyCompanionItemPrep(c, ensureCompanionRotation(c), {
533
+ ds, now: cdNow, char: companionCharacter(c, lvl, ds),
534
+ applyImbue: applyCompanionImbue, log,
535
+ });
536
+ }
537
+ }
538
+ // Raid subgroups: the player holds seat 0 and each companion the next. Party targets stop at the
539
+ // caster's subgroup, so this decides who a Prayer of Healing reaches. A 5-man is one group.
540
+ const subgroups = partySubgroups(squad);
541
+ for (let i = 0; i < squad.length; i++) {
542
+ const comp = squad[i]!;
543
+ const lvl = companionLevel(comp, state.char.level);
544
+ // Legacy hires / level-ups: ensure pet kit exists and is full HP between pulls.
545
+ applyCompanionPetSync(comp, lvl, ds);
546
+ // This IS the engage. A rogue stealths here and nowhere earlier — a sneak costs 30-50%
547
+ // movement (aura 33), so stealthing for the walk in would leave it behind the party.
548
+ // NOT seated in the list's opening form (`openerForm`), deliberately: makeFighter applies a
549
+ // seeded form's attack time and not its power bar, so a druid seeded into Cat Form stands
550
+ // there on 2464 mana with no energy and cannot pay for Claw. Measured both ways on one seat:
551
+ // seeded reads powerType mana / maxPower 2464, shifted by casting 768 reads energy / 100.
552
+ // Until that is fixed the pull's own `Cat Form` row is the correct path — it costs one GCD
553
+ // and goes through enterForm. Ledger: capabilities/manaBarBehindTheForm.mjs seatSeededForm*.
554
+ const rot = ensureCompanionRotation(comp);
555
+ const spec = companionSpec(comp, lvl, ds, {
556
+ stealthForPull: true, spellCooldowns: comp.spellCooldowns, now: cdNow,
557
+ });
558
+ // Spend the cooldown the entry cost, so initialSpellCooldowns below carries it into the
559
+ // fight and a chain-pull inside 10 s finds it hot. Cite: spell_template 1784-1787.
560
+ const sneakId = spec.stealth?.spellId ?? 0;
561
+ if (sneakId) {
562
+ const sneakCd = ds.spell(sneakId)?.cooldownMs ?? 0;
563
+ if (sneakCd > 0) (comp.spellCooldowns ??= {})[sneakId] = cdNow + sneakCd;
564
+ }
565
+ // Companion.role 'dps' is the engine's 'damage' groupRole (S0 role AI + views).
566
+ spec.groupRole = comp.role === 'dps' ? 'damage' : comp.role;
567
+ spec.subgroup = subgroups[i + 1] ?? 0;
568
+ spec.initialSpellCooldowns = sessionSeedFromWorldCooldowns(comp.spellCooldowns, cdNow);
569
+ spec.initialCategoryCooldowns = sessionSeedFromWorldCooldowns(comp.categoryCooldowns, cdNow);
570
+ allySpecs.push(spec);
571
+ // Built from PLAIN DATA, not a live companion ref (S6b): the mid-fight knob toggles that ref
572
+ // existed for now arrive per tick as `SimTickInputs.companionKnobs`, so this is portable.
573
+ const providerSpec: ActionProviderSpec = {
574
+ rotation: rot, ...(targetEntry != null ? { targetEntry } : {}),
575
+ };
576
+ allyActionProviders.push(buildActionProvider(providerSpec, ds));
577
+ allyProviderSpecs.push(providerSpec);
578
+ allyPetIndex.push(null); // filled after pets are appended
579
+ }
580
+ // Companion pets after the squad so allyIndex 0..n-1 still maps to companions.
581
+ for (let i = 0; i < squad.length; i++) {
582
+ const comp = squad[i]!;
583
+ const pet = companionPetSpec(comp, companionLevel(comp, state.char.level), ds);
584
+ if (!pet) continue;
585
+ // A pet is not a Group member but rides its owner's subgroup (FillRaidOrPartyTargets
586
+ // takes each in-group player's pet with it). Cite: Spell.cpp:7345–7350.
587
+ pet.subgroup = allySpecs[i]?.subgroup;
588
+ pet.initialSpellCooldowns = sessionSeedFromWorldCooldowns(comp.pet?.spellCooldowns, cdNow);
589
+ pet.initialCategoryCooldowns = sessionSeedFromWorldCooldowns(comp.pet?.categoryCooldowns, cdNow);
590
+ allyPetIndex[i] = allySpecs.length;
591
+ allySpecs.push(pet);
592
+ // No edited rotation: S0 damage heuristics cast the pet's known spells (Firebolt, etc.).
593
+ allyActionProviders.push(null);
594
+ allyProviderSpecs.push(null);
595
+ }
596
+ return { allySpecs, allyActionProviders, allyProviderSpecs, allyPetIndex };
597
+ };
598
+
599
+ // Coat one companion's weapon from THEIR OWN bags: the bound router debits the player, which is
600
+ // wrong here, so call the unbound one with their bags and a setter that writes them back.
601
+ const applyCompanionImbue = (
602
+ compId: string,
603
+ itemEntry: number,
604
+ hand?: ItemRuleHand,
605
+ ): boolean => {
606
+ const comp = (state.squad ?? []).find((c) => c.id === compId);
607
+ if (!comp) return false;
608
+ const res = routeWeaponImbueItem(
609
+ itemEntry,
610
+ { target: { kind: 'companion', id: compId }, ...(hand != null ? { slot: hand } : {}) },
611
+ {
612
+ ds,
613
+ char: state.char,
614
+ inventory: comp.bags ?? emptyInventory(),
615
+ // Their vial, their weapon: the wearer is the caster, so ownItemOnly does not refuse it.
616
+ byWearer: true,
617
+ squad: state.squad,
618
+ setPlayer: () => {},
619
+ setCompanion: (id, patch, inventory) => {
620
+ const c = (state.squad ?? []).find((x) => x.id === id);
621
+ if (!c) return;
622
+ c.equipped = patch.equipped;
623
+ c.tempEnchants = patch.tempEnchants;
624
+ c.bags = inventory;
625
+ },
626
+ log,
627
+ ...(extras.liveImbueUnit ? { liveUnit: extras.liveImbueUnit } : {}),
628
+ },
629
+ );
630
+ return !!res?.ok;
631
+ };
632
+
633
+ // Item prep between pulls: coats plus elixirs and Well Fed. Zone-in was the only full
634
+ // caller, so a shared flask sat unused after the first pack.
635
+ const doRefreshPartyWeaponCoats = (): number => {
636
+ const now = Date.now();
637
+ const drunk = applyPlayerItemPrep(state.char, state.inventory, ds, now, log);
638
+ if (drunk.spent) { state.char = drunk.char; state.inventory = drunk.inventory; }
639
+ let n = drunk.spent;
640
+ for (const c of state.squad ?? []) {
641
+ const lvl = companionLevel(c, state.char.level);
642
+ n += applyCompanionItemPrep(c, ensureCompanionRotation(c), {
643
+ ds,
644
+ now,
645
+ char: companionCharacter(c, lvl, ds),
646
+ applyImbue: applyCompanionImbue,
647
+ log,
648
+ });
649
+ }
650
+ return n;
651
+ };
652
+
653
+ /**
654
+ * Cast the party's Blessings and Auras from the PallyPower board, and point every paladin's
655
+ * buffs phase at what it was given.
656
+ *
657
+ * `topUpOnly` is the swap path: it casts ONLY what a body is not already carrying from that
658
+ * paladin, so editing the grid takes effect at once without re-charging a Symbol of Kings for a
659
+ * Greater that is already up. The pull path re-casts the lot, which is what it always did.
660
+ */
661
+ const doApplyPaladinBuffs = (now: number, opts: { topUpOnly: boolean }): number => {
662
+ const squad = state.squad ?? [];
663
+ const members: PaladinPartyMember[] = paladinPartyMembers(state, ds);
664
+ const board = normalizePaladinAssignments(state.paladinAssignments);
665
+ const pal = planPartyPaladinBuffs(members, ds, now, board);
666
+ if (!pal.byKey.size) return 0;
667
+
668
+ // What each body already holds from each paladin, so a top-up can tell a cast from a no-op.
669
+ const carried = (bodyKey: string): readonly ActiveAura[] => {
670
+ if (bodyKey === 'player') return state.char.activeAuras ?? [];
671
+ if (bodyKey === 'player:pet') return state.char.pet?.prePullAuras ?? [];
672
+ const [ownerId, isPet] = bodyKey.endsWith(':pet')
673
+ ? [bodyKey.slice(0, -4), true] as const
674
+ : [bodyKey, false] as const;
675
+ const c = squad.find((x) => x.id === ownerId);
676
+ if (!c) return [];
677
+ return (isPet ? c.pet?.prePullAuras : c.prePullAuras) ?? [];
678
+ };
679
+ const alreadyUp = (bodyKey: string, chip: ActiveAura): boolean => carried(bodyKey).some(
680
+ (a) => a.spellId === chip.spellId && (a.fromCaster ?? null) === (chip.fromCaster ?? null),
681
+ );
682
+
683
+ let cast = 0;
684
+ const byKey = new Map<string, ActiveAura[]>();
685
+ for (const [bodyKey, chips] of pal.byKey) {
686
+ const want = opts.topUpOnly ? chips.filter((c) => !alreadyUp(bodyKey, c)) : chips;
687
+ if (want.length) byKey.set(bodyKey, want);
688
+ cast += want.length;
689
+ }
690
+ if (!cast) return 0;
691
+
692
+ // A Greater Blessing eats its Symbol of Kings, same as any other cast reagent. Without this
693
+ // the 15 min ranks were free forever. Cite: Spell.cpp TakeReagents ~4332-4350. A top-up only
694
+ // pays for the (paladin, family, class) casts it actually made.
695
+ const paidFor = new Set<string>();
696
+ for (const b of pal.blessings) {
697
+ if (!b.greater) continue;
698
+ if (!byKey.get(b.on)?.some((c) => c.spellId === b.spellId)) continue;
699
+ const on = members.find((m) => m.key === b.on || m.pet?.key === b.on);
700
+ paidFor.add(`${b.from}|${b.family}|${on?.pet?.key === b.on ? 'pet' : on?.cls ?? 0}`);
701
+ }
702
+ for (const r of pal.reagents) {
703
+ const owed = opts.topUpOnly
704
+ ? [...paidFor].filter((k) => k.startsWith(`${r.from}|`)).length
705
+ : r.count;
706
+ if (owed <= 0) continue;
707
+ if (r.from === 'player') {
708
+ state.inventory = removeItem(state.inventory, r.item, Math.min(owed, r.count))[0];
709
+ continue;
710
+ }
711
+ const c = squad.find((x) => x.id === r.from);
712
+ if (c) c.bags = removeItem(c.bags ?? emptyInventory(), r.item, Math.min(owed, r.count))[0];
713
+ }
714
+
715
+ for (const chip of byKey.get('player') ?? []) {
716
+ state.char = applyAura(
717
+ state.char, chip.spellId,
718
+ chip.expiresAt != null ? chip.expiresAt - now : 0,
719
+ now, ds, chip.fromCaster != null ? { fromCaster: chip.fromCaster } : undefined,
720
+ );
721
+ }
722
+ if (state.char.pet && state.char.pet.health > 0) {
723
+ state.char.pet.prePullAuras = mergePaladinChips(
724
+ state.char.pet.prePullAuras, byKey.get('player:pet'), ds,
725
+ );
726
+ }
727
+ for (const c of squad) {
728
+ c.prePullAuras = mergePaladinChips(c.prePullAuras, byKey.get(c.id), ds);
729
+ if (c.pet && c.pet.health > 0) {
730
+ c.pet.prePullAuras = mergePaladinChips(c.pet.prePullAuras, byKey.get(`${c.id}:pet`), ds);
731
+ }
732
+ }
733
+ // The live rotation must agree with the plan, or the paladin the plan put on Devotion recasts
734
+ // its packaged Retribution Aura on its first out-of-combat tick — and a paladin handing out two
735
+ // families needs a keep-up row for BOTH or the second one silently expires mid-run.
736
+ for (const c of squad) {
737
+ if (c.cls !== CLASS.PALADIN) continue;
738
+ const rot = ensureCompanionRotation(c);
739
+ const next = restampPaladinRotation(rot, pal, c.id, ds);
740
+ if (next !== rot) setCompanionRotationSlot(c, companionActiveProfile(c), next);
741
+ }
742
+ // A player paladin keeps its own party up too. Written to the active profile AND the mirror,
743
+ // the pair every other in-place rotation edit writes. Cite: rotation.ts:310-313.
744
+ if (state.char.cls === CLASS.PALADIN && state.rotation) {
745
+ const next = restampPaladinRotation(state.rotation, pal, 'player', ds);
746
+ if (next !== state.rotation) {
747
+ const i = (state.rotationProfiles ?? []).findIndex(
748
+ (p) => p.id === state.activeRotationProfileId,
749
+ );
750
+ if (i >= 0) state.rotationProfiles![i] = { ...state.rotationProfiles![i]!, rotation: next };
751
+ state.rotation = next;
752
+ }
753
+ }
754
+ for (const b of pal.blessings) {
755
+ if (!byKey.get(b.on)?.some((c) => c.spellId === b.spellId)) continue;
756
+ const sp = ds.spell(b.spellId);
757
+ const on = members.find((m) => m.key === b.on || m.pet?.key === b.on);
758
+ const from = members.find((m) => m.key === b.from);
759
+ if (sp && on && from) log(`${from.name} casts ${sp.name} on ${on.name}`);
760
+ }
761
+ return cast;
762
+ };
763
+
764
+ // "Buff up" before a pull (item 6). Local so both the public method and runFullDungeon reuse it.
765
+ const doApplyPrePullBuffs = (): { buffs: PlannedBuff[]; handouts: PlannedHandout[] } => {
766
+ const now = Date.now();
767
+ const squad = state.squad ?? [];
768
+ // Providers: you + each companion. Single-target buffs (Fortitude, Blessing of Might) do
769
+ // land on everyone; the party-scoped ones only reach their caster's own subgroup.
770
+ const casters: PartyCaster[] = [
771
+ { name: state.char.name, spellbook: state.spellbook },
772
+ ...squad.map((c) => ({
773
+ name: c.name,
774
+ spellbook: companionSpellbook(c, companionLevel(c, state.char.level), ds),
775
+ })),
776
+ ];
777
+ const plan = planPrePull(casters, ds);
778
+
779
+ // Same seating as buildSquadBattleParty — one plan, or the buff a companion carries in is
780
+ // not the buff their group's casters can supply once the fight starts.
781
+ const subgroups = partySubgroups(squad);
782
+ const isPartyScoped = (b: PlannedBuff): boolean => {
783
+ const sp = ds.spell(b.spellId);
784
+ if (!sp) return false;
785
+ const applyTo = sp.effects
786
+ .map((e) => (e as { applyTo?: HealApplyTo }).applyTo)
787
+ .find((a) => a != null);
788
+ return fanStopsAtSubgroup(sp, applyTo);
789
+ };
790
+ // Single-target and greater blessings are cast on anyone by anyone, so the raid-wide plan stands.
791
+ // A party-scoped buff comes from its OWN subgroup's casters — 4 of 12 planned buffs are scoped.
792
+ const openBuffs = plan.buffs.filter((b) => !isPartyScoped(b));
793
+ const castersByGroup: PartyCaster[][] = [];
794
+ casters.forEach((c, seat) => {
795
+ const g = subgroups[seat] ?? 0;
796
+ (castersByGroup[g] ??= []).push(c);
797
+ });
798
+ const groupBuffs = castersByGroup.map((cs) => planPrePull(cs, ds).buffs.filter(isPartyScoped));
799
+ const buffsFor = (group: number): PlannedBuff[] => [...openBuffs, ...(groupBuffs[group] ?? [])];
800
+
801
+ // Item prep first so a drunk elixir cannot exclusivity-drop a party aura applied next.
802
+ {
803
+ const drunk = applyPlayerItemPrep(state.char, state.inventory, ds, now, log);
804
+ if (drunk.spent) { state.char = drunk.char; state.inventory = drunk.inventory; }
805
+ }
806
+ // Apply your own set (applyAura refreshes any rank you already had from prepOoc).
807
+ for (const b of buffsFor(subgroups[0] ?? 0)) state.char = applyAura(state.char, b.spellId, b.durationMs, now, ds);
808
+ // Companions carry theirs as transient auras (one per family → no stacking to dedup).
809
+ const auraRow = (b: PlannedBuff) => ({
810
+ spellId: b.spellId,
811
+ ...(b.durationMs > 0 ? { expiresAt: now + b.durationMs } : {}),
812
+ });
813
+ squad.forEach((c, i) => {
814
+ c.prePullAuras = buffsFor(subgroups[i + 1] ?? 0).map(auraRow);
815
+ });
816
+ // A pet rides its owner's subgroup and takes the owner's list with it.
817
+ // Cite: Spell.cpp:7345-7350 FillRaidOrPartyTargets withPets.
818
+ if (state.char.pet && state.char.pet.health > 0) {
819
+ state.char.pet.prePullAuras = buffsFor(subgroups[0] ?? 0).map(auraRow);
820
+ }
821
+ squad.forEach((c, i) => {
822
+ if (c.pet && c.pet.health > 0) c.pet.prePullAuras = buffsFor(subgroups[i + 1] ?? 0).map(auraRow);
823
+ });
824
+
825
+ // Paladin Blessings and Auras. One Blessing per paladin per body (spell_group 7 is
826
+ // UNIQUE_PER_CASTER), chosen off the target's class and role, and one DISTINCT aura per
827
+ // paladin. Cite: PlayerbotPaladinAI.cpp:412-450 CheckAuras, :643-716 BuffHelper.
828
+ doApplyPaladinBuffs(now, { topUpOnly: false });
829
+
830
+ // Rotation buffs-phase keep-ups: the pre-pull plan only covers party-wide buffs, so a self toggle
831
+ // like Righteous Fury never reached it and the tank pulled without its holy threat.
832
+ for (const c of squad) {
833
+ const rot = ensureCompanionRotation(c);
834
+ for (const r of rot.phases.buffs) {
835
+ if (!r.enabled || r.action.kind !== 'spell') continue;
836
+ const sp = ds.spell(r.action.spellId);
837
+ if (!sp || !isCompanionKeepUpSpell(sp)) continue;
838
+ if (c.prePullAuras!.some((a) => a.spellId === sp.id)) continue;
839
+ // Its own exclusive group is already filled by the pre-pull plan — a second Blessing or
840
+ // Aura from the SAME caster only replaces the first. Cite: Unit.cpp:5021.
841
+ const grp = sp.exclusionGroup ?? sp.spellGroupId ?? 0;
842
+ if (grp > 0 && c.prePullAuras!.some(
843
+ (a) => a.fromCaster == null
844
+ && ((ds.spell(a.spellId)?.exclusionGroup ?? ds.spell(a.spellId)?.spellGroupId ?? 0) === grp),
845
+ )) continue;
846
+ const durMs = keepUpAuraDurationMs(sp);
847
+ c.prePullAuras!.push({ spellId: sp.id, ...(durMs > 0 ? { expiresAt: now + durMs } : {}) });
848
+ }
849
+ // Buffs-phase item rules (weapon imbues, scrolls) then the bag-elixir sweep.
850
+ const lvl = companionLevel(c, state.char.level);
851
+ applyCompanionItemPrep(c, rot, {
852
+ ds,
853
+ now,
854
+ char: companionCharacter(c, lvl, ds),
855
+ applyImbue: applyCompanionImbue,
856
+ log,
857
+ });
858
+ }
859
+
860
+ // The warlock stones the healer: a stoned healer stands themselves up where they fell and
861
+ // resurrects the party. Cite: Player.cpp:18675-18713 — the soulstone outranks every other rez.
862
+ {
863
+ const stoneTargets = [
864
+ ...squad
865
+ .map((c, i) => ({ comp: c, seat: i }))
866
+ .filter((e) => e.comp.role === 'healer')
867
+ .map((e) => ({
868
+ name: e.comp.name,
869
+ auraSpellIds: (e.comp.prePullAuras ?? []).map((a) => a.spellId),
870
+ apply: (spellId: number) => {
871
+ (e.comp.prePullAuras ??= []).push({ spellId });
872
+ },
873
+ })),
874
+ ...(state.squadRole === 'healer'
875
+ ? [{
876
+ name: state.char.name,
877
+ auraSpellIds: (state.char.activeAuras ?? []).map((a) => a.spellId),
878
+ // Until cancelled: a soulstone has a 15 min timer in 1.12, and durationMs 0
879
+ // from applyAura is the persistent form. The run is shorter than the timer.
880
+ apply: (spellId: number) => { state.char = applyAura(state.char, spellId, 0, now, ds); },
881
+ }]
882
+ : []),
883
+ ];
884
+ const suppliers = [
885
+ ...squad
886
+ .filter((c) => c.cls === CLASS.WARLOCK)
887
+ .map((c) => ({
888
+ name: c.name,
889
+ level: companionLevel(c, state.char.level),
890
+ knownSpellIds: companionSpellbook(c, companionLevel(c, state.char.level), ds).known,
891
+ bag: (c.bags ?? emptyInventory()).stacks.map((s) => ({ item: s.item, count: s.count })),
892
+ spend: (item: number, n: number) => { c.bags = removeItem(c.bags ?? emptyInventory(), item, n)[0]; },
893
+ })),
894
+ ...(state.char.cls === CLASS.WARLOCK
895
+ ? [{
896
+ name: state.char.name,
897
+ level: state.char.level,
898
+ knownSpellIds: state.spellbook.known,
899
+ bag: state.inventory.stacks.map((s) => ({ item: s.item, count: s.count })),
900
+ spend: (item: number, n: number) => { state.inventory = removeItem(state.inventory, item, n)[0]; },
901
+ }]
902
+ : []),
903
+ ];
904
+ const stone = planSoulstone({
905
+ suppliers,
906
+ targets: stoneTargets,
907
+ itemOf: (e) => ds.item(e),
908
+ spellOf: (id) => ds.spell(id),
909
+ });
910
+ if (stone) {
911
+ const supplier = suppliers.find((s) => s.name === stone.warlockName);
912
+ // Conjuring one costs a shard; an already-carried stone costs the stone.
913
+ if (stone.soulShards > 0) supplier?.spend(SOUL_SHARD_ITEM, stone.soulShards);
914
+ else supplier?.spend(stone.item, 1);
915
+ stoneTargets.find((t) => t.name === stone.targetName)?.apply(stone.auraSpellId);
916
+ log(`${stone.warlockName} puts a soulstone on ${stone.targetName}.`);
917
+ }
918
+ }
919
+
920
+ // Healthstones — one per member, tanks and melee first, one Soul Shard each. Runs AFTER the soulstone
921
+ // so the wipe insurance gets the first shard out of the bag.
922
+ const stoneHandouts: PlannedHandout[] = [];
923
+ {
924
+ const holders = [
925
+ stoneHolder({
926
+ name: state.char.name,
927
+ char: state.char,
928
+ role: state.squadRole ?? 'dps',
929
+ specName: state.rotation?.autoSig?.spec ?? null,
930
+ knownSpellIds: state.spellbook.known,
931
+ auraSpellIds: (state.char.activeAuras ?? []).map((a) => a.spellId),
932
+ read: () => state.inventory,
933
+ write: (inv) => { state.inventory = inv; },
934
+ }),
935
+ ...squad.map((c) => {
936
+ const lvl = companionLevel(c, state.char.level);
937
+ return stoneHolder({
938
+ name: c.name,
939
+ char: companionCharacter(c, lvl, ds),
940
+ role: c.role,
941
+ specName: c.rotation?.autoSig?.spec ?? ds.botSpecs(c.cls)[c.specIdx ?? 0]?.name ?? null,
942
+ knownSpellIds: companionSpellbook(c, lvl, ds).known,
943
+ auraSpellIds: (c.prePullAuras ?? []).map((a) => a.spellId),
944
+ read: () => c.bags,
945
+ write: (inv) => { c.bags = inv; },
946
+ });
947
+ }),
948
+ ];
949
+ const stones = runPrePullHealthstones(holders, ds, state.char.name);
950
+ for (const line of healthstoneRunLog(stones, ds, state.char.name)) log(line);
951
+ // Your own stone rides the "Handed to you" line with the conjured food and water; a stone
952
+ // that never shows there is one the player does not know they are carrying.
953
+ const mineStone = stones.handouts.find((h) => h.targetName === state.char.name);
954
+ if (mineStone) {
955
+ stoneHandouts.push({ item: mineStone.item, count: 1, from: mineStone.warlockName });
956
+ }
957
+ }
958
+
959
+ // Conjured provisions, ONE stack of each per party member so a re-buff cannot flood a bag. The
960
+ // mage used to conjure for YOU alone, so every companion walked the run on its spawn stack.
961
+ const conjurable: ConjurableProvision[] = [];
962
+ const providerOf = new Map<number, string>();
963
+ for (const c of casters) {
964
+ for (const p of conjurableProvisions(c.spellbook.known, ds)) {
965
+ if (providerOf.has(p.item)) continue;
966
+ providerOf.set(p.item, c.name);
967
+ conjurable.push(p);
968
+ }
969
+ }
970
+ conjurable.sort((a, b) => b.quality - a.quality);
971
+ const stockProvisions = (
972
+ inv: Inventory,
973
+ who: Character,
974
+ ): { inv: Inventory; got: PlannedHandout[] } => {
975
+ const got: PlannedHandout[] = [];
976
+ let out = inv;
977
+ const want = usableProvisionsFor(conjurable, who, ds);
978
+ for (const item of [want.food, want.drink]) {
979
+ if (item == null) continue;
980
+ const maxStack = Math.max(1, ds.item(item)?.stackable ?? 1);
981
+ const have = countItem(out, item);
982
+ if (have >= maxStack) continue; // already a full stack of this provision
983
+ const count = maxStack - have;
984
+ if (!canFitWithBagFamily(out, who, ds, item, count, maxStack)) continue;
985
+ out = addItem(out, item, count, maxStack);
986
+ got.push({ item, count, from: providerOf.get(item) ?? '' });
987
+ }
988
+ return { inv: out, got };
989
+ };
990
+
991
+ const mine = stockProvisions(state.inventory, state.char);
992
+ state.inventory = mine.inv;
993
+ const handouts = [...stoneHandouts, ...mine.got];
994
+ let fedCompanions = 0;
995
+ for (const c of squad) {
996
+ const filled = stockProvisions(
997
+ c.bags ?? emptyInventory(),
998
+ companionCharacter(c, companionLevel(c, state.char.level), ds),
999
+ );
1000
+ if (!filled.got.length) continue;
1001
+ c.bags = filled.inv;
1002
+ fedCompanions += 1;
1003
+ }
1004
+ if (plan.buffs.length) log(`Buffed up: ${plan.buffs.map((b) => b.name).join(', ')}.`);
1005
+ // The stone already has its own line from healthstoneRunLog — it only rides `handouts` so
1006
+ // the UI's "Handed to you" strip names it.
1007
+ for (const h of mine.got) {
1008
+ const idef = ds.item(h.item);
1009
+ log(`${h.from} conjures ${h.count}× ${idef?.name ?? `item ${h.item}`} for you.`);
1010
+ }
1011
+ if (fedCompanions) log(`${fedCompanions} companions get conjured provisions.`);
1012
+ // A buff-up stamps only what the units present can hold (partyAuras presence rule).
1013
+ reconcileGamePartyAuras(state, ds);
1014
+ return { buffs: plan.buffs, handouts };
1015
+ };
1016
+
1017
+ // The innkeeper at the current zone, with a documented fail-open: before the bake stamps innkeepers,
1018
+ // a settlement with vendors or trainers offers a synthetic one so hiring stays playable.
1019
+ const innkeeperHere = (): { entry: number; name: string } | undefined => {
1020
+ if (state.instance) return undefined;
1021
+ const zone = ds.zone(state.char.zoneId);
1022
+ if (!zone) return undefined;
1023
+ const inns = zone.innkeepers ?? [];
1024
+ // Prefer faction-interactable innkeeper (KI-669 multi-inn contested zones).
1025
+ // Cite: domain/creature/faction.ts canInteractWithCreature; Unit::CanInteract.
1026
+ if (inns.length > 0) {
1027
+ const race = state.char.race;
1028
+ const friendly = inns.find((inn) => canInteractWithCreature(ds, race, inn.entry));
1029
+ return friendly;
1030
+ }
1031
+ // Fail open only when the whole bundle lacks innkeeper data (pre-ETL).
1032
+ const anyBaked = ds.zones().some((z) => (z.innkeepers?.length ?? 0) > 0);
1033
+ if (anyBaked) return undefined;
1034
+ const settlement = (zone.vendors?.length ?? 0) > 0 || (zone.trainers?.length ?? 0) > 0;
1035
+ if (!settlement) return undefined;
1036
+ return { entry: 0, name: 'Innkeeper' };
1037
+ };
1038
+
1039
+ // Spawn-gear previews are deterministic per bot + kit profile — cache per game instance. ICH1/ICH3:
1040
+ // profile from pickProfile + slate mix (never free epic L60 outside raid-*).
1041
+ const spawnGearCache = new Map<string, EquippedMap>();
1042
+ const spawnGearFor = (
1043
+ bot: WorldBot,
1044
+ profile: GearProfile = DEFAULT_HIRE_GEAR_PROFILE,
1045
+ ): EquippedMap => {
1046
+ const cacheKey = `${bot.id}|${profile}`;
1047
+ let g = spawnGearCache.get(cacheKey);
1048
+ if (!g) {
1049
+ const scratch: Companion = {
1050
+ id: bot.id, name: bot.name, race: bot.race, cls: bot.cls, role: 'dps',
1051
+ seed: bot.seed, equipped: {}, level: bot.level, specIdx: bot.specIdx,
1052
+ gearProfile: profile,
1053
+ };
1054
+ const botSpec = ds.botSpecs(bot.cls)[bot.specIdx];
1055
+ g = generateSpawnGear(companionCharacter(scratch, bot.level, ds), bot.seed, ds, profile, {
1056
+ specIdx: bot.specIdx,
1057
+ role: botSpec?.role,
1058
+ });
1059
+ spawnGearCache.set(cacheKey, g);
1060
+ }
1061
+ return g;
1062
+ };
1063
+
1064
+ const buildCandidate = (bot: WorldBot, gearProfile: GearProfile): InnCandidate => {
1065
+ const spec = ds.botSpecs(bot.cls)[bot.specIdx];
1066
+ const gear = spawnGearFor(bot, gearProfile);
1067
+ return {
1068
+ bot,
1069
+ ...(spec ? { specName: spec.name, specRole: spec.role } : {}),
1070
+ gear,
1071
+ gearProfile,
1072
+ hireCostCopper: hireCostCopper(bot.level, gearProfile),
1073
+ gearScore: displayGearScore(gear, ds),
1074
+ };
1075
+ };
1076
+
1077
+ // One hireable face by bot id, slate or not. Scanning the slate made anything found by the world-pool
1078
+ // search read as no longer available — you could see a hire you could not take.
1079
+ const resolveCandidate = (botId: string): InnCandidate | undefined => {
1080
+ const fromSlate = innCandidates().find((c) => c.bot.id === botId);
1081
+ if (fromSlate) return fromSlate;
1082
+ const bot = worldBot(ds, botId);
1083
+ if (!bot) return undefined;
1084
+ if (bot.faction !== factionOf(ds, state.char.race)) return undefined;
1085
+ if ((state.squad ?? []).some((c) => c.id === botId)) return undefined;
1086
+ if (state.botRoster?.[botId]) return undefined;
1087
+ const playerLevel = state.char.level;
1088
+ const zoneSalt = innZoneSalt(state.char.zoneId, playerLevel);
1089
+ return buildCandidate(bot, pickProfile(bot.id, zoneSalt, playerLevel));
1090
+ };
1091
+
1092
+ // The slate is deterministic in inn, zone, level and who is taken, and every face costs a character
1093
+ // build plus a gear walk. Both panels repaint on it, so memo the slate rather than re-price 16 bodies.
1094
+ let slateMemo: { key: string; out: InnCandidate[] } | null = null;
1095
+
1096
+ const innCandidates = (): InnCandidate[] => {
1097
+ const inn = innkeeperHere();
1098
+ if (!inn) return [];
1099
+ const taken = [
1100
+ ...(state.squad ?? []).map((c) => c.id),
1101
+ ...Object.keys(state.botRoster ?? {}),
1102
+ ];
1103
+ const playerLevel = state.char.level;
1104
+ const key = `${inn.entry}|${state.char.zoneId}|${playerLevel}|${[...taken].sort().join(',')}`;
1105
+ if (slateMemo?.key === key) return slateMemo.out.slice();
1106
+ const zoneSalt = innZoneSalt(state.char.zoneId, playerLevel);
1107
+ const bots = innSlate({
1108
+ ds,
1109
+ playerRace: state.char.race,
1110
+ playerLevel,
1111
+ zoneId: state.char.zoneId,
1112
+ excludeIds: taken,
1113
+ });
1114
+ // ICH3: per-face profile roll + slate stratification (cheap face; no all-raid; mid no raid).
1115
+ const profiles = ensureSlateProfileMix(
1116
+ bots.map((bot) => pickProfile(bot.id, zoneSalt, playerLevel)),
1117
+ zoneSalt,
1118
+ playerLevel,
1119
+ );
1120
+ const out = bots.map((bot, i) => buildCandidate(bot, profiles[i] ?? DEFAULT_HIRE_GEAR_PROFILE));
1121
+ slateMemo = { key, out };
1122
+ return out.slice();
1123
+ };
1124
+
1125
+ // The whole-population hire search, filter-first by contract: with nothing narrowed it returns empty,
1126
+ // because 2500 rendered faces is not a shortlist. Unlike the slate it has no level window.
1127
+ const searchInnCandidates = (filters: BotSearchFilters): InnCandidate[] => {
1128
+ if (!innkeeperHere()) return [];
1129
+ const taken = [
1130
+ ...(state.squad ?? []).map((c) => c.id),
1131
+ ...Object.keys(state.botRoster ?? {}),
1132
+ ];
1133
+ const playerLevel = state.char.level;
1134
+ const zoneSalt = innZoneSalt(state.char.zoneId, playerLevel);
1135
+ const bots = searchWorldBots({
1136
+ ds,
1137
+ playerRace: state.char.race,
1138
+ playerLevel,
1139
+ zoneId: state.char.zoneId,
1140
+ excludeIds: taken,
1141
+ filters,
1142
+ });
1143
+ return bots.map((bot) => buildCandidate(bot, pickProfile(bot.id, zoneSalt, playerLevel)));
1144
+ };
1145
+
1146
+ // Squad, then the bench, then the dev raid bench: spec, role, gear and bar edits work for all three.
1147
+ // Without the last lookup all 39 raid members answered null and could not be inspected before entry.
1148
+ const findCompanion = (id: string): Companion | undefined =>
1149
+ state.squad?.find((c) => c.id === id)
1150
+ ?? state.botRoster?.[id]
1151
+ ?? state.raidRoster?.members?.[id];
1152
+
1153
+ // Ownership-correct companion equip: source the bot's own bags first, else the player's. Every
1154
+ // displaced piece goes to the BOT's bags, then their bank — never back to the player.
1155
+ const equipOnCompanion = (comp: Companion, itemEntry: number, preferred?: EquipSlot): { ok: boolean; reason?: string; slot?: EquipSlot } => {
1156
+ const lvl = companionLevel(comp, state.char.level);
1157
+ const char = companionCharacter(comp, lvl, ds);
1158
+ const botBags = comp.bags ?? emptyInventory();
1159
+ const fromBot = countItem(botBags, itemEntry) > 0;
1160
+ const source = fromBot ? botBags : state.inventory;
1161
+ // Companion kits know Dual Wield 674 via companionSpellbook at trainer reqLevel.
1162
+ // Cite: Player.cpp CanDualWield; spell 674.
1163
+ const sb = companionSpellbook(comp, lvl, ds);
1164
+ // Sourcing from the player would re-home displaced gear into the PLAYER's inventory and measure it
1165
+ // against the bot's capacity, a false "not enough room". We re-home below, so skip that check.
1166
+ const opts: EquipOpts = {
1167
+ canDualWield: knows(sb, DUAL_WIELD_SPELL_ID),
1168
+ skipBagCapacityCheck: !fromBot,
1169
+ };
1170
+ const r = equipItem(char, source, ds, itemEntry, preferred, opts);
1171
+ if (!r.ok) return { ok: false, reason: r.reason ?? 'Cannot be equipped' };
1172
+ // Pieces that left the paperdoll (swap target, two-hand clearing the offhand). Wave Q P1:
1173
+ // re-home the displaced instances into the bot's bags/bank with their identity intact.
1174
+ const before = comp.equipped;
1175
+ const after = r.char.equipped;
1176
+ const displaced: ItemInstance[] = [];
1177
+ for (const [slot, v] of Object.entries(before) as Array<[EquipSlot, EquippedValue | undefined]>) {
1178
+ const beforeEntry = equippedEntry(v);
1179
+ if (beforeEntry != null && equippedEntry(after[slot]) !== beforeEntry) {
1180
+ displaced.push(equippedInstance(v)!);
1181
+ }
1182
+ }
1183
+ // fromBot: equipItem already did take+displace into r.inventory against bot bagCapacity.
1184
+ // Rebuild from source so we can spill overflow into the companion bank the same way.
1185
+ let bags = fromBot ? removeItem(botBags, itemEntry, 1)[0] : botBags;
1186
+ let bank = comp.bank ?? emptyInventory();
1187
+ const botCap = bagCapacity(r.char, ds);
1188
+ const bCap = bankCapacity(comp.bankBags ?? [], ds);
1189
+ const queued: ItemInstance[] = [];
1190
+ for (const inst of displaced) {
1191
+ const idef = ds.item(inst.item);
1192
+ if (!idef) continue;
1193
+ const maxStack = idef.stackable ?? 1;
1194
+ if (canFit(bags, inst.item, inst.count, maxStack, botCap)) {
1195
+ bags = addInstance(bags, inst, maxStack);
1196
+ } else if (canFit(bank, inst.item, inst.count, maxStack, bCap)) {
1197
+ bank = addInstance(bank, inst, maxStack);
1198
+ } else {
1199
+ // Nowhere on the body: the upgrade goes on and the old piece takes the reward's own queue.
1200
+ queued.push(inst);
1201
+ }
1202
+ }
1203
+ // Shrinking a bag can leave existing stacks over the new capacity even when the
1204
+ // displaced bag itself went to the bank — refuse rather than overflow.
1205
+ if (usedSlots(bags) > botCap) {
1206
+ return { ok: false, reason: 'Inventory full' };
1207
+ }
1208
+ comp.equipped = { ...after };
1209
+ // equipItem clears TEMP_ENCHANTMENT_SLOT on the displaced hand — mirror onto Companion.
1210
+ // Cite: equipment.ts W5 D1; W5 gap 1 companion unequip leak.
1211
+ comp.tempEnchants = r.char.tempEnchants;
1212
+ comp.bags = bags;
1213
+ comp.bank = bank;
1214
+ if (queued.length) {
1215
+ const handle = companionBagHandle(ctx, comp);
1216
+ for (const inst of queued) {
1217
+ handle.overflow(inst.item, inst.count, inst);
1218
+ log(overflowLine(handle, ds, inst.item, inst.count));
1219
+ }
1220
+ }
1221
+ if (!fromBot) state.inventory = removeItem(state.inventory, itemEntry, 1)[0];
1222
+ return { ok: true, slot: r.slot! };
1223
+ };
1224
+
1225
+ return {
1226
+ ensureCompanionRotation,
1227
+ ensureCompanionProfileList,
1228
+ syncCompanionPetAfterLevel,
1229
+ buildSquadBattleParty,
1230
+ doApplyPrePullBuffs,
1231
+ doRefreshPartyWeaponCoats,
1232
+ doApplyPaladinBuffs,
1233
+ applyCompanionImbue,
1234
+ innkeeperHere,
1235
+ spawnGearFor,
1236
+ innCandidates,
1237
+ searchInnCandidates,
1238
+ resolveCandidate,
1239
+ findCompanion,
1240
+ equipOnCompanion,
1241
+ };
1242
+ }
1243
+
1244
+ // Methods (late — after combat helpers)
1245
+
1246
+ /** Late-bound combat re-entry for runFullDungeon. Filled after beginEncounter / resolveFight exist. */
1247
+ export type SquadCombatBridge = {
1248
+ beginEncounter: (
1249
+ dungeonId: string,
1250
+ encounterId: string,
1251
+ opts?: { withSquad?: boolean; elapsedMs?: number },
1252
+ ) => ActionResult<BeginFight>;
1253
+ resolveFight: (session: CombatSession) => FightOutcome;
1254
+ getLiveFightSession: () => CombatSession | null;
1255
+ /** Same pending-fight busy signal travel.ts uses for 'You are in combat'. */
1256
+ getPendingFight: () => unknown | null;
1257
+ };
1258
+
1259
+ export type SquadMethodsExtras = {
1260
+ helpers: SquadHelpers;
1261
+ combat: SquadCombatBridge;
1262
+ /** From travelCore — enterInstance refuses while on a journey. */
1263
+ journeyTick: (nowMs?: number) => void;
1264
+ busyTravelling: () => boolean;
1265
+ /** The player's own equip path (game/equipmentHelper.ts), for a suggestion held by a companion. */
1266
+ gear: Pick<Game, 'equip' | 'suggestedEquipmentUpgrades'>;
1267
+ };
1268
+
1269
+ /** Every Game method this module owns. Named so the API object can be typed AND self-referenced. */
1270
+ type SquadMethods = Pick<
1271
+ Game,
1272
+ | 'enterInstance'
1273
+ | 'leaveInstance'
1274
+ | 'resetInstance'
1275
+ | 'setInstancePace'
1276
+ | 'recruitCompanion'
1277
+ | 'dismissCompanion'
1278
+ | 'activateCompanion'
1279
+ | 'setSquadRole'
1280
+ | 'setSquadStrategy'
1281
+ | 'setCompanionAoeMode'
1282
+ | 'setCompanionOffHeal'
1283
+ | 'setCompanionXpLocked'
1284
+ | 'setAllCompanionsAoeMode'
1285
+ | 'equipCompanionBest'
1286
+ | 'innkeeperHere'
1287
+ | 'innCandidates'
1288
+ | 'searchInnCandidates'
1289
+ | 'hireCandidate'
1290
+ | 'buildRaidRoster'
1291
+ | 'clearRaidRoster'
1292
+ | 'setInstanceRoster'
1293
+ | 'fillInstanceRoster'
1294
+ | 'clearInstanceRoster'
1295
+ | 'setRaidLineup'
1296
+ | 'fillRaidLineup'
1297
+ | 'clearRaidLineup'
1298
+ | 'raidRosterInfo'
1299
+ | 'releaseCompanions'
1300
+ | 'tavernDuel'
1301
+ | 'setCompanionSpec'
1302
+ | 'setCompanionTalentLink'
1303
+ | 'setCompanionRole'
1304
+ | 'setCompanionPet'
1305
+ | 'setCompanionPetAutocast'
1306
+ | 'equipCompanionSlot'
1307
+ | 'unequipCompanionSlot'
1308
+ | 'companionUpgradesFor'
1309
+ | 'squadUpgradePlan'
1310
+ | 'applySquadUpgrades'
1311
+ | 'equipSuggestedUpgrade'
1312
+ | 'consumableShareState'
1313
+ | 'consumableShareMembers'
1314
+ | 'squadConsumableDetail'
1315
+ | 'squadConsumablePlan'
1316
+ | 'applySquadConsumablePlan'
1317
+ | 'setConsumableShareRule'
1318
+ | 'setConsumableShareScope'
1319
+ | 'setConsumableShareIncludeYou'
1320
+ | 'setConsumableShareCat'
1321
+ | 'companionProfessionsOf'
1322
+ | 'learnCompanionProfession'
1323
+ | 'unlearnCompanionProfession'
1324
+ | 'companionPartyEnergy'
1325
+ | 'companionMissionZones'
1326
+ | 'sendCompanionOnMission'
1327
+ | 'recallCompanionMission'
1328
+ | 'collectCompanionMission'
1329
+ | 'previewCompanionMission'
1330
+ | 'collectAllCompanionMissions'
1331
+ | 'craftAsCompanion'
1332
+ | 'enchantAsCompanion'
1333
+ | 'learnRecipeFromItemOnCompanion'
1334
+ | 'learnEnchantFromItemOnCompanion'
1335
+ | 'learnSpellFromItemOnCompanion'
1336
+ | 'learnPetSpellFromItemOnCompanion'
1337
+ | 'giveItemToBot'
1338
+ | 'companionQuestOffers'
1339
+ | 'acceptCompanionQuest'
1340
+ | 'turnInCompanionQuest'
1341
+ | 'abandonCompanionQuest'
1342
+ | 'takeItemFromBot'
1343
+ | 'useCompanionItem'
1344
+ | 'companionBankDeposit'
1345
+ | 'companionBankWithdraw'
1346
+ | 'giveItemToBotStack'
1347
+ | 'takeItemFromBotStack'
1348
+ | 'moveItemBetweenBots'
1349
+ | 'discardCompanionBagStack'
1350
+ | 'sellCompanionItemStack'
1351
+ | 'buyBackCompanionStack'
1352
+ | 'takeFromPartyStorage'
1353
+ | 'companionBankDepositStack'
1354
+ | 'companionBankWithdrawStack'
1355
+ | 'buyCompanionBankSlot'
1356
+ | 'equipCompanionBankBag'
1357
+ | 'unequipCompanionBankBag'
1358
+ | 'runFullDungeon'
1359
+ | 'companionRotation'
1360
+ | 'setCompanionRotation'
1361
+ | 'resetCompanionRotation'
1362
+ | 'companionRotationProfile'
1363
+ | 'setCompanionRotationProfile'
1364
+ | 'companionHasDpsRotationProfile'
1365
+ | 'companionRotationProfiles'
1366
+ | 'addCompanionRotationProfile'
1367
+ | 'companionHasPvpRotationBand'
1368
+ | 'renameCompanionRotationProfile'
1369
+ | 'deleteCompanionRotationProfile'
1370
+ | 'selectCompanionRotationProfile'
1371
+ | 'companionActionBars'
1372
+ | 'setCompanionActionSlot'
1373
+ | 'setCompanionItemActionSlot'
1374
+ | 'addCompanionActionBar'
1375
+ | 'removeCompanionActionBar'
1376
+ | 'resetCompanionActionBars'
1377
+ | 'squadBattleParty'
1378
+ | 'applyPrePullBuffs'
1379
+ | 'refreshPartyWeaponCoats'
1380
+ | 'refreshPaladinBuffs'
1381
+ >;
1382
+
1383
+ export function createSquadMethods(
1384
+ ctx: GameCtx,
1385
+ extras: SquadMethodsExtras,
1386
+ ): SquadMethods {
1387
+ const { ds, state, log } = ctx;
1388
+ const {
1389
+ helpers,
1390
+ combat,
1391
+ journeyTick,
1392
+ busyTravelling,
1393
+ gear,
1394
+ } = extras;
1395
+ const {
1396
+ ensureCompanionRotation,
1397
+ ensureCompanionProfileList,
1398
+ syncCompanionPetAfterLevel,
1399
+ buildSquadBattleParty,
1400
+ doApplyPrePullBuffs,
1401
+ doRefreshPartyWeaponCoats,
1402
+ doApplyPaladinBuffs,
1403
+ applyCompanionImbue,
1404
+ innkeeperHere,
1405
+ innCandidates,
1406
+ searchInnCandidates,
1407
+ resolveCandidate,
1408
+ findCompanion,
1409
+ equipOnCompanion,
1410
+ } = helpers;
1411
+ const { beginEncounter, resolveFight } = combat;
1412
+ // Live session OR pending fight — same busy signal travel.ts / consumables use.
1413
+ const inLiveCombat = (): boolean =>
1414
+ !!combat.getLiveFightSession() || !!combat.getPendingFight();
1415
+ // Hearth / taxi / portal used to drop the instance without this restore. A reload
1416
+ // in the world with leftover raidParty would keep those companions off the roster.
1417
+ if (!state.instance && state.raidParty) restoreRaidPartySeating(state, ds);
1418
+
1419
+ // A rank a book just taught has to reach the FLOOR, not only the spellbook: swap the superseded rank
1420
+ // on their bars and rebuild every untouched rotation. Hand-edited rotations are left alone.
1421
+ const refreshCompanionKitAfterLearn = (comp: Companion, newId: number): void => {
1422
+ const lvl = companionLevel(comp, state.char.level);
1423
+ const sb = companionSpellbook(comp, lvl, ds);
1424
+ let bars = resolveCompanionActionBars(comp, state.char.level, ds);
1425
+ for (const oldId of lowerRanksFor(sb, newId, ds)) bars = replaceBarSpell(bars, oldId, newId);
1426
+ // Grow first: a densely-packed seed can leave no empty slot for a book-taught rank.
1427
+ comp.actionBars = autofillBars(growBarsForAutofill(bars, [newId]), [newId]);
1428
+ for (const profile of ['primary', 'dps'] as const) {
1429
+ const cur = companionRotationSlot(comp, profile);
1430
+ if (!cur || cur.custom) continue;
1431
+ setCompanionRotationSlot(comp, profile, buildCompanionRotation(comp, lvl, ds, profile));
1432
+ }
1433
+ };
1434
+
1435
+ // Roster-wide gearing plan. Local so applySquadUpgrades can recompute between equips without routing
1436
+ // through the Game facade (`this` is not reliable in the methods literal).
1437
+ const doSquadUpgradePlan = (): SquadUpgradeRow[] => {
1438
+ const members = [
1439
+ ...(state.squad ?? []),
1440
+ ...Object.values(state.botRoster ?? {}),
1441
+ ];
1442
+
1443
+ const rows: SquadUpgradeRow[] = [];
1444
+ // Pool key: '' = their own bags (uncontested), 'you:<entry>' = your bags + bank,
1445
+ // '<holderId>:<entry>' = another companion's bags.
1446
+ const shared: Array<{ key: string; row: SquadUpgradeRow }> = [];
1447
+ // Only unbound stacks can leave a holder's bags (takeItemFromBot refuses soulbound).
1448
+ const freeInBags = (inv: Inventory, entry: number): number =>
1449
+ inv.stacks.reduce((t, s) => (s.item === entry && !stackBoundToHolder(s, ds) ? t + s.count : t), 0);
1450
+ for (const comp of members) {
1451
+ const lvl = companionLevel(comp, state.char.level);
1452
+ const char = companionCharacter(comp, lvl, ds);
1453
+ const sb = companionSpellbook(comp, lvl, ds);
1454
+ const equipOpts: EquipOpts = {
1455
+ canDualWield: knows(sb, DUAL_WIELD_SPELL_ID),
1456
+ specId: companionPlayerbotSpecId(comp.cls, comp.specIdx, comp.role, ds),
1457
+ role: comp.role,
1458
+ };
1459
+ const sources: Array<{ from: SquadUpgradeRow['from']; inv: Inventory; holder?: Companion }> = [
1460
+ { from: 'theirs', inv: comp.bags ?? emptyInventory() },
1461
+ { from: 'you', inv: state.inventory },
1462
+ { from: 'bank', inv: state.bank ?? emptyInventory() },
1463
+ ];
1464
+ // A crafter's product sits in the CRAFTER's bags (craftAsCompanion), so every other
1465
+ // member's bags are a source too, the way squadConsumableDetail pools them.
1466
+ for (const o of members) {
1467
+ if (o.id !== comp.id && o.bags?.stacks.length) sources.push({ from: 'mate', inv: o.bags, holder: o });
1468
+ }
1469
+ for (const src of sources) {
1470
+ for (const u of findUpgrades(char, src.inv, ds, equipOpts)) {
1471
+ if (src.holder && freeInBags(src.inv, u.entry) <= 0) continue;
1472
+ const row: SquadUpgradeRow = {
1473
+ companionId: comp.id,
1474
+ companionName: comp.name,
1475
+ benched: !(state.squad ?? []).some((c) => c.id === comp.id),
1476
+ entry: u.entry,
1477
+ targetSlot: u.targetSlot,
1478
+ ...(u.currentEntry != null ? { currentEntry: u.currentEntry } : {}),
1479
+ delta: u.valueDelta,
1480
+ from: src.from,
1481
+ ...(src.holder ? { holderId: src.holder.id, holderName: src.holder.name } : {}),
1482
+ };
1483
+ // Their own bags are not contested; player bags / bank and a mate's bags are.
1484
+ if (src.from === 'theirs') rows.push(row);
1485
+ else shared.push({ key: `${src.holder ? src.holder.id : 'you'}:${u.entry}`, row });
1486
+ }
1487
+ }
1488
+ }
1489
+ // Claim each shared stack for its best taker. Count, not one-per-entry: three of the
1490
+ // same green shoulder can dress three companions. One row per (companion, slot) at the same
1491
+ // time: a body wears ONE wand, so listing the second-best wand as a separate upgrade offered
1492
+ // four rows that Apply All could never land.
1493
+ const claimed = new Map<string, number>();
1494
+ const takenSlots = new Set<string>();
1495
+ const owned = (row: SquadUpgradeRow): number => {
1496
+ if (row.holderId) return freeInBags(findCompanion(row.holderId)?.bags ?? emptyInventory(), row.entry);
1497
+ return countItem(state.inventory, row.entry) + countItem(state.bank ?? emptyInventory(), row.entry);
1498
+ };
1499
+ const all = [...rows.map((row) => ({ key: '', row })), ...shared]
1500
+ .sort((a, b) => b.row.delta - a.row.delta);
1501
+ const out: SquadUpgradeRow[] = [];
1502
+ for (const s of all) {
1503
+ const slotKey = `${s.row.companionId}|${s.row.targetSlot}`;
1504
+ if (takenSlots.has(slotKey)) continue;
1505
+ if (s.key !== '') {
1506
+ const used = claimed.get(s.key) ?? 0;
1507
+ if (used >= owned(s.row)) continue;
1508
+ claimed.set(s.key, used + 1);
1509
+ }
1510
+ takenSlots.add(slotKey);
1511
+ out.push(s.row);
1512
+ }
1513
+ return out;
1514
+ };
1515
+
1516
+ // Every gather node in the world, once per DataStore. `ds` exposes only gatherNode(id), so the list has
1517
+ // to be assembled from the zone tables; missions need it per dispatch.
1518
+
1519
+ // One raid-test body: generated, never bought. Everything a paid hire gives minus the gold, plus the
1520
+ // premade build that MATCHES the seat's role; its rotation is built the same way every other is.
1521
+ const buildRaidMember = (
1522
+ cls: number,
1523
+ role: SquadRole,
1524
+ level: number,
1525
+ gearProfile: GearProfile,
1526
+ itemLevel: { min: number; max: number },
1527
+ seed: number,
1528
+ takenNames: readonly string[],
1529
+ ): Companion | null => {
1530
+ const race = companionRaceFor(ds, state.char.race, cls);
1531
+ if (race == null) return null;
1532
+ const gender = companionGender(seed);
1533
+ const name = companionName(ds.botNames(), seed, gender, takenNames);
1534
+ const specIdx = botSpecIdxForRole(cls, role, ds);
1535
+ const base = {
1536
+ id: `r${seed.toString(36)}`,
1537
+ name, race, cls, gender, role, seed,
1538
+ level, xp: 0, gearProfile,
1539
+ ...(specIdx != null ? { specIdx } : {}),
1540
+ };
1541
+ const shell = companionCharacter({ ...base, equipped: {} }, level, ds);
1542
+ const equipped = generateSpawnGear(shell, seed, ds, gearProfile, {
1543
+ role,
1544
+ ...(specIdx != null ? { specIdx } : {}),
1545
+ itemLevel,
1546
+ });
1547
+ const kit = generateSpawnInventory({ ...shell, equipped }, seed, ds);
1548
+ const petKit = initBotPet({ ...shell, equipped: kit.equipped }, seed, ds);
1549
+ const comp: Companion = {
1550
+ ...base,
1551
+ equipped: { ...kit.equipped },
1552
+ bags: kit.inventory,
1553
+ bank: emptyInventory(),
1554
+ bankBags: [],
1555
+ // Raid bodies are fixed at the tier's level: XP from a test run must not drift them off
1556
+ // the template you are measuring.
1557
+ xpLocked: true,
1558
+ ...(petKit ? { pet: petKit.pet, petSpells: petKit.petSpells } : {}),
1559
+ };
1560
+ applyCompanionPetSync(comp, level, ds);
1561
+ return comp;
1562
+ };
1563
+
1564
+ const demoteRaidPromotions = (): void => restoreRaidPartySeating(state, ds);
1565
+
1566
+ let gatherNodeCache: GatherNodeDef[] | null = null;
1567
+ const allGatherNodes = (): GatherNodeDef[] => {
1568
+ if (gatherNodeCache) return gatherNodeCache;
1569
+ const seen = new Set<number>();
1570
+ const out: GatherNodeDef[] = [];
1571
+ for (const z of ds.zones()) {
1572
+ for (const id of z.nodes ?? []) {
1573
+ if (seen.has(id)) continue;
1574
+ seen.add(id);
1575
+ const n = ds.gatherNode(id);
1576
+ if (n) out.push(n);
1577
+ }
1578
+ }
1579
+ // Only cache a non-empty scan: the node table arrives with the lazy profession data, so an early
1580
+ // call would pin an empty list forever and every gather mission would pay out nothing.
1581
+ if (out.length > 0) gatherNodeCache = out;
1582
+ return out;
1583
+ };
1584
+
1585
+ /** Bags + loot conditions for route order (13523 only after 5384 rewarded). */
1586
+ const routeItemAccess = (def: InstanceDef) => {
1587
+ const snap = {
1588
+ ...questConditionSnapshot(
1589
+ state.questlog,
1590
+ state.char.level,
1591
+ canTakeQuestForCondition(ds, state.char, state.questlog),
1592
+ state.worldStates,
1593
+ ),
1594
+ auras: auraIdsForSnapshot(state.char.activeAuras),
1595
+ };
1596
+ const loot = (id: number | string) => ds.lootTable(id);
1597
+ const goLoot = (goEntry: number) => ds.gameObject(goEntry)?.lootId;
1598
+ return {
1599
+ hasItem: (itemEntry: number) =>
1600
+ countItem(ownedInventory(state.inventory, state.keyring), itemEntry) > 0,
1601
+ itemObtainable: (itemEntry: number) => {
1602
+ // No bake row at all → don't skip the gate (loot chunk not attached).
1603
+ if (!itemDropsInsideInstance(def, itemEntry, loot, goLoot)) return true;
1604
+ return itemDropsInsideInstance(
1605
+ def,
1606
+ itemEntry,
1607
+ loot,
1608
+ goLoot,
1609
+ (row) => lootDropAllowed(ds, row.conditionId, snap),
1610
+ );
1611
+ },
1612
+ };
1613
+ };
1614
+
1615
+ // Once-per-run dungeon pool roll for headless enter (same as liveDungeonController :252-291). Prior
1616
+ // winners on a held/rematerialized run do not re-roll. Cite: MapPersistentStateMgr.cpp InitPools.
1617
+ const routePoolOpts = (
1618
+ def: InstanceDef,
1619
+ priorSpawned?: readonly number[] | null,
1620
+ priorGoSpawned?: readonly number[] | null,
1621
+ ): {
1622
+ poolRoll: DungeonPoolRoll;
1623
+ mapSpawns: ReturnType<typeof dungeonSpawnsForMap>;
1624
+ floorPoolSpawnedGuids: number[];
1625
+ floorPoolSpawnedGoGuids: number[];
1626
+ } => {
1627
+ const mapSpawns = dungeonSpawnsForMap(def.mapId);
1628
+ const mapGuids = new Set<number>(mapSpawns.map((s) => s.guid));
1629
+ for (const enc of def.encounters) {
1630
+ for (const g of enc.spawnGuids ?? []) mapGuids.add(g);
1631
+ }
1632
+ const bakedPools = ds.pools() as BakedPoolRow[];
1633
+ const poolRoll: DungeonPoolRoll = priorSpawned != null
1634
+ ? poolRollFromPersisted(bakedPools, mapGuids, priorSpawned)
1635
+ : rollDungeonPools(
1636
+ bakedPools,
1637
+ mapGuids,
1638
+ mulberry32(hashSeed(state.rngSeed, hashSeed(def.mapId, DUNGEON_POOL_ROLL_SALT))),
1639
+ );
1640
+ return {
1641
+ poolRoll,
1642
+ mapSpawns,
1643
+ floorPoolSpawnedGuids: [...poolRoll.spawnedGuids],
1644
+ // Gameobject pools ride the same once-per-run rule (goPoolRoll.ts): a re-materialized route
1645
+ // keeps the winners it already has, so herbs and chests do not move when density changes.
1646
+ floorPoolSpawnedGoGuids: priorGoSpawned != null
1647
+ ? [...priorGoSpawned]
1648
+ : rollDungeonGoPools(
1649
+ def.mapId,
1650
+ mulberry32(hashSeed(state.rngSeed, hashSeed(def.mapId, DUNGEON_GO_POOL_ROLL_SALT))),
1651
+ ),
1652
+ };
1653
+ };
1654
+
1655
+ /** Surface one unit of `entry` from the bank into your bags so an equip can source it. */
1656
+ const withdrawForEquip = (entry: number): boolean => {
1657
+ const bank = state.bank ?? emptyInventory();
1658
+ if (countItem(bank, entry) <= 0) return false;
1659
+ const idef = ds.item(entry);
1660
+ if (!idef) return false;
1661
+ const stack = idef.stackable ?? 1;
1662
+ if (!canFit(state.inventory, entry, 1, stack, bagCapacity(state.char, ds))) return false;
1663
+ const idx = bank.stacks.findIndex((s) => s.item === entry);
1664
+ if (idx < 0) return false;
1665
+ const [nextBank, taken] = takeStack(bank, idx, 1);
1666
+ if (!taken) return false;
1667
+ state.bank = nextBank;
1668
+ state.inventory = addInstance(state.inventory, taken, stack);
1669
+ return true;
1670
+ };
1671
+
1672
+ /** All companions (active squad + benched roster) for vault / catalog. */
1673
+ const allCompanions = (): Companion[] => [
1674
+ ...(state.squad ?? []),
1675
+ ...Object.values(state.botRoster ?? {}),
1676
+ ];
1677
+
1678
+ // Held tools (hammer, spanner, rod) anywhere in the party's bags. Never consumed, so a companion
1679
+ // crafts on the party's one copy instead of needing their own.
1680
+ const partyTools = (): ReadonlySet<number> =>
1681
+ partyHeldTools(state.inventory, allCompanions(), ds);
1682
+
1683
+ const vaultCtx = (): PartyVaultCtx => ({
1684
+ char: state.char,
1685
+ inventory: state.inventory,
1686
+ bank: state.bank ?? emptyInventory(),
1687
+ bankBags: state.bankBags ?? [],
1688
+ companions: allCompanions(),
1689
+ playerLevel: state.char.level,
1690
+ ds,
1691
+ });
1692
+
1693
+ const vaultMut = (): VaultMutators => ({
1694
+ setPlayerInventory(inv) { state.inventory = inv; },
1695
+ setPlayerBank(inv) { state.bank = inv; },
1696
+ setCompanionBags(cid, inv) {
1697
+ const c = findCompanion(cid);
1698
+ if (c) c.bags = inv;
1699
+ },
1700
+ setCompanionBank(cid, inv) {
1701
+ const c = findCompanion(cid);
1702
+ if (c) c.bank = inv;
1703
+ },
1704
+ });
1705
+
1706
+ // Vendor goods the whole party lacks for a companion's craft are bought into the vault at vendor
1707
+ // price from the player's purse, the same buy-in the player's own craft() makes.
1708
+ const buyVaultVendorReagents = (
1709
+ reagents: readonly { item: number; count: number }[],
1710
+ preferId: string,
1711
+ ): { ok: true; cost: number; buys: VendorReagentBuy[] } | { ok: false; reason: string } => {
1712
+ const ctx = vaultCtx();
1713
+ let probe: Inventory = emptyInventory();
1714
+ for (const st of partyMergedStacks(ctx)) probe = { stacks: [...probe.stacks, { item: st.item, count: st.count }] };
1715
+ const sf = vendorShortfall(ds, state.char, reagents, probe);
1716
+ if (sf.blocked.length || !sf.buys.length) return { ok: true, cost: 0, buys: [] };
1717
+ if (sf.cost > state.char.gold) {
1718
+ return { ok: false, reason: `Not enough money for ${buyNames(ds, sf.buys)} (${moneyMarked(sf.cost)})` };
1719
+ }
1720
+ const mut = vaultMut();
1721
+ const landed: Array<{ item: number; count: number }> = [];
1722
+ for (const b of sf.buys) {
1723
+ const r = partyDeposit(ctx, mut, b.item, b.units, ds.item(b.item)?.stackable ?? 1, preferId);
1724
+ if (r.deposited > 0) landed.push({ item: b.item, count: r.deposited });
1725
+ if (r.remaining > 0) {
1726
+ for (const row of landed) partyTake(ctx, mut, row.item, row.count, preferId);
1727
+ return { ok: false, reason: 'Not enough bag space' };
1728
+ }
1729
+ }
1730
+ state.char = { ...state.char, gold: state.char.gold - sf.cost };
1731
+ log(`Bought ${buyNames(ds, sf.buys)} for ${moneyMarked(sf.cost)}.`);
1732
+ return { ok: true, cost: sf.cost, buys: sf.buys };
1733
+ };
1734
+
1735
+ /** Resolve mission nodes for a companion mission (zone-scoped). */
1736
+ const missionNodesFor = (m: { skill: MissionSkill; zoneId: string; atSkill: number }): GatherNodeDef[] => {
1737
+ if (m.skill === 'skinning') {
1738
+ const entries = zoneSpawns(ds, m.zoneId).map((s) => s.entry);
1739
+ // Also include huntable list when spawns are sparse.
1740
+ const zone = ds.zone(m.zoneId);
1741
+ const hunt = zone?.huntable ?? [];
1742
+ const set = new Set<number>([...entries, ...hunt]);
1743
+ return workableZoneSkinNodes(m.zoneId, m.atSkill, ds, [...set]);
1744
+ }
1745
+ return workableZoneNodes(ds.zone(m.zoneId), m.skill, m.atSkill, ds);
1746
+ };
1747
+
1748
+ const skinCreaturesByZone = (): Map<string, number[]> => {
1749
+ const map = new Map<string, number[]>();
1750
+ for (const z of ds.zones()) {
1751
+ const entries = new Set<number>();
1752
+ for (const s of zoneSpawns(ds, z.id)) entries.add(s.entry);
1753
+ for (const e of z.huntable ?? []) entries.add(e);
1754
+ map.set(z.id, [...entries]);
1755
+ }
1756
+ return map;
1757
+ };
1758
+
1759
+ /** Ensure companion mission shape is current; drop unrecoverable legacy trips. */
1760
+ const ensureMissionShape = (comp: Companion): void => {
1761
+ if (!comp.mission) return;
1762
+ const norm = normalizeCompanionMission(comp.mission);
1763
+ if (!norm) {
1764
+ delete comp.mission;
1765
+ return;
1766
+ }
1767
+ comp.mission = norm;
1768
+ };
1769
+
1770
+ // hireRaidRoster hires each seat through hireCandidate so the raid path and the single-hire
1771
+ // path share one economy. The API is an object literal, so it needs a self-reference.
1772
+ let self: SquadMethods;
1773
+
1774
+ const api: SquadMethods = {
1775
+ enterInstance(dungeonId, nowMs = 0, opts) {
1776
+ journeyTick(nowMs);
1777
+ if (busyTravelling()) {
1778
+ return { ok: false, reason: 'You are travelling' };
1779
+ }
1780
+ // Corpse stays at death site; no portal while dead / mid-fight (KI-941 / KI-944).
1781
+ // Cite: Player.cpp:4388 ghost dungeon enter; travel.ts refuseIfDead + getPendingFight.
1782
+ {
1783
+ const gate = refuseIfDeadOrInCombat(
1784
+ state.char.health, state.deathFlow, inLiveCombat(), YOU_ARE_IN_COMBAT,
1785
+ );
1786
+ if (gate) return gate;
1787
+ }
1788
+ const def = ds.instance(dungeonId);
1789
+ if (!def) return { ok: false, reason: 'Unknown dungeon' };
1790
+ if (state.instance) return { ok: false, reason: 'You are already inside an instance' };
1791
+ // The area-trigger lock chain: inventory, turned-in quests and the baked teleports, with the name
1792
+ // fragment narrowing a multi-entrance zone. Cite: Player.cpp:19906-19984.
1793
+ const chk = canEnter(def, state.char.level, state.lockouts, nowMs, {
1794
+ teleports: ds.areatriggerTeleports(),
1795
+ instanceSaves: state.instanceSaves,
1796
+ ...buildEntryLockInputs(state, def.name),
1797
+ });
1798
+ if (!chk.ok) return { ok: false, reason: chk.reason ?? 'You cannot enter' };
1799
+ // Only full|bosses and single|link|chain|mayhem persist — match setSquadStrategy (KI-687).
1800
+ if (opts?.routeDensity != null && !isRouteDensity(opts.routeDensity)) {
1801
+ return {
1802
+ ok: false,
1803
+ reason: `Unknown route density '${String(opts.routeDensity)}' (use full or bosses)`,
1804
+ };
1805
+ }
1806
+ if (opts?.pullFusion != null && !isPullFusion(opts.pullFusion)) {
1807
+ return {
1808
+ ok: false,
1809
+ reason: `Unknown pull fusion '${String(opts.pullFusion)}' (use single, link, chain, or mayhem)`,
1810
+ };
1811
+ }
1812
+ // Materialize the route density and fusion for this run. The rest axis stays the squad strategy
1813
+ // and pacing, never an HP multiplier, and an over-strong pack splits into sequential pulls.
1814
+ const routeDensity = opts?.routeDensity ?? DEFAULT_ROUTE_DENSITY;
1815
+ const pullFusion = opts?.pullFusion ?? DEFAULT_PULL_FUSION;
1816
+ // A run you left (5-man under 30 min, raid until lockout) is the SAME instance, not a
1817
+ // fresh one — its route/density stand (setInstancePace refuses once packs are down).
1818
+ const held = restoreSavedRun(state.instanceSaves, dungeonId, nowMs);
1819
+ if (state.instanceSaves) {
1820
+ state.instanceSaves = pruneInstanceSaves(state.instanceSaves, nowMs);
1821
+ delete state.instanceSaves[dungeonId];
1822
+ }
1823
+ unsummonTotemsOnLeaveWorld(combat.getLiveFightSession());
1824
+ state.instance = held ?? enterRunWithRoute(def, {
1825
+ routeDensity,
1826
+ pullFusion,
1827
+ packBudget: {
1828
+ partyLevel: state.char.level,
1829
+ creatureOf: (entry) => ds.creature(entry),
1830
+ spellOf: (id) => ds.spell(id),
1831
+ },
1832
+ ...routeItemAccess(def),
1833
+ // KI-971: strip pool-absent / multi-slot rares like the live floor.
1834
+ ...routePoolOpts(def, null),
1835
+ entranceTriggerId: opts?.entranceTriggerId,
1836
+ });
1837
+ // Same clock canEnter / runFullDungeon lockout / leaveInstance compare (KI-1022).
1838
+ state.instanceEnteredAtMs = nowMs;
1839
+ // Instance OnPlayerEnter door flags (Dire Maul 179550 north-before-west).
1840
+ // Cite: instance_dire_maul.cpp:62-73; areatrigger 3189 target inside probe.
1841
+ {
1842
+ const instIr = scriptDevByName('instance_dire_maul');
1843
+ const enterAt = entranceForInstance(dungeonId, opts?.entranceTriggerId);
1844
+ if (instIr && enterAt && def.mapId === 429) {
1845
+ const store = scriptInstanceDataMap(state.instance);
1846
+ if (hasFlag(state.instance, DIRE_MAUL_WEST_FLAGS.tendrisDeath)) {
1847
+ store.set(TYPE_WARPWOOD, INSTANCE_DATA.DONE);
1848
+ }
1849
+ const doorFlags = applyInstanceOnPlayerEnter(
1850
+ instIr,
1851
+ enterAt.x,
1852
+ enterAt.y,
1853
+ store,
1854
+ );
1855
+ state.instance = persistScriptInstanceData(state.instance, store);
1856
+ for (const f of doorFlags) {
1857
+ const key = `${state.char.zoneId}:${f.goEntry}`;
1858
+ const prev = state.goRuntime?.[key] ?? {};
1859
+ state.goRuntime = {
1860
+ ...(state.goRuntime ?? {}),
1861
+ [key]: { ...prev, noInteract: f.noInteract, locked: f.locked },
1862
+ };
1863
+ }
1864
+ }
1865
+ }
1866
+ state.corpse = null;
1867
+ // A leftover record from a hearth/taxi that skipped restore — put them back
1868
+ // before this run's seating overwrites raidParty and drops the bodies.
1869
+ if (state.raidParty) restoreRaidPartySeating(state, ds);
1870
+ seatPartyForRun(state, ds, log, def, opts?.asRaid === true);
1871
+ log(held ? `You enter ${def.name}. Your progress stands.` : `You enter ${def.name}.`);
1872
+ return { ok: true };
1873
+ },
1874
+
1875
+ leaveInstance(nowMs = Date.now()) {
1876
+ if (!state.instance) return { ok: false, reason: 'You are not in an instance' };
1877
+ // Refuse — do not clear corpse or orphan liveFightSession (KI-952/955/943/953).
1878
+ {
1879
+ const gate = refuseIfDeadOrInCombat(
1880
+ state.char.health, state.deathFlow, inLiveCombat(), YOU_ARE_IN_COMBAT,
1881
+ );
1882
+ if (gate) return gate;
1883
+ }
1884
+ unsummonTotemsOnLeaveWorld(combat.getLiveFightSession());
1885
+ const def = ds.instance(state.instance.dungeonId);
1886
+ const name = def?.name ?? 'the dungeon';
1887
+ demoteRaidPromotions();
1888
+ // 5-man: 30 min hold. Raid: hold until lockout so leave is not a wipe + barred door.
1889
+ const saves = state.instanceSaves ?? {};
1890
+ state.instanceSaves = def
1891
+ ? saveRunOnLeave(saves, state.instance, def, nowMs, state.lockouts)
1892
+ : pruneInstanceSaves(saves, nowMs);
1893
+ const held = def != null && state.instanceSaves[def.id] != null;
1894
+ const raidHeld = held && (def?.lockoutMs ?? 0) > 0;
1895
+ state.instance = null;
1896
+ delete state.instanceEnteredAtMs;
1897
+ state.corpse = null;
1898
+ // Outside, the companions are no longer inside any aura radius: the sheet drops their area auras.
1899
+ reconcileGamePartyAuras(state, ds);
1900
+ log(
1901
+ raidHeld ? `You leave ${name}. Your progress stands.`
1902
+ : held ? `You leave ${name}. It resets in 30 minutes.`
1903
+ : `You leave ${name}.`,
1904
+ );
1905
+ return { ok: true };
1906
+ },
1907
+
1908
+ resetInstance() {
1909
+ if (!state.instance) return { ok: false, reason: 'You are not in an instance' };
1910
+ // Dead / mid-pull cannot rewrite the run (KI-958 / KI-945).
1911
+ {
1912
+ const gate = refuseIfDeadOrInCombat(
1913
+ state.char.health, state.deathFlow, inLiveCombat(), YOU_ARE_IN_COMBAT,
1914
+ );
1915
+ if (gate) return gate;
1916
+ }
1917
+ // A raid save is not resettable — that is what "saved" MEANS. Without this the lockout would be
1918
+ // one button press away from meaningless. Cite: instance_template.reset_delay.
1919
+ const rdef = ds.instance(state.instance.dungeonId);
1920
+ if (rdef && rdef.lockoutMs > 0) {
1921
+ const left = lockRemainingMs(state.lockouts, rdef.id, Date.now());
1922
+ if (left > 0) {
1923
+ const h = Math.ceil(left / 3_600_000);
1924
+ return { ok: false, reason: `You are saved to ${rdef.name} for another ${h}h` };
1925
+ }
1926
+ }
1927
+ state.instance = resetRun(state.instance);
1928
+ const name = ds.instance(state.instance.dungeonId)?.name ?? 'The instance';
1929
+ log(`${name} has been reset - the enemies return.`);
1930
+ return { ok: true };
1931
+ },
1932
+
1933
+ setInstancePace(opts) {
1934
+ const run = state.instance;
1935
+ if (!run) return { ok: false, reason: 'You are not in an instance' };
1936
+ // Dead / open fight must not rematerialize the route (KI-959 / KI-951).
1937
+ {
1938
+ const gate = refuseIfDeadOrInCombat(
1939
+ state.char.health, state.deathFlow, inLiveCombat(), YOU_ARE_IN_COMBAT,
1940
+ );
1941
+ if (gate) return gate;
1942
+ }
1943
+ const def = ds.instance(run.dungeonId);
1944
+ if (!def) return { ok: false, reason: 'Unknown dungeon' };
1945
+ if (run.defeated.length > 0) {
1946
+ return { ok: false, reason: 'Reset the instance first - packs are already down' };
1947
+ }
1948
+ // Same union gate as enterInstance — refuse garbage before rematerializing the route.
1949
+ if (opts.routeDensity != null && !isRouteDensity(opts.routeDensity)) {
1950
+ return {
1951
+ ok: false,
1952
+ reason: `Unknown route density '${String(opts.routeDensity)}' (use full or bosses)`,
1953
+ };
1954
+ }
1955
+ if (opts.pullFusion != null && !isPullFusion(opts.pullFusion)) {
1956
+ return {
1957
+ ok: false,
1958
+ reason: `Unknown pull fusion '${String(opts.pullFusion)}' (use single, link, chain, or mayhem)`,
1959
+ };
1960
+ }
1961
+ const routeDensity = opts.routeDensity ?? run.routeDensity ?? DEFAULT_ROUTE_DENSITY;
1962
+ const pullFusion = opts.pullFusion ?? run.pullFusion ?? DEFAULT_PULL_FUSION;
1963
+ if (routeDensity === run.routeDensity && pullFusion === run.pullFusion) {
1964
+ return { ok: true };
1965
+ }
1966
+ // Same materialization enterInstance uses — one code path, so a route chosen here and a
1967
+ // route chosen at the portal cannot diverge.
1968
+ state.instance = enterRunWithRoute(def, {
1969
+ routeDensity,
1970
+ pullFusion,
1971
+ packBudget: {
1972
+ partyLevel: state.char.level,
1973
+ creatureOf: (entry) => ds.creature(entry),
1974
+ spellOf: (id) => ds.spell(id),
1975
+ },
1976
+ ...routeItemAccess(def),
1977
+ ...routePoolOpts(
1978
+ def,
1979
+ run.floorPoolSpawnedGuids ?? null,
1980
+ run.floorPoolSpawnedGoGuids ?? null,
1981
+ ),
1982
+ });
1983
+ return { ok: true };
1984
+ },
1985
+
1986
+ recruitCompanion(cls, role, gearProfile) {
1987
+ // Seated allies must match state.squad — no mid-pull roster mutate (KI-961).
1988
+ {
1989
+ const busy = refuseIfInCombat(inLiveCombat(), YOU_ARE_IN_COMBAT);
1990
+ if (busy) return busy;
1991
+ }
1992
+ // Only tank|healer|dps and GEAR_PROFILES persist — same gate as setCompanionRole / KI-687.
1993
+ if (!isSquadRole(role)) {
1994
+ return { ok: false, reason: 'Unknown role' };
1995
+ }
1996
+ if (gearProfile != null && !isGearProfile(gearProfile)) {
1997
+ return {
1998
+ ok: false,
1999
+ reason: `Unknown gear profile '${String(gearProfile)}' (use ${GEAR_PROFILES.join(', ')})`,
2000
+ };
2001
+ }
2002
+ const squad = state.squad ?? [];
2003
+ if (rosterSize(squad, state.botRoster) >= MAX_ROSTER) {
2004
+ return { ok: false, reason: `Your roster is full (${MAX_ROSTER} companions)` };
2005
+ }
2006
+ const race = companionRaceFor(ds, state.char.race, cls);
2007
+ if (race == null) return { ok: false, reason: 'No one of your faction can fight that way' };
2008
+ // Seed on TOTAL owned, not party size: the squad stops at the cap, so every recruit past the
2009
+ // fourth drew a seed that depended only on class, and the 13th onward collided.
2010
+ const owned = rosterSize(squad, state.botRoster);
2011
+ const seed = hashSeed(state.rngSeed, 7100 + owned * 13 + cls);
2012
+ const gender = companionGender(seed);
2013
+ const takenNames = [...squad.map((c) => c.name), ...Object.values(state.botRoster ?? {}).map((c) => c.name)];
2014
+ const name = companionName(ds.botNames(), seed, gender, takenNames);
2015
+ // Stamp level to player so pet unlocks (hunter ≥10 / warlock Imp) apply immediately.
2016
+ const level = Math.max(1, Math.min(60, state.char.level));
2017
+ // Stamp a role-matching premade talent build when available so tank/healer
2018
+ // companions seed Protection/Holy bands (not Arms/Shadow) on first rotation build.
2019
+ const roleSpecIdx = botSpecIdxForRole(cls, role, ds);
2020
+ // Gear the recruit like an inn hire. It used to ship a naked level-60 body, so every headless
2021
+ // squad measurement read a fraction of real output — 39 naked companions did 5.5% of a boss.
2022
+ const profile = gearProfile ?? DEFAULT_HIRE_GEAR_PROFILE;
2023
+ const shell = companionCharacter(
2024
+ { id: `c${seed.toString(36)}`, name, race, cls, gender, role, seed, equipped: {},
2025
+ level, xp: 0,
2026
+ ...(roleSpecIdx != null ? { specIdx: roleSpecIdx } : {}) },
2027
+ level,
2028
+ ds,
2029
+ );
2030
+ const equipped = generateSpawnGear(shell, seed, ds, profile, {
2031
+ role,
2032
+ ...(roleSpecIdx != null ? { specIdx: roleSpecIdx } : {}),
2033
+ });
2034
+ // The same bag kit a raid body gets, whose own comment already claimed the paid hire did this. It
2035
+ // did not, and a hire with no Soul Shards can soulstone nobody and conjure nobody a healthstone.
2036
+ const kit = generateSpawnInventory({ ...shell, equipped }, seed, ds);
2037
+ const comp: Companion = {
2038
+ id: `c${seed.toString(36)}`, name, race, cls, gender, role, seed,
2039
+ equipped: { ...kit.equipped },
2040
+ bags: kit.inventory,
2041
+ level, xp: 0, gearProfile: profile,
2042
+ // ICH4: twink + raid-* default locked so band/endgame kits stay put (design §4.3).
2043
+ xpLocked: defaultXpLocked(profile),
2044
+ ...(roleSpecIdx != null ? { specIdx: roleSpecIdx } : {}),
2045
+ };
2046
+ applyCompanionPetSync(comp, level, ds);
2047
+ const placed = placeHire(squad, state.botRoster, comp);
2048
+ // A recruit that changed nothing must not answer ok — that is how 27 of 39 raid
2049
+ // hires vanished silently.
2050
+ if (placed.where === 'duplicate') return { ok: false, reason: 'That companion is already yours' };
2051
+ if (placed.where === 'full') return { ok: false, reason: `Your roster is full (${MAX_ROSTER} companions)` };
2052
+ state.squad = placed.squad;
2053
+ state.botRoster = placed.roster;
2054
+ reconcileGamePartyAuras(state, ds);
2055
+ log(placed.where === 'roster'
2056
+ ? `${name} joins your roster as ${role === 'dps' ? 'damage' : role} (party is full - tap Party on them in Companions).`
2057
+ : `${name} joins your party as ${role === 'dps' ? 'damage' : role}.`);
2058
+ return { ok: true, id: comp.id, name: comp.name, benched: placed.where === 'roster' };
2059
+ },
2060
+
2061
+ dismissCompanion(id) {
2062
+ {
2063
+ const busy = refuseIfInCombat(inLiveCombat(), YOU_ARE_IN_COMBAT);
2064
+ if (busy) return busy;
2065
+ }
2066
+ const comp = state.squad?.find((c) => c.id === id);
2067
+ if (!comp) return { ok: false, reason: 'No such companion' };
2068
+ // D4 ownership: the bot's gear/bags/bank are THEIRS - they leave with
2069
+ // everything and wait in your roster, re-hirable at any inn.
2070
+ state.squad = removeCompanion(state.squad ?? [], id);
2071
+ state.botRoster = { ...(state.botRoster ?? {}), [id]: comp };
2072
+ reconcileGamePartyAuras(state, ds);
2073
+ log(`${comp.name} leaves the party (their gear goes with them - bring them back from Companions).`);
2074
+ return { ok: true };
2075
+ },
2076
+
2077
+ activateCompanion(id) {
2078
+ {
2079
+ const busy = refuseIfInCombat(inLiveCombat(), YOU_ARE_IN_COMBAT);
2080
+ if (busy) return busy;
2081
+ }
2082
+ // Combat only. Staging is the swap surface; Companions is not a live-floor panel (LIVE_PANEL_IDS).
2083
+ const squad = state.squad ?? [];
2084
+ if (squad.some((c) => c.id === id)) return { ok: false, reason: 'Already in your party' };
2085
+ const benched = state.botRoster?.[id];
2086
+ if (!benched) return { ok: false, reason: 'No such companion on your roster' };
2087
+ if (squad.length >= MAX_COMPANIONS) {
2088
+ return { ok: false, reason: `Your party is full (${MAX_COMPANIONS}) - bench someone first` };
2089
+ }
2090
+ const roster = { ...state.botRoster };
2091
+ delete roster[id];
2092
+ state.botRoster = roster;
2093
+ state.squad = addCompanion(squad, benched);
2094
+ reconcileGamePartyAuras(state, ds);
2095
+ log(`${benched.name} joins your party.`);
2096
+ return { ok: true, id: benched.id, name: benched.name };
2097
+ },
2098
+
2099
+ setSquadRole(role) {
2100
+ // Only tank|healer|dps — match setCompanionRole so illegal stamps cannot hide grades.
2101
+ if (!isSquadRole(role)) {
2102
+ return { ok: false, reason: 'Unknown role' };
2103
+ }
2104
+ state.squadRole = role;
2105
+ // Align the auto-combat taunt policy with the signed dungeon role: the default suppresses taunts,
2106
+ // so signing tank must not leave the player as "DPS (no auto-taunt)".
2107
+ const ap = normalizeAutopilotConfig(state.autopilot);
2108
+ if (role === 'tank') {
2109
+ state.autopilot = { ...ap, combatRole: 'tank' };
2110
+ } else if (ap.combatRole === 'tank') {
2111
+ // No longer signed tank → drop auto-taunt (healer/dps). Offtank only via editor.
2112
+ state.autopilot = { ...ap, combatRole: 'dps' };
2113
+ }
2114
+ return { ok: true };
2115
+ },
2116
+
2117
+ setSquadStrategy(strategy) {
2118
+ // Only careful|aggressive|sip have SQUAD_PACING rows — reject unknown keys so
2119
+ // runFullDungeon restBetweenPulls never TypeErrors on undefined.restTo (KI-687).
2120
+ if (strategy !== 'careful' && strategy !== 'aggressive' && strategy !== 'sip') {
2121
+ return { ok: false, reason: `Unknown squad strategy '${String(strategy)}' (use careful, aggressive, or sip)` };
2122
+ }
2123
+ state.squadStrategy = strategy;
2124
+ return { ok: true };
2125
+ },
2126
+
2127
+ setCompanionAoeMode(id, mode) {
2128
+ const comp = state.squad?.find((c) => c.id === id);
2129
+ if (!comp) return { ok: false, reason: 'No such companion' };
2130
+ if (mode !== 'auto' && mode !== 'aoe' && mode !== 'single') {
2131
+ return { ok: false, reason: 'Invalid AoE mode' };
2132
+ }
2133
+ comp.aoeMode = mode;
2134
+ return { ok: true };
2135
+ },
2136
+
2137
+ setCompanionOffHeal(id, on) {
2138
+ const comp = state.squad?.find((c) => c.id === id);
2139
+ if (!comp) return { ok: false, reason: 'No such companion' };
2140
+ comp.offHeal = !!on;
2141
+ return { ok: true };
2142
+ },
2143
+
2144
+ setCompanionXpLocked(id, locked) {
2145
+ const comp = state.squad?.find((c) => c.id === id);
2146
+ if (!comp) return { ok: false, reason: 'No such companion' };
2147
+ // Free toggle (design §3.4). Store explicit boolean so saves round-trip true/false.
2148
+ comp.xpLocked = !!locked;
2149
+ return { ok: true };
2150
+ },
2151
+
2152
+ setAllCompanionsAoeMode(mode) {
2153
+ if (mode !== 'auto' && mode !== 'aoe' && mode !== 'single') {
2154
+ return { ok: false, reason: 'Invalid AoE mode' };
2155
+ }
2156
+ for (const c of state.squad ?? []) c.aoeMode = mode;
2157
+ return { ok: true };
2158
+ },
2159
+
2160
+ equipCompanionBest(id) {
2161
+ const comp = state.squad?.find((c) => c.id === id);
2162
+ if (!comp) return { ok: false, reason: 'No such companion' };
2163
+ let equipped = 0;
2164
+ // Their own bags first, then yours (a gifted item becomes the bot's).
2165
+ // equipOnCompanion keeps ownership straight (displaced gear → bot bags).
2166
+ for (const source of ['bot', 'player'] as const) {
2167
+ const skipped = new Set<number>();
2168
+ for (let guard = 0; guard < 16 && equipped < 12; guard++) {
2169
+ const lvl = companionLevel(comp, state.char.level);
2170
+ const char = companionCharacter(comp, lvl, ds);
2171
+ const inv = source === 'bot' ? (comp.bags ?? emptyInventory()) : state.inventory;
2172
+ // Spec weights plus Dual Wield: without them a dual-wielder never fills the off hand and a
2173
+ // caster gets melee-tilted junk. Cite: Player.cpp CanDualWield; RandomItemMgr GetPlayerSpecId.
2174
+ const sb = companionSpellbook(comp, lvl, ds);
2175
+ const equipOpts: EquipOpts = {
2176
+ canDualWield: knows(sb, DUAL_WIELD_SPELL_ID),
2177
+ specId: companionPlayerbotSpecId(comp.cls, comp.specIdx, comp.role, ds),
2178
+ role: comp.role,
2179
+ };
2180
+ const ups = findUpgrades(char, inv, ds, equipOpts);
2181
+ const best = ups.find((u) => !skipped.has(u.entry));
2182
+ if (!best) break;
2183
+ const r = equipOnCompanion(comp, best.entry, best.targetSlot);
2184
+ if (r.ok) equipped++;
2185
+ else skipped.add(best.entry);
2186
+ }
2187
+ }
2188
+ if (equipped > 0) {
2189
+ log(`${comp.name} equips ${equipped} upgrade${equipped === 1 ? '' : 's'}.`);
2190
+ }
2191
+ return { ok: true, equipped };
2192
+ },
2193
+
2194
+ innkeeperHere,
2195
+ innCandidates,
2196
+ searchInnCandidates,
2197
+
2198
+ hireCandidate(botId, role) {
2199
+ // Same SquadRole gate as recruitCompanion / setCompanionRole — refuse banana stamps.
2200
+ if (!isSquadRole(role)) {
2201
+ return { ok: false, reason: 'Unknown role' };
2202
+ }
2203
+ if (!innkeeperHere()) return { ok: false, reason: 'Companions are hired at an inn' };
2204
+ const squad = state.squad ?? [];
2205
+ if (squad.some((c) => c.id === botId)) return { ok: false, reason: 'Already in your party' };
2206
+ // A roster re-hire is free — the bot comes back exactly as it left. Activating a bench member is
2207
+ // the one path that must still refuse on a full PARTY, or benching them again reads as success.
2208
+ const benched = state.botRoster?.[botId];
2209
+ if (benched) {
2210
+ if (squad.length >= MAX_COMPANIONS) {
2211
+ return { ok: false, reason: `Your party is full (${MAX_COMPANIONS}) - dismiss someone to swap them in` };
2212
+ }
2213
+ const roster = { ...state.botRoster };
2214
+ delete roster[botId];
2215
+ state.botRoster = roster;
2216
+ const comp: Companion = { ...benched };
2217
+ if (comp.role !== role) applyCompanionRole(comp, role, ds);
2218
+ state.squad = addCompanion(squad, comp);
2219
+ reconcileGamePartyAuras(state, ds);
2220
+ log(`${comp.name} rejoins your party as ${role === 'dps' ? 'damage' : role} (re-hire free).`);
2221
+ return { ok: true, id: comp.id, name: comp.name, costCopper: 0 };
2222
+ }
2223
+ if (rosterSize(squad, state.botRoster) >= MAX_ROSTER) {
2224
+ return { ok: false, reason: `Your roster is full (${MAX_ROSTER} companions)` };
2225
+ }
2226
+ const cand = resolveCandidate(botId);
2227
+ if (!cand) return { ok: false, reason: 'That hire is no longer available' };
2228
+ // ICH1: freeze kit profile + spawn gear at hire (preview used the same profile).
2229
+ const gearProfile = cand.gearProfile ?? DEFAULT_HIRE_GEAR_PROFILE;
2230
+ // ICH2: first hire pays exact hireCostCopper; broke fails (design §3.1).
2231
+ const cost = cand.hireCostCopper ?? hireCostCopper(cand.bot.level, gearProfile);
2232
+ if (state.char.gold < cost) {
2233
+ return { ok: false, reason: 'Not enough gold' };
2234
+ }
2235
+ // Same helper as setCompanionRole / recruitCompanion — keep slate spec when it already plays `role`.
2236
+ const specIdx = specIdxForRole(cand.bot.cls, role, cand.bot.specIdx, ds) ?? cand.bot.specIdx;
2237
+ // PlayerbotFactory kit: bags/gold-path inventory + pet (same as Random Character).
2238
+ const hireShell = companionCharacter({
2239
+ id: cand.bot.id, name: cand.bot.name, race: cand.bot.race, cls: cand.bot.cls,
2240
+ role, seed: cand.bot.seed, equipped: { ...cand.gear }, level: cand.bot.level, specIdx,
2241
+ gearProfile,
2242
+ }, cand.bot.level, ds);
2243
+ const kit = generateSpawnInventory(
2244
+ { ...hireShell, equipped: { ...cand.gear } },
2245
+ cand.bot.seed,
2246
+ ds,
2247
+ );
2248
+ const petKit = initBotPet({ ...hireShell, equipped: kit.equipped }, cand.bot.seed, ds);
2249
+ const comp: Companion = {
2250
+ id: cand.bot.id,
2251
+ name: cand.bot.name,
2252
+ race: cand.bot.race,
2253
+ cls: cand.bot.cls,
2254
+ gender: cand.bot.gender,
2255
+ role,
2256
+ seed: cand.bot.seed,
2257
+ equipped: { ...kit.equipped }, // spawn gear + bag slots from InitBags
2258
+ level: cand.bot.level,
2259
+ xp: 0,
2260
+ specIdx,
2261
+ gearProfile,
2262
+ // ICH4: twink + raid-* default locked so band/endgame kits stay put (design §4.3).
2263
+ xpLocked: defaultXpLocked(gearProfile),
2264
+ bags: kit.inventory,
2265
+ bank: emptyInventory(),
2266
+ bankBags: [],
2267
+ ...(petKit ? { pet: petKit.pet, petSpells: petKit.petSpells } : {}),
2268
+ };
2269
+ if (cost > 0) {
2270
+ state.char = { ...state.char, gold: state.char.gold - cost };
2271
+ }
2272
+ const placed = placeHire(squad, state.botRoster, comp);
2273
+ state.squad = placed.squad;
2274
+ state.botRoster = placed.roster;
2275
+ reconcileGamePartyAuras(state, ds);
2276
+ // Design §13: Hired {name} for {price}
2277
+ const where = placed.where === 'roster' ? ' - your party is full, so they wait in your roster' : '';
2278
+ log(
2279
+ cost > 0
2280
+ ? `Hired ${comp.name} for ${hirePriceLabel(cost)}${where}.`
2281
+ : `Hired ${comp.name} (Free - knows their spells, bring your own steel)${where}.`,
2282
+ );
2283
+ return {
2284
+ ok: true, id: comp.id, name: comp.name, costCopper: cost, benched: placed.where === 'roster',
2285
+ };
2286
+ },
2287
+
2288
+ // DEV: build the raid test roster for one raid from its tier template. It replaces a version that
2289
+ // spent the player's gold on the lowest-level bodies in the game. Free, generated, level 60.
2290
+ buildRaidRoster(opts) {
2291
+ const def = ds.instance(opts.dungeonId);
2292
+ if (!def) return { ok: false, reason: 'Unknown dungeon' };
2293
+ const tpl = raidRosterTemplate({
2294
+ ds,
2295
+ def,
2296
+ playerRace: state.char.race,
2297
+ playerRole: state.squadRole ?? 'dps',
2298
+ playerCls: state.char.cls,
2299
+ ...(opts.tier ? { tier: opts.tier } : {}),
2300
+ });
2301
+ if (!tpl) {
2302
+ // Separate the two refusals: a 5-man has no template at all, an unknown --tier is a typo.
2303
+ const named = opts.tier ? ` at tier '${String(opts.tier)}' (${RAID_TIER_IDS.join(', ')})` : '';
2304
+ return { ok: false, reason: `${def.name} has no raid roster template${named}` };
2305
+ }
2306
+
2307
+ const members: Record<string, Companion> = {};
2308
+ const taken: string[] = [];
2309
+ let scores = 0;
2310
+ tpl.seats.forEach((seat, i) => {
2311
+ // Seeded on the raid + tier + seat index so the same template rebuilds the same raid —
2312
+ // a re-measurement has to be comparable to the one before it.
2313
+ const seed = hashSeed(
2314
+ stableIdHash(`${tpl.dungeonId}:${tpl.tier.id}`),
2315
+ 7300 + i * 13 + seat.cls,
2316
+ );
2317
+ const built = buildRaidMember(
2318
+ seat.cls, seat.role, tpl.level, tpl.tier.gear, tpl.itemLevel, seed, taken,
2319
+ );
2320
+ if (!built) return;
2321
+ members[built.id] = built;
2322
+ taken.push(built.name);
2323
+ scores += displayGearScore(built.equipped, ds);
2324
+ });
2325
+
2326
+ const built = Object.keys(members).length;
2327
+ if (!built) return { ok: false, reason: `No ${def.name} roster could be built for your faction` };
2328
+ state.raidRoster = { dungeonId: tpl.dungeonId, tier: tpl.tier.id, members };
2329
+ const comp = describeRaidComp([
2330
+ ...Object.values(members).map((c) => ({ cls: c.cls, role: c.role })),
2331
+ { cls: state.char.cls, role: state.squadRole ?? 'dps' },
2332
+ ]);
2333
+ const gearScore = Math.round(scores / built);
2334
+ log(`Raid roster [dev]: ${built + 1}/${tpl.size} for ${def.name} in ${tpl.tier.label} - ${comp}, avg ilvl ${gearScore}.`);
2335
+ return {
2336
+ ok: true,
2337
+ dungeonId: tpl.dungeonId,
2338
+ name: def.name,
2339
+ tier: tpl.tier.id,
2340
+ tierLabel: tpl.tier.label,
2341
+ size: tpl.size,
2342
+ built,
2343
+ comp,
2344
+ gearScore,
2345
+ };
2346
+ },
2347
+
2348
+ clearRaidRoster() {
2349
+ const cleared = Object.keys(state.raidRoster?.members ?? {}).length;
2350
+ if (!cleared) return { ok: false, reason: 'No raid test roster to clear' };
2351
+ delete state.raidRoster;
2352
+ log(`Raid roster [dev]: cleared ${cleared} test companions.`);
2353
+ return { ok: true, cleared };
2354
+ },
2355
+
2356
+ setInstanceRoster(dungeonId, ids) {
2357
+ const def = ds.instance(dungeonId);
2358
+ if (!def) return { ok: false, reason: 'Unknown dungeon' };
2359
+ const cap = maxCompanionsFor(def.maxPlayers);
2360
+ const ownedIds = new Set(allCompanions().map((c) => c.id));
2361
+ const picked = clampRosterPicks(ids, ownedIds, cap);
2362
+ // Empty is a real pick (you walk in alone). `clearInstanceRoster` is what returns to auto.
2363
+ state.instanceRoster = { dungeonId, ids: picked };
2364
+ return { ok: true, dungeonId, picked: picked.length, cap };
2365
+ },
2366
+
2367
+ fillInstanceRoster(dungeonId) {
2368
+ const def = ds.instance(dungeonId);
2369
+ if (!def) return { ok: false, reason: 'Unknown dungeon' };
2370
+ const cap = maxCompanionsFor(def.maxPlayers);
2371
+ const owned: RosterPickBody[] = allCompanions().map((c) => ({
2372
+ id: c.id,
2373
+ cls: c.cls,
2374
+ role: c.role,
2375
+ level: companionLevel(c, state.char.level),
2376
+ }));
2377
+ if (!owned.length) return { ok: false, reason: 'You have no companions to bring' };
2378
+ const tpl = raidRosterTemplate({
2379
+ ds,
2380
+ def,
2381
+ playerRace: state.char.race,
2382
+ playerRole: state.squadRole ?? 'dps',
2383
+ playerCls: state.char.cls,
2384
+ });
2385
+ const ids = fillOwnedInstanceRoster({
2386
+ dungeonId,
2387
+ cap,
2388
+ owned,
2389
+ playerCls: state.char.cls,
2390
+ playerRole: state.squadRole ?? 'dps',
2391
+ seats: tpl?.seats ?? null,
2392
+ });
2393
+ if (!ids.length) return { ok: false, reason: 'No companion matched this instance' };
2394
+ state.instanceRoster = { dungeonId, ids };
2395
+ const mage = state.char.cls === CLASS.MAGE
2396
+ || ids.some((id) => owned.find((c) => c.id === id)?.cls === CLASS.MAGE);
2397
+ log(`${def.name}: ${ids.length}/${cap} from your roster${mage ? ' (mage in)' : ''}.`);
2398
+ return { ok: true, dungeonId, picked: ids.length, cap, mage };
2399
+ },
2400
+
2401
+ clearInstanceRoster() {
2402
+ const n = state.instanceRoster?.ids.length ?? 0;
2403
+ if (!n) return { ok: false, reason: 'No instance roster picked' };
2404
+ delete state.instanceRoster;
2405
+ log('Instance roster: back to auto.');
2406
+ return { ok: true, cleared: n };
2407
+ },
2408
+
2409
+ setRaidLineup(size, ids) {
2410
+ if (!isRaidLineupSize(size)) return { ok: false, reason: 'Raid size is 10, 20, or 40' };
2411
+ const cap = raidLineupCap(size);
2412
+ const ownedIds = new Set(allCompanions().map((c) => c.id));
2413
+ const picked = clampRosterPicks(ids, ownedIds, cap);
2414
+ state.raidLineup = { size, ids: picked };
2415
+ return { ok: true, size, picked: picked.length, cap };
2416
+ },
2417
+
2418
+ fillRaidLineup(size) {
2419
+ if (!isRaidLineupSize(size)) return { ok: false, reason: 'Raid size is 10, 20, or 40' };
2420
+ const cap = raidLineupCap(size);
2421
+ const owned: RosterPickBody[] = allCompanions().map((c) => ({
2422
+ id: c.id,
2423
+ cls: c.cls,
2424
+ role: c.role,
2425
+ level: companionLevel(c, state.char.level),
2426
+ }));
2427
+ if (!owned.length) return { ok: false, reason: 'You have no companions to bring' };
2428
+ const ids = fillOwnedInstanceRoster({
2429
+ dungeonId: '',
2430
+ cap,
2431
+ owned,
2432
+ playerCls: state.char.cls,
2433
+ playerRole: state.squadRole ?? 'dps',
2434
+ seats: null,
2435
+ });
2436
+ if (!ids.length) return { ok: false, reason: 'No companion matched this raid size' };
2437
+ state.raidLineup = { size, ids };
2438
+ log(`Raid group: ${ids.length}/${cap} from your roster (${size}-man).`);
2439
+ return { ok: true, size, picked: ids.length, cap };
2440
+ },
2441
+
2442
+ clearRaidLineup() {
2443
+ const n = state.raidLineup?.ids.length ?? 0;
2444
+ if (!state.raidLineup) return { ok: false, reason: 'No raid group picked' };
2445
+ delete state.raidLineup;
2446
+ log('Raid group: back to auto.');
2447
+ return { ok: true, cleared: n };
2448
+ },
2449
+
2450
+ raidRosterInfo() {
2451
+ const rr = state.raidRoster;
2452
+ if (!rr) return null;
2453
+ const members = Object.values(rr.members ?? {});
2454
+ if (!members.length) return null;
2455
+ const def = ds.instance(rr.dungeonId);
2456
+ const tier = RAID_TIERS[rr.tier] ?? RAID_TIERS.t1;
2457
+ const levels = members.map((c) => c.level ?? 1);
2458
+ let scores = 0;
2459
+ for (const c of members) {
2460
+ scores += displayGearScore(c.equipped, ds);
2461
+ }
2462
+ return {
2463
+ dungeonId: rr.dungeonId,
2464
+ name: def?.name ?? rr.dungeonId,
2465
+ tier: rr.tier,
2466
+ tierLabel: tier.label,
2467
+ size: def?.maxPlayers ?? members.length + 1,
2468
+ built: members.length,
2469
+ comp: describeRaidComp([
2470
+ ...members.map((c) => ({ cls: c.cls, role: c.role })),
2471
+ { cls: state.char.cls, role: state.squadRole ?? 'dps' },
2472
+ ]),
2473
+ gearScore: Math.round(scores / members.length),
2474
+ levels: { min: Math.min(...levels), max: Math.max(...levels) },
2475
+ };
2476
+ },
2477
+
2478
+ // Let companions go for good, refunding what they cost. Dismiss only benches, so nothing could
2479
+ // REMOVE one. The refund is exact: the cost is a pure function of level and kit profile.
2480
+ releaseCompanions(opts) {
2481
+ const idSet = opts.ids?.length ? new Set(opts.ids) : null;
2482
+ const maxLevel = opts.maxLevel;
2483
+ if (!idSet && maxLevel == null) return { ok: false, reason: 'Name companions or a level cap to release' };
2484
+ const wanted = (c: Companion): boolean => {
2485
+ if (idSet) return idSet.has(c.id);
2486
+ return (c.level ?? 1) <= (maxLevel ?? 0);
2487
+ };
2488
+ const squad = state.squad ?? [];
2489
+ const bench = { ...(state.botRoster ?? {}) };
2490
+ const goneNames: string[] = [];
2491
+ let refund = 0;
2492
+ const keep: Companion[] = [];
2493
+ const drop = (c: Companion): void => {
2494
+ goneNames.push(c.name);
2495
+ refund += hireCostCopper(c.level ?? 1, c.gearProfile ?? DEFAULT_HIRE_GEAR_PROFILE);
2496
+ };
2497
+ for (const c of squad) {
2498
+ if (wanted(c)) drop(c);
2499
+ else keep.push(c);
2500
+ }
2501
+ for (const [id, c] of Object.entries(bench)) {
2502
+ if (!wanted(c)) continue;
2503
+ drop(c);
2504
+ delete bench[id];
2505
+ }
2506
+ if (!goneNames.length) return { ok: false, reason: 'No companion matched' };
2507
+ state.squad = keep;
2508
+ state.botRoster = bench;
2509
+ reconcileGamePartyAuras(state, ds);
2510
+ if (refund > 0) state.char = { ...state.char, gold: state.char.gold + refund };
2511
+ log(`Released ${goneNames.length} companions - refunded ${hirePriceLabel(refund)}.`);
2512
+ return { ok: true, released: goneNames.length, refundCopper: refund, names: goneNames };
2513
+ },
2514
+
2515
+ // The inn-gated tavern trial: an auto-resolved duel against a slate candidate or a companion,
2516
+ // yielding at 1 HP with no gold, loot, XP or durability. Cite: domain/run/duel.ts.
2517
+ tavernDuel(targetId) {
2518
+ if (!innkeeperHere()) {
2519
+ return { ok: false, reason: 'Tavern trials are only at an inn' };
2520
+ }
2521
+ if (busyTravelling()) {
2522
+ return { ok: false, reason: 'You are travelling' };
2523
+ }
2524
+ // Same death gate as startFight / travel refuseIfDead (KI-861).
2525
+ {
2526
+ const dead = refuseIfDead(state.char.health, state.deathFlow);
2527
+ if (dead) return dead;
2528
+ }
2529
+
2530
+ // Resolve opponent: slate candidate → active squad → benched roster.
2531
+ let oppComp: Companion | undefined;
2532
+ let oppLevel: number | undefined;
2533
+ const cand = resolveCandidate(targetId);
2534
+ if (cand) {
2535
+ const role: Companion['role'] =
2536
+ cand.specRole === 'tank' || cand.specRole === 'healer' ? cand.specRole : 'dps';
2537
+ oppComp = {
2538
+ id: cand.bot.id,
2539
+ name: cand.bot.name,
2540
+ race: cand.bot.race,
2541
+ cls: cand.bot.cls,
2542
+ gender: cand.bot.gender,
2543
+ role,
2544
+ seed: cand.bot.seed,
2545
+ equipped: { ...cand.gear },
2546
+ level: cand.bot.level,
2547
+ specIdx: cand.bot.specIdx,
2548
+ gearProfile: cand.gearProfile,
2549
+ };
2550
+ oppLevel = cand.bot.level;
2551
+ } else {
2552
+ oppComp = findCompanion(targetId) ?? state.botRoster?.[targetId];
2553
+ if (oppComp) oppLevel = companionLevel(oppComp, state.char.level);
2554
+ }
2555
+ if (!oppComp || oppLevel == null) {
2556
+ return { ok: false, reason: 'That combatant is not available to trial' };
2557
+ }
2558
+ if (oppComp.name === state.char.name) {
2559
+ return { ok: false, reason: 'You cannot duel yourself' };
2560
+ }
2561
+
2562
+ // Slate already level-windows peers; relax MAX_DUEL_LEVEL_GAP for trials.
2563
+ const chk = canChallenge(
2564
+ { name: state.char.name, level: state.char.level },
2565
+ { name: oppComp.name, level: oppLevel },
2566
+ { ignoreLevelGap: true },
2567
+ );
2568
+ if (!chk.ok) return { ok: false, reason: chk.reason ?? 'Cannot start trial' };
2569
+
2570
+ // Real combatant shells — same buildCombatant / companionSpec path as outdoor fights.
2571
+ const playerSpec = buildCombatant(state.char, state.spellbook, ds, state.inventory);
2572
+ playerSpec.items = combatItems(state.char, state.inventory, ds);
2573
+ const targetSpec = companionSpec(oppComp, oppLevel, ds);
2574
+ // A trial is a duel, so the foe fights the pvp band and G16's matchup list, exactly as a
2575
+ // gauntlet rung is seated (arenaLab.ts:96-114). Without the provider a player-shaped enemy
2576
+ // spec only takes the class heuristics (types.ts:757), so it never ran its own list at all.
2577
+ const oppRotVariant = vsClassVariant(state.char.cls);
2578
+ const oppRot = companionRotationOf(oppComp, state.char.level, ds, undefined, {
2579
+ mode: 'pvp', ...(oppRotVariant ? { variant: oppRotVariant } : {}),
2580
+ });
2581
+ // Seeded stream: advance fightCounter so trials are deterministic and don't re-roll free.
2582
+ const rng = mulberry32(hashSeed(state.rngSeed, state.fightCounter++));
2583
+ // The trial is a FIGHT now, not a verdict: the caller mounts this session on the combat surface.
2584
+ // Still ephemeral — no live pointer, so it can never be settled for gold, XP or loot.
2585
+ const geo = seedDuelGeometry({
2586
+ player: playerSpec, enemySpecs: [targetSpec], spellOf: (id) => ds.spell(id),
2587
+ });
2588
+ const playerProvider = playerFightActionProvider(state, ds, ctx.opts);
2589
+ const session = createDuelSession(playerSpec, targetSpec, rng, {
2590
+ enemyActionProviders: [companionActionProvider(oppRot, undefined, {
2591
+ companion: oppComp,
2592
+ isOocKeepUp: (id) => isCompanionKeepUpSpell(ds.spell(id)),
2593
+ })],
2594
+ ...(playerProvider ? { playerActionProvider: playerProvider } : {}),
2595
+ unitPositionsYd: geo.positions,
2596
+ geometryAnchor: geo.anchor,
2597
+ });
2598
+ log(`Tavern trial vs ${oppComp.name}. Both yield at 1 HP - no stakes.`);
2599
+
2600
+ return {
2601
+ ok: true,
2602
+ session,
2603
+ targetName: oppComp.name,
2604
+ opponent: {
2605
+ race: oppComp.race,
2606
+ cls: oppComp.cls,
2607
+ gender: oppComp.gender,
2608
+ equipped: oppComp.equipped,
2609
+ },
2610
+ };
2611
+ },
2612
+
2613
+ setCompanionSpec(id, specIdx) {
2614
+ const comp = findCompanion(id);
2615
+ if (!comp) return { ok: false, reason: 'No such companion' };
2616
+ const specs = ds.botSpecs(comp.cls);
2617
+ if (specs.length === 0) return { ok: false, reason: 'No premade talent builds for this class' };
2618
+ if (!Number.isInteger(specIdx) || specIdx < 0 || specIdx >= specs.length) {
2619
+ return { ok: false, reason: 'Unknown talent build' };
2620
+ }
2621
+ const next = specs[specIdx]!;
2622
+ if (comp.specIdx === specIdx && !comp.talentLink) return { ok: true, name: next.name };
2623
+ comp.specIdx = specIdx;
2624
+ if (comp.talentLink) delete comp.talentLink;
2625
+ // Spec change invalidates the seeded rotation — rebuild on next ensure/fight
2626
+ // so Protection↔Arms actually swaps the combat list (not just talents).
2627
+ if (comp.rotation && !comp.rotation.custom) delete comp.rotation;
2628
+ log(`${comp.name} respecs to ${next.name}.`);
2629
+ return { ok: true, name: next.name };
2630
+ },
2631
+
2632
+ setCompanionTalentLink(id, link) {
2633
+ const comp = findCompanion(id);
2634
+ if (!comp) return { ok: false, reason: 'No such companion' };
2635
+ if (link == null || link === '') {
2636
+ if (!comp.talentLink) return { ok: true, name: comp.name };
2637
+ delete comp.talentLink;
2638
+ if (comp.rotation && !comp.rotation.custom) delete comp.rotation;
2639
+ log(`${comp.name} returns to a premade talent build.`);
2640
+ return { ok: true, name: comp.name };
2641
+ }
2642
+ const clean = link.trim();
2643
+ const lvl = companionLevel(comp, state.char.level);
2644
+ const want = talentsFromLink(clean, comp.cls, lvl, ds);
2645
+ if (!Object.values(want).some((n) => n > 0)) {
2646
+ return { ok: false, reason: `Nothing in that string fits a level ${lvl} ${comp.name}` };
2647
+ }
2648
+ if (comp.talentLink === clean) return { ok: true, name: comp.name };
2649
+ comp.talentLink = clean;
2650
+ if (comp.rotation && !comp.rotation.custom) delete comp.rotation;
2651
+ log(`${comp.name} uses a saved talent build.`);
2652
+ return { ok: true, name: comp.name };
2653
+ },
2654
+
2655
+ setCompanionRole(id, role) {
2656
+ // Role was hire-time-only purely by omission: the player re-signs their own role
2657
+ // anytime (setSquadRole) while companions needed a dismiss→inn-rehire round trip.
2658
+ const comp = findCompanion(id);
2659
+ if (!comp) return { ok: false, reason: 'No such companion' };
2660
+ if (!isSquadRole(role)) {
2661
+ return { ok: false, reason: 'Unknown role' };
2662
+ }
2663
+ if (comp.role === role) return { ok: true };
2664
+ // Re-stamp the matching talent premade when one exists, a free respec, so gear weights and
2665
+ // rotation bands follow the new job. An Arms-talented "tank" would otherwise get tank rules.
2666
+ applyCompanionRole(comp, role, ds);
2667
+ // Live fight: groupRole was snapshotted at pull — restamp the fighter + provider
2668
+ // so this pull sees tank/heal/dps now (not only the next one). Cite: docs/PLAN.md:43.
2669
+ const live = combat.getLiveFightSession();
2670
+ if (live && !live.ended && typeof live.setAllyGroupRole === 'function') {
2671
+ const idx = (state.squad ?? []).findIndex((c) => c.id === id);
2672
+ if (idx >= 0) {
2673
+ const groupRole = role === 'dps' ? 'damage' : role;
2674
+ const provider = companionActionProvider(
2675
+ ensureCompanionRotation(comp),
2676
+ undefined,
2677
+ {
2678
+ companion: comp,
2679
+ isMultiSpell: (sid) => isMultiTargetCombatSpell(ds.spell(sid)),
2680
+ isOocKeepUp: (sid) => isCompanionKeepUpSpell(ds.spell(sid)),
2681
+ },
2682
+ );
2683
+ live.setAllyGroupRole(`ally_${idx}`, groupRole, provider);
2684
+ }
2685
+ }
2686
+ log(`${comp.name} re-signs as ${role === 'dps' ? 'DPS' : role}.`);
2687
+ return { ok: true };
2688
+ },
2689
+
2690
+ setCompanionPet(id, creatureEntry) {
2691
+ const comp = findCompanion(id);
2692
+ if (!comp) return { ok: false, reason: 'No such companion' };
2693
+ const lvl = companionLevel(comp, state.char.level);
2694
+ const r = setCompanionPetCreature(comp, lvl, creatureEntry, ds);
2695
+ if (!r.ok) return { ok: false, reason: r.reason };
2696
+ comp.pet = r.pet;
2697
+ comp.petSpells = r.petSpells;
2698
+ log(`${comp.name} calls ${r.pet.name}.`);
2699
+ return { ok: true, name: r.pet.name };
2700
+ },
2701
+
2702
+ setCompanionPetAutocast(id, spellId, on) {
2703
+ const comp = findCompanion(id);
2704
+ if (!comp) return { ok: false, reason: 'No such companion' };
2705
+ const pet = comp.pet;
2706
+ if (!pet) return { ok: false, reason: 'Companion has no pet' };
2707
+ const sp = ds.spell(spellId);
2708
+ if (!sp) return { ok: false, reason: 'Unknown ability' };
2709
+ const sameChain = (sid: number) => sid === spellId || ds.spell(sid)?.name === sp.name;
2710
+ const off = pet.autocastOff ?? [];
2711
+ const wasOff = off.some(sameChain);
2712
+ // Explicit `on` wins; otherwise toggle.
2713
+ const wantOn = on !== undefined ? on : wasOff;
2714
+ let next: number[];
2715
+ if (wantOn) {
2716
+ next = off.filter((sid) => !sameChain(sid));
2717
+ } else {
2718
+ next = wasOff ? off : [...off, spellId];
2719
+ }
2720
+ comp.pet = { ...pet, ...(next.length ? { autocastOff: next } : { autocastOff: undefined }) };
2721
+ return { ok: true, enabled: wantOn };
2722
+ },
2723
+
2724
+ equipCompanionSlot(id, itemEntry, preferred) {
2725
+ const comp = findCompanion(id);
2726
+ if (!comp) return { ok: false, reason: 'No such companion' };
2727
+ const r = equipOnCompanion(comp, itemEntry, preferred);
2728
+ if (!r.ok) return { ok: false, reason: r.reason ?? 'Cannot be equipped' };
2729
+ log(`${comp.name} equips ${ds.item(itemEntry)?.name ?? itemEntry}.`);
2730
+ return { ok: true, slot: r.slot! };
2731
+ },
2732
+
2733
+ unequipCompanionSlot(id, slot) {
2734
+ const comp = findCompanion(id);
2735
+ if (!comp) return { ok: false, reason: 'No such companion' };
2736
+ const inst = equippedInstance(comp.equipped[slot]);
2737
+ if (inst == null) return { ok: false, reason: 'Nothing equipped there' };
2738
+ const idef = ds.item(inst.item);
2739
+ if (!idef) return { ok: false, reason: 'Unknown item' };
2740
+ // Their gear stays theirs: bags first, then bank (identity intact).
2741
+ const equipped = { ...comp.equipped };
2742
+ delete equipped[slot];
2743
+ const cap = bagCapacity(companionCharacter({ ...comp, equipped }, companionLevel(comp, state.char.level), ds), ds);
2744
+ let bags = comp.bags ?? emptyInventory();
2745
+ let bank = comp.bank ?? emptyInventory();
2746
+ const maxStack = idef.stackable ?? 1;
2747
+ const bCap = bankCapacity(comp.bankBags ?? [], ds);
2748
+ if (canFit(bags, inst.item, inst.count, maxStack, cap)) {
2749
+ bags = addInstance(bags, inst, maxStack);
2750
+ } else if (canFit(bank, inst.item, inst.count, maxStack, bCap)) {
2751
+ bank = addInstance(bank, inst, maxStack);
2752
+ } else {
2753
+ return { ok: false, reason: 'Inventory full' };
2754
+ }
2755
+ // Unequipping a bag shrinks capacity — refuse if remaining stacks would overflow.
2756
+ if (idef.slot === 'bag' && usedSlots(bags) > cap) {
2757
+ return { ok: false, reason: 'Inventory full' };
2758
+ }
2759
+ comp.equipped = equipped;
2760
+ // W5 D1 / gap 1: poison does not follow a companion weapon swap (after capacity ok).
2761
+ if ((slot === 'mainhand' || slot === 'offhand') && comp.tempEnchants?.[slot]) {
2762
+ const te = { ...comp.tempEnchants };
2763
+ delete te[slot];
2764
+ comp.tempEnchants = (te.mainhand || te.offhand) ? te : undefined;
2765
+ }
2766
+ comp.bags = bags;
2767
+ comp.bank = bank;
2768
+ log(`${comp.name} unequips ${idef.name}.`);
2769
+ return { ok: true };
2770
+ },
2771
+
2772
+ // One gearing view for the whole roster: the player's BANK is a source, and a shared stack must be
2773
+ // CLAIMED — highest delta wins, so Apply all cannot hand out the same axe twice.
2774
+ squadUpgradePlan() {
2775
+ return doSquadUpgradePlan();
2776
+ },
2777
+
2778
+ applySquadUpgrades(max = 40, allowKeys) {
2779
+ let equipped = 0;
2780
+ const skipped = new Set<string>();
2781
+ const allow = allowKeys ? new Set(allowKeys) : null;
2782
+ for (let guard = 0; guard < max * 2 && equipped < max; guard++) {
2783
+ const next = doSquadUpgradePlan().find((r) => {
2784
+ const key = sendGoodsUpgradeKey(r);
2785
+ if (skipped.has(key)) return false;
2786
+ if (allow && !allow.has(key)) return false;
2787
+ return true;
2788
+ });
2789
+ if (!next) break;
2790
+ const key = sendGoodsUpgradeKey(next);
2791
+ const comp = findCompanion(next.companionId);
2792
+ if (!comp) { skipped.add(key); continue; }
2793
+ // equipOnCompanion sources their bags then yours — a bank row has to surface first.
2794
+ if (next.from === 'bank' && !withdrawForEquip(next.entry)) { skipped.add(key); continue; }
2795
+ // A mate's row crosses through your bags: take it from the holder, then equip from yours.
2796
+ if (next.from === 'mate' && !(next.holderId && self.takeItemFromBot(next.holderId, next.entry, 1).ok)) {
2797
+ skipped.add(key);
2798
+ continue;
2799
+ }
2800
+ const r = equipOnCompanion(comp, next.entry, next.targetSlot);
2801
+ if (r.ok) {
2802
+ equipped++;
2803
+ log(`${comp.name} equips ${ds.item(next.entry)?.name ?? next.entry}.`);
2804
+ } else {
2805
+ skipped.add(key);
2806
+ }
2807
+ }
2808
+ return { ok: true as const, equipped };
2809
+ },
2810
+
2811
+ // The player's own suggestion row: a companion-held piece comes through your bags first.
2812
+ equipSuggestedUpgrade(itemEntry) {
2813
+ const hit = gear.suggestedEquipmentUpgrades().find((u) => u.entry === itemEntry);
2814
+ if (hit?.holderId) {
2815
+ const t = self.takeItemFromBot(hit.holderId, itemEntry, 1);
2816
+ if (!t.ok) return { ok: false, reason: t.reason };
2817
+ }
2818
+ return gear.equip(itemEntry, hit?.slot);
2819
+ },
2820
+
2821
+ // ---- share consumables ---------------------------------------------------------------
2822
+
2823
+ consumableShareState() {
2824
+ return normalizeConsumableShare(state.consumableShare);
2825
+ },
2826
+
2827
+ // Party is the four seats; raid is the lineup picked on the 10/20/40 tab, and falls back to the
2828
+ // party when no lineup is set. Same pick as instanceRoster.ts pickInstanceRoster.
2829
+ consumableShareMembers() {
2830
+ const share = normalizeConsumableShare(state.consumableShare);
2831
+ const party = state.squad ?? [];
2832
+ let comps: Companion[] = party;
2833
+ if (share.scope === 'raid' && state.raidLineup?.ids.length) {
2834
+ const seen = new Set<string>();
2835
+ comps = [];
2836
+ for (const id of state.raidLineup.ids) {
2837
+ const c = findCompanion(id);
2838
+ if (c && !seen.has(c.id)) { seen.add(c.id); comps.push(c); }
2839
+ }
2840
+ }
2841
+ return comps.map((c) => shareMemberOf(c, state.char.level, ds));
2842
+ },
2843
+
2844
+ // Player bags, bank, and every seated companion's bags are one pool. A bank row and a
2845
+ // companion over their share are withdrawn on apply, then the short seats are filled.
2846
+ squadConsumableDetail() {
2847
+ const share = normalizeConsumableShare(state.consumableShare);
2848
+ const off = new Set(share.off ?? []);
2849
+ return planConsumableShare({
2850
+ ds,
2851
+ inventory: { stacks: [...state.inventory.stacks, ...(state.bank ?? emptyInventory()).stacks] },
2852
+ members: self.consumableShareMembers(),
2853
+ rules: share.rules,
2854
+ includeYou: share.includeYou ?? true,
2855
+ you: shareMemberOfYou(state.char, state.spellbook.known, ds),
2856
+ cats: SHARE_CATEGORIES.filter((c) => !off.has(c)),
2857
+ });
2858
+ },
2859
+
2860
+ squadConsumablePlan() {
2861
+ return shareRowsOf(self.squadConsumableDetail());
2862
+ },
2863
+
2864
+ applySquadConsumablePlan(pick) {
2865
+ const want = typeof pick === 'number' ? { entry: pick } : (pick ?? {});
2866
+ const detail = self.squadConsumableDetail().filter((e) =>
2867
+ (want.entry == null || e.entry === want.entry) && (want.key == null || e.key === want.key));
2868
+ let moved = 0;
2869
+ let rows = 0;
2870
+ const skipped: string[] = [];
2871
+ for (const e of detail) {
2872
+ for (const t of e.takes) {
2873
+ const r = self.takeItemFromBot(t.companionId, t.entry, t.count);
2874
+ if (!r.ok) skipped.push(`${t.companionName}: ${r.reason}`);
2875
+ }
2876
+ for (const row of e.rows) {
2877
+ const short = row.count - countItem(state.inventory, row.entry);
2878
+ for (let i = 0; i < short; i++) if (!withdrawForEquip(row.entry)) break;
2879
+ const r = self.giveItemToBot(row.companionId, row.entry, row.count);
2880
+ if (r.ok) { moved += r.moved; rows++; } else { skipped.push(`${row.companionName}: ${r.reason}`); }
2881
+ }
2882
+ }
2883
+ return { ok: true as const, moved, rows, skipped };
2884
+ },
2885
+
2886
+ setConsumableShareRule(key, rule) {
2887
+ const share = normalizeConsumableShare(state.consumableShare);
2888
+ if (rule == null || rule.kind === 'auto') delete share.rules[key];
2889
+ else share.rules[key] = rule;
2890
+ state.consumableShare = share;
2891
+ return { ok: true as const };
2892
+ },
2893
+
2894
+ setConsumableShareScope(scope) {
2895
+ const share = normalizeConsumableShare(state.consumableShare);
2896
+ state.consumableShare = { ...share, scope };
2897
+ return { ok: true as const };
2898
+ },
2899
+
2900
+ setConsumableShareIncludeYou(on) {
2901
+ const share = normalizeConsumableShare(state.consumableShare);
2902
+ state.consumableShare = { ...share, includeYou: on };
2903
+ return { ok: true as const };
2904
+ },
2905
+
2906
+ setConsumableShareCat(cat, on) {
2907
+ if (!(SHARE_CATEGORIES as readonly string[]).includes(cat)) return { ok: false as const, reason: 'Unknown kit' };
2908
+ const share = normalizeConsumableShare(state.consumableShare);
2909
+ const off = new Set(share.off ?? []);
2910
+ if (on) off.delete(cat);
2911
+ else off.add(cat);
2912
+ state.consumableShare = { ...share, off: off.size ? [...off] : undefined };
2913
+ return { ok: true as const };
2914
+ },
2915
+
2916
+ // ---- professions + zone missions + party vault craft --------------------------------
2917
+
2918
+ companionProfessionsOf(id) {
2919
+ const comp = findCompanion(id);
2920
+ if (!comp) return null;
2921
+ ensureMissionShape(comp);
2922
+ const lvl = companionLevel(comp, state.char.level);
2923
+ const skills = companionSkillMap(comp, lvl);
2924
+ // Keep trainer/vendor catalog and trainer ranks in sync with skill (free for companions).
2925
+ syncCompanionProfessionCatalog(comp, skills, ds);
2926
+ announceCompanionRanks(comp, lvl, ds, log);
2927
+ return {
2928
+ cap: professionSkillCap(lvl),
2929
+ assigned: comp.professions != null && Object.keys(comp.professions).length > 0,
2930
+ lines: (Object.keys(skills) as SkillLine[]).map((line) => ({
2931
+ line,
2932
+ current: skills[line]!.current,
2933
+ max: skills[line]!.max,
2934
+ kind: ds.profession(line)?.kind ?? 'crafting',
2935
+ rank: companionTrainedRank(line, skills[line]!.current, lvl)?.name,
2936
+ })),
2937
+ };
2938
+ },
2939
+
2940
+ // Take up a profession. The first call FREEZES the seed-derived set into `professions`, so learning one
2941
+ // thing does not silently wipe the two the body arrived with.
2942
+ learnCompanionProfession(id, line) {
2943
+ const comp = findCompanion(id);
2944
+ if (!comp) return { ok: false, reason: 'No such companion' };
2945
+ const lvl = companionLevel(comp, state.char.level);
2946
+ if (comp.professions == null || Object.keys(comp.professions).length === 0) {
2947
+ const derived = companionSkillMap(comp, lvl);
2948
+ const frozen: CompanionProfessionMap = {};
2949
+ for (const k of Object.keys(derived) as SkillLine[]) frozen[k] = derived[k]!.current;
2950
+ comp.professions = frozen;
2951
+ }
2952
+ const gate = canCompanionLearnProfession(comp, line, ds, lvl);
2953
+ if (!gate.ok) return { ok: false, reason: gate.reason };
2954
+ comp.professions = { ...comp.professions, [line]: 1 };
2955
+ const skills = companionSkillMap(comp, lvl);
2956
+ syncCompanionProfessionCatalog(comp, skills, ds);
2957
+ // Tools for the new line (pick, knife, rod, …).
2958
+ const shell = companionCharacter(comp, lvl, ds);
2959
+ shell.skills = skills;
2960
+ shell.knownRecipes = comp.knownRecipes ?? [];
2961
+ shell.knownEnchants = comp.knownEnchants ?? [];
2962
+ comp.bags = initInventorySkill(shell, comp.bags ?? emptyInventory(), ds);
2963
+ log(`${comp.name} takes up ${ds.profession(line)?.name ?? line}.`);
2964
+ announceCompanionRanks(comp, lvl, ds, log);
2965
+ return { ok: true };
2966
+ },
2967
+
2968
+ unlearnCompanionProfession(id, line) {
2969
+ const comp = findCompanion(id);
2970
+ if (!comp) return { ok: false, reason: 'No such companion' };
2971
+ const lvl = companionLevel(comp, state.char.level);
2972
+ if (comp.professions == null || Object.keys(comp.professions).length === 0) {
2973
+ const derived = companionSkillMap(comp, lvl);
2974
+ const frozen: CompanionProfessionMap = {};
2975
+ for (const k of Object.keys(derived) as SkillLine[]) frozen[k] = derived[k]!.current;
2976
+ comp.professions = frozen;
2977
+ }
2978
+ if (comp.professions[line] == null) return { ok: false, reason: 'They do not know that' };
2979
+ ensureMissionShape(comp);
2980
+ if (comp.mission?.skill === line) return { ok: false, reason: 'They are away on that profession' };
2981
+ const next = { ...comp.professions };
2982
+ delete next[line];
2983
+ comp.professions = next;
2984
+ // Drop recipes for that line only.
2985
+ if (comp.knownRecipes?.length) {
2986
+ const drop = new Set(ds.recipes().filter((r) => r.skill === line).map((r) => r.id));
2987
+ comp.knownRecipes = comp.knownRecipes.filter((id) => !drop.has(id));
2988
+ }
2989
+ if (line === 'enchanting') comp.knownEnchants = [];
2990
+ log(`${comp.name} abandons ${ds.profession(line)?.name ?? line}.`);
2991
+ return { ok: true };
2992
+ },
2993
+
2994
+ companionPartyEnergy(nowMs = Date.now()) {
2995
+ return partyEnergy(state.companionEnergy, nowMs);
2996
+ },
2997
+
2998
+ companionMissionZones(id, skill) {
2999
+ const comp = findCompanion(id);
3000
+ if (!comp || !isMissionSkill(skill)) return [];
3001
+ const lvl = companionLevel(comp, state.char.level);
3002
+ const skills = companionSkillMap(comp, lvl);
3003
+ const cur = missionWorkSkill(skill, skills[skill]?.current, lvl);
3004
+ if (cur == null || cur <= 0) return [];
3005
+ return recommendGatherZones(skill, cur, ds, {
3006
+ skinCreaturesByZone: skill === 'skinning' ? skinCreaturesByZone() : undefined,
3007
+ limit: 12,
3008
+ }).map((r) => ({
3009
+ zoneId: r.zoneId,
3010
+ zoneName: r.zoneName,
3011
+ preview: r.preview,
3012
+ targetCount: r.targetCount,
3013
+ }));
3014
+ },
3015
+
3016
+ sendCompanionOnMission(id, skill, zoneId, hours, nowMs = Date.now()) {
3017
+ const comp = findCompanion(id);
3018
+ if (!comp) return { ok: false, reason: 'No such companion' };
3019
+ ensureMissionShape(comp);
3020
+ if (comp.mission) return { ok: false, reason: `${comp.name} is already away` };
3021
+ if (state.instance) return { ok: false, reason: 'Not while you are in a dungeon' };
3022
+ if (!isMissionSkill(skill)) return { ok: false, reason: 'Only mining, herbalism, or skinning' };
3023
+ const h = Math.max(1, Math.min(MAX_MISSION_HOURS, Math.floor(hours)));
3024
+ const cost = energyCostForHours(h);
3025
+ const lvl = companionLevel(comp, state.char.level);
3026
+ const skills = companionSkillMap(comp, lvl);
3027
+ const atSkill = missionWorkSkill(skill, skills[skill]?.current, lvl);
3028
+ if (atSkill == null) return { ok: false, reason: `${comp.name} does not have that profession` };
3029
+ const zone = ds.zone(zoneId);
3030
+ if (!zone) return { ok: false, reason: 'Unknown zone' };
3031
+ const nodes = missionNodesFor({ skill, zoneId, atSkill });
3032
+ if (!nodes.length) {
3033
+ return {
3034
+ ok: false,
3035
+ reason: `Nothing in ${zone.name} for ${comp.name} to work at ${ds.profession(skill)?.name ?? skill} ${atSkill}`,
3036
+ };
3037
+ }
3038
+ const energy = partyEnergy(state.companionEnergy, nowMs);
3039
+ if (energy < cost) {
3040
+ return {
3041
+ ok: false,
3042
+ reason: `Need ${cost} energy (have ${energy}). Pool refills at 06:00 local.`,
3043
+ };
3044
+ }
3045
+ state.companionEnergy = spendPartyEnergy(state.companionEnergy, cost, nowMs);
3046
+ comp.mission = {
3047
+ skill,
3048
+ zoneId,
3049
+ hours: h,
3050
+ energyCost: cost,
3051
+ departedAtEpochMs: nowMs,
3052
+ atLevel: lvl,
3053
+ atSkill,
3054
+ };
3055
+ log(
3056
+ `${comp.name} heads to ${zone.name} to ${skill === 'skinning' ? 'skin' : skill === 'herbalism' ? 'herb' : 'mine'} for ${h}h (${cost} energy).`,
3057
+ );
3058
+ return { ok: true, mission: comp.mission };
3059
+ },
3060
+
3061
+ recallCompanionMission(id) {
3062
+ const comp = findCompanion(id);
3063
+ if (!comp) return { ok: false, reason: 'No such companion' };
3064
+ ensureMissionShape(comp);
3065
+ if (!comp.mission) return { ok: false, reason: 'They are not away' };
3066
+ // Recall forfeits the payout; energy is already spent and is NOT refunded.
3067
+ delete comp.mission;
3068
+ log(`${comp.name} comes home early with nothing to show.`);
3069
+ return { ok: true };
3070
+ },
3071
+
3072
+ collectCompanionMission(id, nowMs = Date.now()) {
3073
+ const comp = findCompanion(id);
3074
+ if (!comp) return { ok: false, reason: 'No such companion' };
3075
+ ensureMissionShape(comp);
3076
+ const m = comp.mission;
3077
+ if (!m) return { ok: false, reason: 'They are not away' };
3078
+ const p = missionProgress(m, nowMs);
3079
+ if (!p.done) return { ok: false, reason: `${comp.name} is still away - ${missionEtaLabel(p)}` };
3080
+
3081
+ const nodes = missionNodesFor(m);
3082
+ const rows = zoneMissionYield(m, comp, nodes);
3083
+ const yielded: Array<{ item: number; count: number }> = [];
3084
+ const ctx = vaultCtx();
3085
+ const mut = vaultMut();
3086
+ for (const row of rows) {
3087
+ const idef = ds.item(row.item);
3088
+ if (!idef) continue;
3089
+ const stack = idef.stackable ?? 1;
3090
+ const { deposited, remaining } = partyDeposit(ctx, mut, row.item, row.count, stack, comp.id);
3091
+ if (deposited > 0) yielded.push({ item: row.item, count: deposited });
3092
+ if (remaining > 0) {
3093
+ log(`${comp.name} could not store ${remaining}× ${idef.name} - party bags full.`);
3094
+ }
3095
+ }
3096
+
3097
+ let skillUp = gatherMissionSkillGain(m);
3098
+ if (skillUp > 0) {
3099
+ const base = comp.professions?.[m.skill] ?? m.atSkill;
3100
+ // Freeze seed-derived set if still implicit.
3101
+ if (comp.professions == null || Object.keys(comp.professions).length === 0) {
3102
+ const derived = companionSkillMap(comp, companionLevel(comp, state.char.level));
3103
+ const frozen: CompanionProfessionMap = {};
3104
+ for (const k of Object.keys(derived) as SkillLine[]) frozen[k] = derived[k]!.current;
3105
+ comp.professions = frozen;
3106
+ }
3107
+ comp.professions = { ...(comp.professions ?? {}), [m.skill]: base + skillUp };
3108
+ const skills = companionSkillMap(comp, companionLevel(comp, state.char.level));
3109
+ syncCompanionProfessionCatalog(comp, skills, ds);
3110
+ announceCompanionRanks(comp, companionLevel(comp, state.char.level), ds, log);
3111
+ }
3112
+
3113
+ delete comp.mission;
3114
+ const zoneName = ds.zone(m.zoneId)?.name ?? m.zoneId;
3115
+ const what = yielded.length
3116
+ ? yielded.map((y) => `${y.count}× ${ds.item(y.item)?.name ?? y.item}`).join(', ')
3117
+ : 'nothing usable';
3118
+ log(`${comp.name} returns from ${zoneName}: ${what}${skillUp ? ` (+${skillUp} skill)` : ''}.`);
3119
+ return { ok: true, skillUp, yielded };
3120
+ },
3121
+
3122
+ previewCompanionMission(id, nowMs = Date.now()) {
3123
+ const comp = findCompanion(id);
3124
+ if (!comp) return null;
3125
+ ensureMissionShape(comp);
3126
+ const m = comp.mission;
3127
+ if (!m) return null;
3128
+ const nodes = missionNodesFor(m);
3129
+ return {
3130
+ skillUp: gatherMissionSkillGain(m),
3131
+ yielded: zoneMissionYield(m, comp, nodes),
3132
+ progress: missionProgress(m, nowMs),
3133
+ };
3134
+ },
3135
+
3136
+ collectAllCompanionMissions(nowMs = Date.now()) {
3137
+ const bodies = [...(state.squad ?? []), ...Object.values(state.botRoster ?? {})];
3138
+ const collected: Array<{
3139
+ id: string;
3140
+ name: string;
3141
+ skillUp: number;
3142
+ yielded: Array<{ item: number; count: number }>;
3143
+ }> = [];
3144
+ for (const c of bodies) {
3145
+ ensureMissionShape(c);
3146
+ if (!c.mission) continue;
3147
+ if (!missionProgress(c.mission, nowMs).done) continue;
3148
+ const res = self.collectCompanionMission(c.id, nowMs);
3149
+ if (res.ok) collected.push({ id: c.id, name: c.name, skillUp: res.skillUp, yielded: res.yielded });
3150
+ }
3151
+ return { ok: true as const, collected };
3152
+ },
3153
+
3154
+ craftAsCompanion(id, recipeId) {
3155
+ const comp = findCompanion(id);
3156
+ if (!comp) return { ok: false, reason: 'No such companion' };
3157
+ if (comp.mission) return { ok: false, reason: `${comp.name} is away` };
3158
+ const recipe = ds.recipe(recipeId);
3159
+ if (!recipe) return { ok: false, reason: 'Unknown recipe' };
3160
+ const lvl = companionLevel(comp, state.char.level);
3161
+ const skills = companionSkillMap(comp, lvl);
3162
+ syncCompanionProfessionCatalog(comp, skills, ds);
3163
+ if (!(comp.knownRecipes ?? []).includes(recipeId)) {
3164
+ return { ok: false, reason: `${comp.name} does not know that recipe` };
3165
+ }
3166
+ const bought = buyVaultVendorReagents(recipe.reagents, comp.id);
3167
+ if (!bought.ok) return { ok: false, reason: bought.reason };
3168
+ // Pull reagents from the party vault into the companion's bags, then craft there.
3169
+ const ctx = vaultCtx();
3170
+ const mut = vaultMut();
3171
+ const takenRows: Array<{ item: number; count: number }> = [];
3172
+ for (const rg of recipe.reagents) {
3173
+ const taken = partyTake(ctx, mut, rg.item, rg.count, comp.id);
3174
+ if (taken < rg.count) {
3175
+ // Refund partial takes.
3176
+ for (const row of takenRows) {
3177
+ const idef = ds.item(row.item);
3178
+ partyDeposit(ctx, mut, row.item, row.count, idef?.stackable ?? 1, comp.id);
3179
+ }
3180
+ if (taken > 0) {
3181
+ const idef = ds.item(rg.item);
3182
+ partyDeposit(ctx, mut, rg.item, taken, idef?.stackable ?? 1, comp.id);
3183
+ }
3184
+ return { ok: false, reason: `Missing reagents for ${recipe.name}` };
3185
+ }
3186
+ takenRows.push({ item: rg.item, count: rg.count });
3187
+ }
3188
+ // Re-add debited reagents onto companion bags so craft() can consume them in one inv.
3189
+ let workBags = comp.bags ?? emptyInventory();
3190
+ for (const rg of recipe.reagents) {
3191
+ const idef = ds.item(rg.item);
3192
+ workBags = addItem(workBags, rg.item, rg.count, idef?.stackable ?? 1);
3193
+ }
3194
+
3195
+ const shell = companionCharacter(comp, lvl, ds);
3196
+ shell.skills = skills;
3197
+ shell.knownRecipes = comp.knownRecipes ?? [];
3198
+ shell.knownEnchants = comp.knownEnchants ?? [];
3199
+ const rng = mulberry32(hashSeed(state.rngSeed, state.fightCounter++));
3200
+ const productStack = ds.item(recipe.product.item)?.stackable ?? 1;
3201
+ const cap = bagCapacity(shell, ds);
3202
+ // Signature matches engine/game/professions.ts craft path. A companion is its own body, so
3203
+ // the transmute cooldown reads and stamps THEIR clocks, not the player's.
3204
+ const now = Date.now();
3205
+ const res = doCraft(
3206
+ shell, workBags, recipe, productStack, rng, cap, ds, undefined, undefined,
3207
+ { spellCooldowns: comp.spellCooldowns, categoryCooldowns: comp.categoryCooldowns, now },
3208
+ partyTools(),
3209
+ );
3210
+ if (!res.ok) {
3211
+ // Reagents stay on the companion if craft refused after we staged them.
3212
+ comp.bags = res.inventory;
3213
+ if (res.reason === 'Not ready yet') {
3214
+ const rem = recipeCooldownRemainingMs(recipe, comp, now);
3215
+ return { ok: false, reason: `${comp.name} is ready in ${craftCooldownLabel(rem)}` };
3216
+ }
3217
+ return { ok: false, reason: res.reason ?? 'Cannot craft' };
3218
+ }
3219
+ stampRecipeCooldown(recipe, {
3220
+ spellCooldowns: (comp.spellCooldowns ??= {}),
3221
+ categoryCooldowns: (comp.categoryCooldowns ??= {}),
3222
+ }, now);
3223
+ if (res.skillUp != null && res.char.skills[recipe.skill]) {
3224
+ const v = res.char.skills[recipe.skill]!.current;
3225
+ if (comp.professions == null || Object.keys(comp.professions).length === 0) {
3226
+ const frozen: CompanionProfessionMap = {};
3227
+ for (const k of Object.keys(skills) as SkillLine[]) frozen[k] = skills[k]!.current;
3228
+ comp.professions = frozen;
3229
+ }
3230
+ comp.professions = { ...(comp.professions ?? {}), [recipe.skill]: v };
3231
+ syncCompanionProfessionCatalog(comp, companionSkillMap(comp, lvl), ds);
3232
+ announceCompanionRanks(comp, lvl, ds, log);
3233
+ }
3234
+ comp.bags = res.inventory;
3235
+ const product = recipe.product.item;
3236
+ const pname = ds.item(product)?.name ?? product;
3237
+ const made = res.produced?.count ?? 1;
3238
+ log(`${comp.name} crafts ${made}× ${pname}.${res.skillUp ? ` (${recipe.skill} → ${res.skillUp})` : ''}`);
3239
+ return { ok: true, product, count: made, skillUp: res.skillUp, ...(bought.cost ? { spent: bought.cost } : {}) };
3240
+ },
3241
+
3242
+ enchantAsCompanion(enchanterId, enchantId, slot, target) {
3243
+ const enchanter = findCompanion(enchanterId);
3244
+ if (!enchanter) return { ok: false, reason: 'No such companion' };
3245
+ if (enchanter.mission) return { ok: false, reason: `${enchanter.name} is away` };
3246
+ const enchant = ds.enchant(enchantId);
3247
+ if (!enchant) return { ok: false, reason: 'Unknown enchant' };
3248
+ if (target !== enchanterId && hasAttrEx2(ds.spell(enchant.spellId), SPELL_ATTR_EX2.ENCHANT_OWN_ITEM_ONLY)) {
3249
+ return { ok: false, reason: 'They can only enchant their own items with that' };
3250
+ }
3251
+ const lvl = companionLevel(enchanter, state.char.level);
3252
+ const skills = companionSkillMap(enchanter, lvl);
3253
+ syncCompanionProfessionCatalog(enchanter, skills, ds);
3254
+ if (!(enchanter.knownEnchants ?? []).includes(enchantId)) {
3255
+ return { ok: false, reason: `${enchanter.name} does not know that formula` };
3256
+ }
3257
+ const bought = buyVaultVendorReagents(enchant.reagents, enchanter.id);
3258
+ if (!bought.ok) return { ok: false, reason: bought.reason };
3259
+ // Pull reagents into enchanter bags.
3260
+ const ctx = vaultCtx();
3261
+ const mut = vaultMut();
3262
+ for (const rg of enchant.reagents) {
3263
+ const taken = partyTake(ctx, mut, rg.item, rg.count, enchanter.id);
3264
+ if (taken < rg.count) {
3265
+ if (taken > 0) {
3266
+ const idef = ds.item(rg.item);
3267
+ partyDeposit(ctx, mut, rg.item, taken, idef?.stackable ?? 1, enchanter.id);
3268
+ }
3269
+ return { ok: false, reason: 'Missing reagents' };
3270
+ }
3271
+ }
3272
+ let workBags = enchanter.bags ?? emptyInventory();
3273
+ for (const rg of enchant.reagents) {
3274
+ const idef = ds.item(rg.item);
3275
+ workBags = addItem(workBags, rg.item, rg.count, idef?.stackable ?? 1);
3276
+ }
3277
+ enchanter.bags = workBags;
3278
+
3279
+ // Resolve wearer paperdoll.
3280
+ let wearEquipped = state.char.equipped;
3281
+ let wearEnchants = state.char.enchants;
3282
+ let targetComp: Companion | null = null;
3283
+ if (target.kind === 'companion') {
3284
+ targetComp = findCompanion(target.id) ?? null;
3285
+ if (!targetComp) return { ok: false, reason: 'Target companion not found' };
3286
+ wearEquipped = targetComp.equipped;
3287
+ wearEnchants = undefined;
3288
+ }
3289
+
3290
+ const shell = companionCharacter(enchanter, lvl, ds);
3291
+ shell.skills = skills;
3292
+ shell.knownRecipes = enchanter.knownRecipes ?? [];
3293
+ shell.knownEnchants = enchanter.knownEnchants ?? [];
3294
+ shell.equipped = wearEquipped;
3295
+ shell.enchants = wearEnchants;
3296
+
3297
+ const rng = mulberry32(hashSeed(state.rngSeed, state.fightCounter++));
3298
+ const res = doApplyEnchant(shell, workBags, ds, enchant, slot, rng, partyTools());
3299
+ if (!res.ok) {
3300
+ enchanter.bags = res.inventory;
3301
+ return { ok: false, reason: res.reason ?? 'Cannot enchant' };
3302
+ }
3303
+ enchanter.bags = res.inventory;
3304
+ if (target.kind === 'self') {
3305
+ state.char.equipped = res.char.equipped;
3306
+ state.char.enchants = res.char.enchants;
3307
+ } else if (targetComp) {
3308
+ targetComp.equipped = res.char.equipped;
3309
+ }
3310
+ if (res.skillUp != null && res.char.skills.enchanting) {
3311
+ const v = res.char.skills.enchanting.current;
3312
+ if (enchanter.professions == null || Object.keys(enchanter.professions).length === 0) {
3313
+ const frozen: CompanionProfessionMap = {};
3314
+ for (const k of Object.keys(skills) as SkillLine[]) frozen[k] = skills[k]!.current;
3315
+ enchanter.professions = frozen;
3316
+ }
3317
+ enchanter.professions = { ...(enchanter.professions ?? {}), enchanting: v };
3318
+ syncCompanionProfessionCatalog(enchanter, companionSkillMap(enchanter, lvl), ds);
3319
+ }
3320
+ const who = target.kind === 'self' ? 'you' : (targetComp?.name ?? 'them');
3321
+ const targetName = (() => {
3322
+ const raw = target.kind === 'self' ? state.char.equipped[slot] : targetComp!.equipped[slot];
3323
+ const entry = typeof raw === 'number' ? raw : (raw as { item?: number } | undefined)?.item;
3324
+ return ds.item(entry ?? 0)?.name ?? slot;
3325
+ })();
3326
+ log(
3327
+ `${enchanter.name} applies ${enchant.name} to ${who}'s ${targetName}.` +
3328
+ `${res.skillUp ? ` (enchanting → ${res.skillUp})` : ''}`,
3329
+ );
3330
+ return { ok: true, skillUp: res.skillUp };
3331
+ },
3332
+
3333
+ companionUpgradesFor(id, playerInv) {
3334
+ const comp = findCompanion(id);
3335
+ if (!comp) return [];
3336
+ const lvl = companionLevel(comp, state.char.level);
3337
+ const char = companionCharacter(comp, lvl, ds);
3338
+ const sb = companionSpellbook(comp, lvl, ds);
3339
+ const equipOpts: EquipOpts = {
3340
+ canDualWield: knows(sb, DUAL_WIELD_SPELL_ID),
3341
+ specId: companionPlayerbotSpecId(comp.cls, comp.specIdx, comp.role, ds),
3342
+ role: comp.role,
3343
+ };
3344
+ const tag = (from: 'bot' | 'player', inv: Inventory) =>
3345
+ findUpgrades(char, inv, ds, equipOpts).map((u) => ({
3346
+ entry: u.entry,
3347
+ slot: u.targetSlot,
3348
+ randomPropertyId: u.randomPropertyId,
3349
+ currentEntry: u.currentEntry,
3350
+ delta: u.valueDelta,
3351
+ from,
3352
+ }));
3353
+ return [
3354
+ ...tag('bot', comp.bags ?? emptyInventory()),
3355
+ ...tag('player', playerInv ?? state.inventory),
3356
+ ];
3357
+ },
3358
+
3359
+ learnRecipeFromItemOnCompanion(companionId, itemEntry) {
3360
+ const comp = findCompanion(companionId);
3361
+ if (!comp) return { ok: false, reason: 'No such companion' };
3362
+ if (comp.mission) return { ok: false, reason: `${comp.name} is away` };
3363
+ const it = ds.item(itemEntry);
3364
+ const teach = itemTeachIntent(it, ds);
3365
+ if (!it || teach?.kind !== 'recipe') return { ok: false, reason: 'That item teaches no recipe' };
3366
+ if (countItem(state.inventory, itemEntry) <= 0) return { ok: false, reason: "You don't have that recipe" };
3367
+ const recipe = ds.recipe(teach.id);
3368
+ if (!recipe) return { ok: false, reason: 'That recipe is unknown' };
3369
+ const lvl = companionLevel(comp, state.char.level);
3370
+ const skills = companionSkillMap(comp, lvl);
3371
+ syncCompanionProfessionCatalog(comp, skills, ds);
3372
+ const skill = skills[recipe.skill];
3373
+ if (!skill) {
3374
+ return {
3375
+ ok: false,
3376
+ reason: `${comp.name} needs ${ds.profession(recipe.skill)?.name ?? recipe.skill} first`,
3377
+ };
3378
+ }
3379
+ // Party teach: companion must meet orange (craft gate). Classic player item-learn can
3380
+ // read early; bots only take a book they can use.
3381
+ if (skill.current < (recipe.orange | 0)) {
3382
+ const pname = ds.profession(recipe.skill)?.name ?? recipe.skill;
3383
+ return {
3384
+ ok: false,
3385
+ reason: `${comp.name} needs ${pname} ${recipe.orange} (has ${skill.current})`,
3386
+ };
3387
+ }
3388
+ if ((comp.knownRecipes ?? []).includes(recipe.id)) {
3389
+ return { ok: false, reason: `${comp.name} already knows that` };
3390
+ }
3391
+ state.inventory = removeItem(state.inventory, itemEntry, 1)[0];
3392
+ comp.knownRecipes = [...(comp.knownRecipes ?? []), recipe.id];
3393
+ log(`${comp.name} studies ${it.name} and learns to craft ${recipe.name}.`);
3394
+ return { ok: true, recipeId: recipe.id, name: recipe.name };
3395
+ },
3396
+
3397
+ learnEnchantFromItemOnCompanion(companionId, itemEntry) {
3398
+ const comp = findCompanion(companionId);
3399
+ if (!comp) return { ok: false, reason: 'No such companion' };
3400
+ if (comp.mission) return { ok: false, reason: `${comp.name} is away` };
3401
+ const it = ds.item(itemEntry);
3402
+ const teach = itemTeachIntent(it, ds);
3403
+ if (!it || teach?.kind !== 'enchant') return { ok: false, reason: 'That item teaches no formula' };
3404
+ if (countItem(state.inventory, itemEntry) <= 0) return { ok: false, reason: "You don't have that formula" };
3405
+ const ench = ds.enchant(teach.id);
3406
+ if (!ench) return { ok: false, reason: 'That formula is unknown' };
3407
+ const lvl = companionLevel(comp, state.char.level);
3408
+ const skills = companionSkillMap(comp, lvl);
3409
+ syncCompanionProfessionCatalog(comp, skills, ds);
3410
+ const skill = skills.enchanting;
3411
+ if (!skill) {
3412
+ return { ok: false, reason: `${comp.name} needs Enchanting first` };
3413
+ }
3414
+ if (skill.current < (ench.orange | 0)) {
3415
+ return {
3416
+ ok: false,
3417
+ reason: `${comp.name} needs Enchanting ${ench.orange} (has ${skill.current})`,
3418
+ };
3419
+ }
3420
+ if ((comp.knownEnchants ?? []).includes(ench.id)) {
3421
+ return { ok: false, reason: `${comp.name} already knows that` };
3422
+ }
3423
+ state.inventory = removeItem(state.inventory, itemEntry, 1)[0];
3424
+ comp.knownEnchants = [...(comp.knownEnchants ?? []), ench.id];
3425
+ log(`${comp.name} studies ${it.name} and learns ${ench.name}.`);
3426
+ return { ok: true, enchantId: ench.id, name: ench.name };
3427
+ },
3428
+
3429
+ // Read a class book ON a companion. Nothing else in their kit is stored, so the taught rank is
3430
+ // remembered on the body itself. The gates are the player's, retargeted to THEIR level.
3431
+ learnSpellFromItemOnCompanion(companionId, itemEntry) {
3432
+ const comp = findCompanion(companionId);
3433
+ if (!comp) return { ok: false, reason: 'No such companion' };
3434
+ if (comp.mission) return { ok: false, reason: `${comp.name} is away` };
3435
+ const it = ds.item(itemEntry);
3436
+ const teach = itemTeachIntent(it, ds);
3437
+ if (!it || teach?.kind !== 'spell') return { ok: false, reason: 'That item teaches no ability' };
3438
+ if (countItem(state.inventory, itemEntry) <= 0) return { ok: false, reason: "You don't have that book" };
3439
+ if (it.allowClass?.length && !it.allowClass.includes(comp.cls)) {
3440
+ return { ok: false, reason: `A ${className(comp.cls)} cannot study this` };
3441
+ }
3442
+ const lvl = companionLevel(comp, state.char.level);
3443
+ if ((it.requiredLevel | 0) > lvl) {
3444
+ return { ok: false, reason: `${comp.name} needs level ${it.requiredLevel}` };
3445
+ }
3446
+ const sp = ds.spell(teach.id);
3447
+ if (!sp) return { ok: false, reason: 'That book teaches an unknown ability' };
3448
+ if ((sp.reqLevel ?? 0) > lvl) return { ok: false, reason: `${comp.name} needs level ${sp.reqLevel}` };
3449
+ const skillMeta = ds.combatSkillMeta?.();
3450
+ // Empty AllowableClass needs a class-mask SLA. Measured refuse: 5657 Instant Toxin
3451
+ // (CREATE_ITEM, no SLA) + deprecated 2404/2405. Cite: Player.cpp:18369.
3452
+ if (!it.allowClass?.length && !spellHasClassRestrictingAbility(teach.id, skillMeta)) {
3453
+ return { ok: false, reason: `${sp.name} is not a ${className(comp.cls)} ability` };
3454
+ }
3455
+ if (!isSpellFitByClassAndRace(teach.id, comp.race, comp.cls, skillMeta, lvl)) {
3456
+ return { ok: false, reason: `${sp.name} is not a ${className(comp.cls)} ability` };
3457
+ }
3458
+ const sb = companionSpellbook(comp, lvl, ds);
3459
+ if (knows(sb, teach.id)) return { ok: false, reason: `${comp.name} already knows ${sp.name}` };
3460
+ // Same expansion as the player path: the ability plus whatever learning it also trains.
3461
+ // Cite: SpellEffects.cpp:3410 UpdateSpellTrainedSkills.
3462
+ const toLearn = [teach.id, ...spellLearnDependents(teach.id, skillMeta)].filter(
3463
+ (id, i, arr) => arr.indexOf(id) === i && !!ds.spell(id) && !knows(sb, id),
3464
+ );
3465
+ state.inventory = removeItem(state.inventory, itemEntry, 1)[0];
3466
+ comp.taughtSpells = [...(comp.taughtSpells ?? []), ...toLearn];
3467
+ refreshCompanionKitAfterLearn(comp, teach.id);
3468
+ log(`${comp.name} studies ${it.name} and learns ${sp.name}.`);
3469
+ return { ok: true, spell: teach.id, name: sp.name };
3470
+ },
3471
+
3472
+ // Read a pet-training book ON a companion. The rank is character-wide on THEM, exactly as the
3473
+ // player's is, so a re-summoned demon keeps it. Cite: SpellEffects.cpp:3386-3404.
3474
+ learnPetSpellFromItemOnCompanion(companionId, itemEntry) {
3475
+ const comp = findCompanion(companionId);
3476
+ if (!comp) return { ok: false, reason: 'No such companion' };
3477
+ if (comp.mission) return { ok: false, reason: `${comp.name} is away` };
3478
+ const it = ds.item(itemEntry);
3479
+ const teach = itemTeachIntent(it, ds);
3480
+ if (!it || teach?.kind !== 'petSpell') {
3481
+ return { ok: false, reason: 'That is not a pet-training book' };
3482
+ }
3483
+ if (countItem(state.inventory, itemEntry) <= 0) return { ok: false, reason: "You don't have that book" };
3484
+ if (it.allowClass?.length && !it.allowClass.includes(comp.cls)) {
3485
+ return { ok: false, reason: `A ${className(comp.cls)} cannot study this` };
3486
+ }
3487
+ const lvl = companionLevel(comp, state.char.level);
3488
+ if ((it.requiredLevel | 0) > lvl) {
3489
+ return { ok: false, reason: `${comp.name} needs level ${it.requiredLevel}` };
3490
+ }
3491
+ // Store the live rank (EffectTriggerSpell), never the LEARN_PET_SPELL stub.
3492
+ const liveId = resolveSpellId(teach.id, ds);
3493
+ const spell = ds.spell(liveId);
3494
+ if (!spell) return { ok: false, reason: 'That book teaches an unknown ability' };
3495
+ if (petRepertoireHas(comp.petSpells ?? [], liveId, ds)) {
3496
+ return { ok: false, reason: `${comp.name}'s pet already knows ${spell.name}` };
3497
+ }
3498
+ state.inventory = removeItem(state.inventory, itemEntry, 1)[0];
3499
+ comp.petSpells = [...(comp.petSpells ?? []), liveId];
3500
+ log(`${comp.name} studies ${it.name}. Their pet can now cast ${spell.name} (Rank ${spell.rank}).`);
3501
+ return { ok: true, spell: liveId, name: spell.name };
3502
+ },
3503
+
3504
+ // Where the party is standing, for the offer gate. Inside a run the instance's own quest list
3505
+ // counts as being at the giver, exactly as the player's accept path has it.
3506
+ companionQuestOffers(companionId) {
3507
+ const runDef = state.instance ? ds.instance(state.instance.dungeonId) : undefined;
3508
+ const place: CompanionQuestPlace = {
3509
+ zoneId: state.char.zoneId,
3510
+ ...(runDef?.quests?.length ? { instanceQuests: runDef.quests } : {}),
3511
+ };
3512
+ const bodies = companionId
3513
+ ? [findCompanion(companionId)].filter((c): c is Companion => !!c)
3514
+ : (state.squad ?? []);
3515
+ const out: CompanionQuestOffer[] = [];
3516
+ for (const comp of bodies) {
3517
+ out.push(...companionQuestOffers(comp, state.char.level, place, ds));
3518
+ }
3519
+ return out;
3520
+ },
3521
+
3522
+ acceptCompanionQuest(companionId, entry) {
3523
+ const comp = findCompanion(companionId);
3524
+ if (!comp) return { ok: false, reason: 'No such companion' };
3525
+ const r = acceptCompanionQuestAction(comp, entry, state.char.level, ds);
3526
+ if (!r.ok) return { ok: false, reason: r.reason ?? 'Cannot take that quest' };
3527
+ log(`${comp.name} accepts ${ds.quest(entry)?.title ?? 'a quest'}.`);
3528
+ return { ok: true };
3529
+ },
3530
+
3531
+ turnInCompanionQuest(companionId, entry, rewardChoice = 0) {
3532
+ const comp = findCompanion(companionId);
3533
+ if (!comp) return { ok: false, reason: 'No such companion' };
3534
+ const r = turnInCompanionQuestAction(
3535
+ comp, entry, state.char.level, ds, state.char.zoneId, rewardChoice, partyOverflow(ctx), log,
3536
+ );
3537
+ if (!r.ok) return { ok: false, reason: r.reason ?? 'Cannot hand that in' };
3538
+ const money = r.money ?? 0;
3539
+ if (money > 0) state.char = { ...state.char, gold: state.char.gold + money };
3540
+ log(`${comp.name} completes ${ds.quest(entry)?.title ?? 'a quest'}. +${r.xp ?? 0} XP.`);
3541
+ for (const a of r.rep ?? []) {
3542
+ const name = ds.faction(a.faction)?.name ?? `faction ${a.faction}`;
3543
+ log(`${comp.name} ${a.delta > 0 ? '+' : ''}${a.delta} reputation with ${name}`);
3544
+ }
3545
+ return {
3546
+ ok: true,
3547
+ xp: r.xp ?? 0,
3548
+ money,
3549
+ items: r.items ?? [],
3550
+ rep: r.rep ?? [],
3551
+ leveledTo: r.leveledTo ?? [],
3552
+ };
3553
+ },
3554
+
3555
+ abandonCompanionQuest(companionId, entry) {
3556
+ const comp = findCompanion(companionId);
3557
+ if (!comp) return { ok: false, reason: 'No such companion' };
3558
+ const before = companionQuestLog(comp);
3559
+ const next = abandonQuest(before, entry);
3560
+ if (next === before) return { ok: false, reason: 'Not in their log' };
3561
+ comp.questLog = next;
3562
+ return { ok: true };
3563
+ },
3564
+
3565
+ giveItemToBot(id, itemEntry, qty = 1) {
3566
+ const comp = findCompanion(id);
3567
+ if (!comp) return { ok: false, reason: 'No such companion' };
3568
+ const idef = ds.item(itemEntry);
3569
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3570
+ const n = Math.min(Math.max(1, qty), countItem(state.inventory, itemEntry));
3571
+ if (n <= 0) return { ok: false, reason: "You don't have that" };
3572
+
3573
+ // Recipe/formula: teach via the learn-on path (same as bag picker), don't put the book in bags.
3574
+ const teach = itemTeachIntent(idef, ds);
3575
+ if (teach?.kind === 'recipe') {
3576
+ const r = self.learnRecipeFromItemOnCompanion(id, itemEntry);
3577
+ return r.ok ? { ok: true, moved: 1 } : { ok: false, reason: r.reason };
3578
+ }
3579
+ if (teach?.kind === 'enchant') {
3580
+ const r = self.learnEnchantFromItemOnCompanion(id, itemEntry);
3581
+ return r.ok ? { ok: true, moved: 1 } : { ok: false, reason: r.reason };
3582
+ }
3583
+ // A tome / class book / grimoire is read, not carried: hand it over and they study it.
3584
+ if (teach?.kind === 'spell') {
3585
+ const r = self.learnSpellFromItemOnCompanion(id, itemEntry);
3586
+ return r.ok ? { ok: true, moved: 1 } : { ok: false, reason: r.reason };
3587
+ }
3588
+ if (teach?.kind === 'petSpell') {
3589
+ const r = self.learnPetSpellFromItemOnCompanion(id, itemEntry);
3590
+ return r.ok ? { ok: true, moved: 1 } : { ok: false, reason: r.reason };
3591
+ }
3592
+
3593
+ const bags = comp.bags ?? emptyInventory();
3594
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
3595
+ if (!canFit(bags, itemEntry, n, idef.stackable ?? 1, cap)) {
3596
+ return { ok: false, reason: `${comp.name}'s bags are full` };
3597
+ }
3598
+ const moved = transferItem(state.inventory, bags, itemEntry, n, idef.stackable ?? 1, cap);
3599
+ state.inventory = moved.from;
3600
+ comp.bags = moved.to;
3601
+ log(`You give ${idef.name}${n > 1 ? ` ×${n}` : ''} to ${comp.name}.`);
3602
+ return { ok: true, moved: moved.moved };
3603
+ },
3604
+
3605
+ /** One stack of yours (by bag index) into their bags. Omitted qty = that whole stack. */
3606
+ giveItemToBotStack(id, slotIndex, qty) {
3607
+ const comp = findCompanion(id);
3608
+ if (!comp) return { ok: false, reason: 'No such companion' };
3609
+ const stk = state.inventory.stacks[slotIndex];
3610
+ if (!stk) return { ok: false, reason: 'Invalid slot' };
3611
+ const idef = ds.item(stk.item);
3612
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3613
+ // A book / recipe / grimoire is read, not carried - the entry path owns that routing.
3614
+ if (itemTeachIntent(idef, ds)) return self.giveItemToBot(id, stk.item, 1);
3615
+ const n = Math.min(Math.max(1, qty ?? stk.count), stk.count);
3616
+ const bags = comp.bags ?? emptyInventory();
3617
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
3618
+ if (!canFit(bags, stk.item, n, idef.stackable ?? 1, cap)) {
3619
+ return { ok: false, reason: `${comp.name}'s bags are full` };
3620
+ }
3621
+ const [inv, taken] = takeStack(state.inventory, slotIndex, n);
3622
+ if (!taken) return { ok: false, reason: "You don't have that" };
3623
+ state.inventory = inv;
3624
+ comp.bags = addInstance(bags, taken, idef.stackable ?? 1);
3625
+ log(`You give ${idef.name}${taken.count > 1 ? ` ×${taken.count}` : ''} to ${comp.name}.`);
3626
+ return { ok: true, moved: taken.count };
3627
+ },
3628
+
3629
+ // Use a consumable out of THEIR bags, on them: a weapon imbue coats a hand, a reputation effect
3630
+ // pays their standing, a buff is drunk now. A restore is refused — it would just be destroyed.
3631
+ useCompanionItem(id, itemEntry, opts) {
3632
+ const comp = findCompanion(id);
3633
+ if (!comp) return { ok: false, reason: 'No such companion' };
3634
+ const idef = ds.item(itemEntry);
3635
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3636
+ const bags = comp.bags ?? emptyInventory();
3637
+ if (countItem(bags, itemEntry) <= 0) return { ok: false, reason: "They don't have that" };
3638
+
3639
+ if (isWeaponImbueItem(idef, ds)) {
3640
+ const app = ds.enchantApplicator(idef.useSpell!);
3641
+ const scan = scanTempWeaponImbueHands(comp.equipped, ds, app);
3642
+ if (!scan.eligible.length) {
3643
+ const why = scan.rejected[0]?.reason;
3644
+ return {
3645
+ ok: false,
3646
+ reason: why
3647
+ ? `Nothing ${comp.name} has can take that - ${why.toLowerCase()}`
3648
+ : `Nothing ${comp.name} has can take that`,
3649
+ };
3650
+ }
3651
+ const hand = opts?.slot;
3652
+ if (hand == null && scan.eligible.length > 1) {
3653
+ return {
3654
+ ok: false,
3655
+ reason: 'Choose a hand',
3656
+ needsTarget: {
3657
+ itemEntry,
3658
+ enchantId: app!.enchantId,
3659
+ target: { kind: 'companion', id: comp.id },
3660
+ slots: scan.eligible,
3661
+ rejected: scan.rejected,
3662
+ },
3663
+ };
3664
+ }
3665
+ const pick = hand ?? scan.eligible[0]!;
3666
+ if (pick !== 'mainhand' && pick !== 'offhand') {
3667
+ return { ok: false, reason: 'That only goes on a weapon hand' };
3668
+ }
3669
+ if (!applyCompanionImbue(comp.id, itemEntry, pick)) {
3670
+ return { ok: false, reason: 'That did not take' };
3671
+ }
3672
+ return { ok: true, used: 1 };
3673
+ }
3674
+
3675
+ const lvl = companionLevel(comp, state.char.level);
3676
+ const char = companionCharacter(comp, lvl, ds);
3677
+ const kit = combatItems(char, bags, ds).find((x) => x.entry === itemEntry);
3678
+ const spellId = idef.useSpell;
3679
+ // Reputation items must not fall through to the pre-pull aura path (that ate the
3680
+ // bijou as a buff and never granted standing). Cite: SpellEffects.cpp:4908-4926.
3681
+ if (spellId != null && spellId > 0) {
3682
+ const useRaw = ds.spell(spellId);
3683
+ const useResolved = useRaw
3684
+ ? resolveCombatSpell(useRaw, { casterLevel: lvl })
3685
+ : undefined;
3686
+ const hasRep = (useResolved?.effects ?? []).some((e) => e.kind === 'reputation');
3687
+ if (hasRep && useResolved) {
3688
+ const spent = consume(char, bags, ds, itemEntry);
3689
+ if (!spent.ok) {
3690
+ const why = spent.reason ?? 'Cannot use that';
3691
+ return {
3692
+ ok: false,
3693
+ reason: why === 'Your bags are full' ? `${comp.name}'s bags are full` : why,
3694
+ };
3695
+ }
3696
+ const granted = grantSpellReputationEffects(
3697
+ spent.char,
3698
+ useResolved.effects,
3699
+ ds,
3700
+ Date.now(),
3701
+ (msg) => log(`${comp.name} ${msg}`),
3702
+ );
3703
+ writeCompanionStandings(comp, granted);
3704
+ comp.bags = spent.inventory;
3705
+ log(`${comp.name} uses ${idef.name}.`);
3706
+ return { ok: true, used: 1 };
3707
+ }
3708
+ }
3709
+ if (kit?.effect.kind === 'restore_health' || kit?.effect.kind === 'restore_power') {
3710
+ return {
3711
+ ok: false,
3712
+ reason: `${comp.name} will drink that in the fight - put it in their rotation`,
3713
+ };
3714
+ }
3715
+ if (spellId == null || spellId <= 0) return { ok: false, reason: 'Nothing happens' };
3716
+ const now = Date.now();
3717
+ comp.prePullAuras ??= [];
3718
+ if (comp.prePullAuras.some((a) => a.spellId === spellId && (a.expiresAt ?? Infinity) > now)) {
3719
+ return { ok: false, reason: `${comp.name} already has that` };
3720
+ }
3721
+ const durMs = idef.useBuff?.durationMs
3722
+ ?? ds.spell(spellId)?.effects.find((e) => e.kind === 'buff')?.durationMs
3723
+ ?? 0;
3724
+ comp.bags = removeItem(bags, itemEntry, 1)[0];
3725
+ comp.prePullAuras.push({ spellId, ...(durMs > 0 ? { expiresAt: now + durMs } : {}) });
3726
+ log(`${comp.name} uses ${idef.name}.`);
3727
+ return { ok: true, used: 1 };
3728
+ },
3729
+
3730
+ takeItemFromBot(id, itemEntry, qty = 1) {
3731
+ const comp = findCompanion(id);
3732
+ if (!comp) return { ok: false, reason: 'No such companion' };
3733
+ const idef = ds.item(itemEntry);
3734
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3735
+ // Party-internal transfer is free (shared vault) EXCEPT soulbound: a BoP row belongs to the
3736
+ // body that picked it up, so only the unbound stacks of this entry can come over.
3737
+ const bags = comp.bags ?? emptyInventory();
3738
+ const free = bags.stacks.reduce(
3739
+ (t, s) => (s.item === itemEntry && !stackBoundToHolder(s, ds) ? t + s.count : t), 0,
3740
+ );
3741
+ if (countItem(bags, itemEntry) > 0 && free <= 0) {
3742
+ return { ok: false, reason: 'Soulbound - it stays with them' };
3743
+ }
3744
+ const n = Math.min(Math.max(1, qty), free);
3745
+ if (n <= 0) return { ok: false, reason: "They don't have that" };
3746
+ const cap = bagCapacity(state.char, ds);
3747
+ if (!canFit(state.inventory, itemEntry, n, idef.stackable ?? 1, cap)) {
3748
+ return { ok: false, reason: 'Your bags are full' };
3749
+ }
3750
+ const moved = transferItem(bags, state.inventory, itemEntry, n, idef.stackable ?? 1, cap);
3751
+ comp.bags = moved.from;
3752
+ state.inventory = moved.to;
3753
+ log(`You take ${idef.name}${n > 1 ? ` ×${n}` : ''} from ${comp.name}.`);
3754
+ return { ok: true, moved: moved.moved };
3755
+ },
3756
+
3757
+ /** One stack of theirs (by bag index) into your bags. Omitted qty = that whole stack. */
3758
+ takeItemFromBotStack(id, slotIndex, qty) {
3759
+ const comp = findCompanion(id);
3760
+ if (!comp) return { ok: false, reason: 'No such companion' };
3761
+ const bags = comp.bags ?? emptyInventory();
3762
+ const stk = bags.stacks[slotIndex];
3763
+ if (!stk) return { ok: false, reason: 'Invalid slot' };
3764
+ const idef = ds.item(stk.item);
3765
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3766
+ // The bag grid's own Take, the same soulbind gate the party-storage tile uses.
3767
+ if (stackBoundToHolder(stk, ds)) return { ok: false, reason: 'Soulbound - it stays with them' };
3768
+ const n = Math.min(Math.max(1, qty ?? stk.count), stk.count);
3769
+ const cap = bagCapacity(state.char, ds);
3770
+ if (!canFit(state.inventory, stk.item, n, idef.stackable ?? 1, cap)) {
3771
+ return { ok: false, reason: 'Your bags are full' };
3772
+ }
3773
+ const [next, taken] = takeStack(bags, slotIndex, n);
3774
+ if (!taken) return { ok: false, reason: "They don't have that" };
3775
+ comp.bags = next;
3776
+ state.inventory = addInstance(state.inventory, taken, idef.stackable ?? 1);
3777
+ log(`You take ${idef.name}${taken.count > 1 ? ` ×${taken.count}` : ''} from ${comp.name}.`);
3778
+ return { ok: true, moved: taken.count };
3779
+ },
3780
+
3781
+ // One stack straight from one companion's bags to another's, without a stop in yours: the
3782
+ // vendor's split Companions grids drag onto each other. Same soulbind gate as takeItemFromBotStack.
3783
+ moveItemBetweenBots(fromId, slotIndex, toId, qty) {
3784
+ if (fromId === toId) return { ok: false, reason: 'Same companion' };
3785
+ const from = findCompanion(fromId);
3786
+ const to = findCompanion(toId);
3787
+ if (!from || !to) return { ok: false, reason: 'No such companion' };
3788
+ const fromBags = from.bags ?? emptyInventory();
3789
+ const stk = fromBags.stacks[slotIndex];
3790
+ if (!stk) return { ok: false, reason: 'Invalid slot' };
3791
+ const idef = ds.item(stk.item);
3792
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3793
+ if (stackBoundToHolder(stk, ds)) return { ok: false, reason: 'Soulbound - it stays with them' };
3794
+ const n = Math.min(Math.max(1, qty ?? stk.count), stk.count);
3795
+ const toBags = to.bags ?? emptyInventory();
3796
+ const cap = bagCapacity(companionCharacter(to, companionLevel(to, state.char.level), ds), ds);
3797
+ if (!canFit(toBags, stk.item, n, idef.stackable ?? 1, cap)) {
3798
+ return { ok: false, reason: `${to.name}'s bags are full` };
3799
+ }
3800
+ const [next, taken] = takeStack(fromBags, slotIndex, n);
3801
+ if (!taken) return { ok: false, reason: "They don't have that" };
3802
+ from.bags = next;
3803
+ to.bags = addInstance(toBags, taken, idef.stackable ?? 1);
3804
+ log(`${from.name} passes ${idef.name}${taken.count > 1 ? ` ×${taken.count}` : ''} to ${to.name}.`);
3805
+ return { ok: true, moved: taken.count };
3806
+ },
3807
+
3808
+ /** Destroy one stack out of their bags. The player's trash, aimed at a companion's grid. */
3809
+ discardCompanionBagStack(id, slotIndex, qty) {
3810
+ const comp = findCompanion(id);
3811
+ if (!comp) return { ok: false, reason: 'No such companion' };
3812
+ const bags = comp.bags ?? emptyInventory();
3813
+ const stk = bags.stacks[slotIndex];
3814
+ if (!stk) return { ok: false, reason: 'Nothing there' };
3815
+ if (qty != null && !(Number.isInteger(qty) && qty > 0)) return { ok: false, reason: 'Invalid quantity' };
3816
+ const take = qty != null ? Math.min(qty, stk.count) : stk.count;
3817
+ const [next, n] = removeStack(bags, slotIndex, take);
3818
+ if (n <= 0) return { ok: false, reason: 'Nothing there' };
3819
+ comp.bags = next;
3820
+ const idef = ds.item(stk.item);
3821
+ log(`${comp.name} destroys ${idef?.name ?? stk.item}${n > 1 ? ` ×${n}` : ''}.`);
3822
+ return { ok: true, discarded: n };
3823
+ },
3824
+
3825
+ /** Sell one stack out of their bags at the open vendor — the same sellStack the player's grid uses. */
3826
+ sellCompanionItemStack(id, slotIndex, qty) {
3827
+ {
3828
+ const dead = refuseIfDead(state.char.health, state.deathFlow);
3829
+ if (dead) return dead;
3830
+ }
3831
+ const comp = findCompanion(id);
3832
+ if (!comp) return { ok: false, reason: 'No such companion' };
3833
+ const bags = comp.bags ?? emptyInventory();
3834
+ const stk = bags.stacks[slotIndex];
3835
+ if (!stk) return { ok: false, reason: 'Invalid slot' };
3836
+ const def = ds.item(stk.item);
3837
+ if (!def) return { ok: false, reason: 'Unknown item' };
3838
+ if (qty != null && !(Number.isInteger(qty) && qty > 0)) return { ok: false, reason: 'Invalid quantity' };
3839
+ const res = sellStack(state.char, bags, def, slotIndex, qty ?? stk.count);
3840
+ if (!res.ok) return { ok: false, reason: res.reason ?? 'Cannot sell' };
3841
+ state.char = res.char;
3842
+ comp.bags = res.inventory;
3843
+ const sold = res.qty ?? res.buybackEntry?.qty ?? stk.count;
3844
+ log(`${comp.name} sells ${sold > 1 ? `${sold}x ` : ''}${def.name} for ${moneyMarked(res.amount)}.`);
3845
+ return { ok: true, earned: res.amount, qty: sold };
3846
+ },
3847
+
3848
+ // Buy a sold stack back into the bags it came out of. Your gold pays either way, but the item
3849
+ // goes home to its owner - a companion's gear never lands in your bags off the buyback tab.
3850
+ buyBackCompanionStack(id, itemEntry, qty, refundCopper) {
3851
+ {
3852
+ const dead = refuseIfDead(state.char.health, state.deathFlow);
3853
+ if (dead) return dead;
3854
+ }
3855
+ const comp = findCompanion(id);
3856
+ if (!comp) return { ok: false, reason: 'No such companion' };
3857
+ const def = ds.item(itemEntry);
3858
+ if (!def) return { ok: false, reason: 'Unknown item' };
3859
+ if (!(Number.isInteger(qty) && qty > 0)) return { ok: false, reason: 'Invalid quantity' };
3860
+ if (!(Number.isInteger(refundCopper) && refundCopper >= 0)) return { ok: false, reason: 'Invalid price' };
3861
+ const bags = comp.bags ?? emptyInventory();
3862
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
3863
+ const res = buyBack(
3864
+ state.char,
3865
+ bags,
3866
+ [{ entry: itemEntry, qty, refundCopper, soldAt: 0 }],
3867
+ 0,
3868
+ def.stackable ?? 1,
3869
+ cap,
3870
+ );
3871
+ if (!res.ok) {
3872
+ const full = res.reason === 'Not enough bag space';
3873
+ return { ok: false, reason: full ? `${comp.name}'s bags are full` : res.reason ?? 'Cannot buy back' };
3874
+ }
3875
+ state.char = res.char;
3876
+ comp.bags = res.inventory;
3877
+ log(`You buy back ${qty > 1 ? `${qty}x ` : ''}${def.name} for ${moneyMarked(res.amount)} - back to ${comp.name}.`);
3878
+ return { ok: true, qty };
3879
+ },
3880
+
3881
+ takeFromPartyStorage(itemEntry, qty) {
3882
+ const idef = ds.item(itemEntry);
3883
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3884
+ const r = partyTakeToBags(vaultCtx(), vaultMut(), itemEntry, bagCapacity(state.char, ds), qty);
3885
+ if (r.moved <= 0) {
3886
+ return { ok: false, reason: r.full ? 'Your bags are full' : r.bound > 0 ? 'Soulbound - it stays with them' : 'Nobody is carrying that' };
3887
+ }
3888
+ log(`You take ${idef.name}${r.moved > 1 ? ` ×${r.moved}` : ''} from party storage.`);
3889
+ return { ok: true, moved: r.moved, bound: r.bound, full: r.full };
3890
+ },
3891
+
3892
+ companionBankDeposit(id, itemEntry, qty = 1) {
3893
+ const comp = findCompanion(id);
3894
+ if (!comp) return { ok: false, reason: 'No such companion' };
3895
+ const idef = ds.item(itemEntry);
3896
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3897
+ const r = deposit(
3898
+ comp.bags ?? emptyInventory(), comp.bank ?? emptyInventory(),
3899
+ itemEntry, qty, idef.stackable ?? 1, bankCapacity(comp.bankBags ?? [], ds),
3900
+ );
3901
+ if (!r.ok) return { ok: false, reason: r.reason ?? `${comp.name}'s bank is full` };
3902
+ comp.bags = r.inventory;
3903
+ comp.bank = r.bank;
3904
+ return { ok: true, moved: r.moved };
3905
+ },
3906
+
3907
+ companionBankWithdraw(id, itemEntry, qty = 1) {
3908
+ const comp = findCompanion(id);
3909
+ if (!comp) return { ok: false, reason: 'No such companion' };
3910
+ const idef = ds.item(itemEntry);
3911
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3912
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
3913
+ const r = withdraw(
3914
+ comp.bags ?? emptyInventory(), comp.bank ?? emptyInventory(),
3915
+ itemEntry, qty, idef.stackable ?? 1, cap,
3916
+ );
3917
+ if (!r.ok) return { ok: false, reason: r.reason ?? `${comp.name}'s bags are full` };
3918
+ comp.bags = r.inventory;
3919
+ comp.bank = r.bank;
3920
+ return { ok: true, moved: r.moved };
3921
+ },
3922
+
3923
+ /** One stack of their bags (by index) into their bank. Omitted qty = that whole stack. */
3924
+ companionBankDepositStack(id, slotIndex, qty) {
3925
+ const comp = findCompanion(id);
3926
+ if (!comp) return { ok: false, reason: 'No such companion' };
3927
+ const bags = comp.bags ?? emptyInventory();
3928
+ const stk = bags.stacks[slotIndex];
3929
+ if (!stk) return { ok: false, reason: 'Invalid slot' };
3930
+ const idef = ds.item(stk.item);
3931
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3932
+ const r = depositStack(
3933
+ bags, comp.bank ?? emptyInventory(), slotIndex,
3934
+ Math.min(Math.max(1, qty ?? stk.count), stk.count),
3935
+ idef.stackable ?? 1, bankCapacity(comp.bankBags ?? [], ds),
3936
+ );
3937
+ if (!r.ok) return { ok: false, reason: r.reason ?? `${comp.name}'s bank is full` };
3938
+ comp.bags = r.inventory;
3939
+ comp.bank = r.bank;
3940
+ return { ok: true, moved: r.moved };
3941
+ },
3942
+
3943
+ /** One stack of their bank (by index) into their bags. Omitted qty = that whole stack. */
3944
+ companionBankWithdrawStack(id, slotIndex, qty) {
3945
+ const comp = findCompanion(id);
3946
+ if (!comp) return { ok: false, reason: 'No such companion' };
3947
+ const bank = comp.bank ?? emptyInventory();
3948
+ const stk = bank.stacks[slotIndex];
3949
+ if (!stk) return { ok: false, reason: 'Invalid slot' };
3950
+ const idef = ds.item(stk.item);
3951
+ if (!idef) return { ok: false, reason: 'Unknown item' };
3952
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
3953
+ const r = withdrawStack(
3954
+ comp.bags ?? emptyInventory(), bank, slotIndex,
3955
+ Math.min(Math.max(1, qty ?? stk.count), stk.count),
3956
+ idef.stackable ?? 1, cap,
3957
+ );
3958
+ if (!r.ok) return { ok: false, reason: r.reason ?? `${comp.name}'s bags are full` };
3959
+ comp.bags = r.inventory;
3960
+ comp.bank = r.bank;
3961
+ return { ok: true, moved: r.moved };
3962
+ },
3963
+
3964
+ buyCompanionBankSlot(id) {
3965
+ const comp = findCompanion(id);
3966
+ if (!comp) return { ok: false, reason: 'No such companion' };
3967
+ const r = buyBankBagSlot(comp.bankBags ?? [], state.char.gold);
3968
+ if (!r.ok) return { ok: false, reason: r.reason ?? 'Cannot buy that slot' };
3969
+ state.char = { ...state.char, gold: state.char.gold - r.cost };
3970
+ comp.bankBags = r.bankBags;
3971
+ log(`You buy ${comp.name} a bank bag slot for ${moneyMarked(r.cost)}.`);
3972
+ return { ok: true, cost: r.cost };
3973
+ },
3974
+
3975
+ equipCompanionBankBag(id, itemEntry, slotIndex) {
3976
+ const comp = findCompanion(id);
3977
+ if (!comp) return { ok: false, reason: 'No such companion' };
3978
+ const idef = ds.item(itemEntry);
3979
+ if (idef?.slot !== 'bag') return { ok: false, reason: 'That is not a bag' };
3980
+ // The bag may come from your bags - giving it makes it the bot's.
3981
+ let bags = comp.bags ?? emptyInventory();
3982
+ if (countItem(bags, itemEntry) <= 0 && countItem(state.inventory, itemEntry) > 0) {
3983
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
3984
+ if (!canFit(bags, itemEntry, 1, 1, cap)) return { ok: false, reason: `${comp.name}'s bags are full` };
3985
+ const moved = transferItem(state.inventory, bags, itemEntry, 1, 1, cap);
3986
+ state.inventory = moved.from;
3987
+ bags = moved.to;
3988
+ }
3989
+ const r = equipBankBag(bags, comp.bank ?? emptyInventory(), comp.bankBags ?? [], ds, itemEntry, slotIndex);
3990
+ if (!r.ok) {
3991
+ comp.bags = bags; // the give (if any) stands - it's theirs now
3992
+ return { ok: false, reason: r.reason ?? 'Cannot slot that bag' };
3993
+ }
3994
+ comp.bags = r.inventory;
3995
+ comp.bankBags = r.bankBags;
3996
+ return { ok: true };
3997
+ },
3998
+
3999
+ unequipCompanionBankBag(id, slotIndex) {
4000
+ const comp = findCompanion(id);
4001
+ if (!comp) return { ok: false, reason: 'No such companion' };
4002
+ const cap = bagCapacity(companionCharacter(comp, companionLevel(comp, state.char.level), ds), ds);
4003
+ const r = unequipBankBag(comp.bags ?? emptyInventory(), comp.bank ?? emptyInventory(), comp.bankBags ?? [], ds, slotIndex, cap);
4004
+ if (!r.ok) return { ok: false, reason: r.reason ?? 'Cannot remove that bag' };
4005
+ comp.bags = r.inventory;
4006
+ comp.bankBags = r.bankBags;
4007
+ return { ok: true };
4008
+ },
4009
+
4010
+ runFullDungeon(opts) {
4011
+ return executeRunFullDungeon(ctx, combat, helpers, opts);
4012
+ },
4013
+
4014
+ // S1 (GROUPING-PLAN): per-companion rotations, mirroring the player accessors.
4015
+ companionRotation(id, profile) {
4016
+ const comp = findCompanion(id);
4017
+ if (!comp) return null;
4018
+ return ensureCompanionRotation(comp, profile ?? companionActiveProfile(comp));
4019
+ },
4020
+ setCompanionRotation(id, rot, profile) {
4021
+ const comp = findCompanion(id);
4022
+ if (!comp) return { ok: false, reason: 'No such companion' };
4023
+ const p = profile ?? companionActiveProfile(comp);
4024
+ setCompanionRotationSlot(comp, p, { ...rot, version: 1, custom: true, profile: p });
4025
+ return { ok: true };
4026
+ },
4027
+ resetCompanionRotation(id, profile) {
4028
+ const comp = findCompanion(id);
4029
+ if (!comp) return null;
4030
+ const p = profile ?? companionActiveProfile(comp);
4031
+ const rot = buildCompanionRotation(comp, companionLevel(comp, state.char.level), ds, p);
4032
+ setCompanionRotationSlot(comp, p, rot);
4033
+ return rot;
4034
+ },
4035
+ companionRotationProfile(id) {
4036
+ const comp = findCompanion(id);
4037
+ return comp ? companionActiveProfile(comp) : 'primary';
4038
+ },
4039
+ setCompanionRotationProfile(id, profile) {
4040
+ const comp = findCompanion(id);
4041
+ if (!comp) return { ok: false, reason: 'No such companion' };
4042
+ if (profile === 'dps' && !companionHasDpsProfile(comp)) {
4043
+ return { ok: false, reason: 'Only a tank or healer companion has a second rotation' };
4044
+ }
4045
+ comp.rotationProfile = profile;
4046
+ return { ok: true };
4047
+ },
4048
+ companionHasDpsRotationProfile(id) {
4049
+ const comp = findCompanion(id);
4050
+ return comp ? companionHasDpsProfile(comp) : false;
4051
+ },
4052
+
4053
+ // ---- named profiles: a companion owns as many as the player does ---------------------
4054
+ companionRotationProfiles(id) {
4055
+ const comp = findCompanion(id);
4056
+ if (!comp) return [];
4057
+ ensureCompanionProfileList(comp);
4058
+ syncActiveFromMirror(comp);
4059
+ return (comp.rotationProfiles ?? []).map((p) => ({
4060
+ id: p.id,
4061
+ name: p.name,
4062
+ active: p.id === activeProfile(comp)?.id,
4063
+ }));
4064
+ },
4065
+ companionHasPvpRotationBand(id) {
4066
+ const comp = findCompanion(id);
4067
+ return comp ? companionHasPvpBand(comp, companionLevel(comp, state.char.level), ds) : false;
4068
+ },
4069
+ addCompanionRotationProfile(id, opts) {
4070
+ const comp = findCompanion(id);
4071
+ if (!comp) return { ok: false, reason: 'No such companion' };
4072
+ ensureCompanionProfileList(comp);
4073
+ const lvl = companionLevel(comp, state.char.level);
4074
+ if (opts?.mode === 'pvp' && !companionHasPvpBand(comp, lvl, ds)) {
4075
+ return { ok: false, reason: `No PvP list is authored for ${comp.name} at level ${lvl}` };
4076
+ }
4077
+ const made = addProfile(comp, {
4078
+ name: opts?.name,
4079
+ copyFromId: opts?.copyFromId,
4080
+ // `mode: 'pvp'` is what reaches the authored pvp band; without it `selectBand` filters
4081
+ // that pool out entirely (rotationSuggestions.ts).
4082
+ ...(opts?.mode === 'pvp' || opts?.fromBand !== undefined
4083
+ ? {
4084
+ rotation: buildCompanionRotation(
4085
+ comp, lvl, ds, undefined, opts?.mode ? { mode: opts.mode } : undefined,
4086
+ ),
4087
+ }
4088
+ : {}),
4089
+ });
4090
+ if (!made) return { ok: false, reason: `At most ${MAX_ROTATION_PROFILES} rotations` };
4091
+ return { ok: true, id: made.id, name: made.name };
4092
+ },
4093
+ renameCompanionRotationProfile(id, profileId, name) {
4094
+ const comp = findCompanion(id);
4095
+ if (!comp) return { ok: false, reason: 'No such companion' };
4096
+ ensureCompanionProfileList(comp);
4097
+ return renameProfile(comp, profileId, name)
4098
+ ? { ok: true }
4099
+ : { ok: false, reason: 'No such rotation' };
4100
+ },
4101
+ deleteCompanionRotationProfile(id, profileId) {
4102
+ const comp = findCompanion(id);
4103
+ if (!comp) return { ok: false, reason: 'No such companion' };
4104
+ ensureCompanionProfileList(comp);
4105
+ return removeProfile(comp, profileId)
4106
+ ? { ok: true }
4107
+ : { ok: false, reason: 'Cannot delete their only rotation' };
4108
+ },
4109
+ selectCompanionRotationProfile(id, profileId) {
4110
+ const comp = findCompanion(id);
4111
+ if (!comp) return { ok: false, reason: 'No such companion' };
4112
+ ensureCompanionProfileList(comp);
4113
+ return setActiveProfile(comp, profileId)
4114
+ ? { ok: true }
4115
+ : { ok: false, reason: 'No such rotation' };
4116
+ },
4117
+ companionActionBars(id) {
4118
+ const comp = findCompanion(id);
4119
+ if (!comp) return null;
4120
+ // Kit-drift refresh first: this read is the bar UI, and ensureCompanionRotation is what
4121
+ // tops the bars up after a level-up (otherwise they only grow at the next pull).
4122
+ ensureCompanionRotation(comp);
4123
+ return ensureCompanionActionBars(comp, state.char.level, ds);
4124
+ },
4125
+ setCompanionActionSlot(id, bar, slot, spellId) {
4126
+ const comp = findCompanion(id);
4127
+ if (!comp) return { ok: false, reason: 'No such companion' };
4128
+ const bars = ensureCompanionActionBars(comp, state.char.level, ds);
4129
+ if (spellId == null) {
4130
+ comp.actionBars = clearBarSlot(bars, bar, slot);
4131
+ return { ok: true };
4132
+ }
4133
+ const eligible = companionEligibleSpellIds(comp, companionLevel(comp, state.char.level), ds);
4134
+ if (!eligible.includes(spellId)) return { ok: false, reason: 'They do not know that spell' };
4135
+ comp.actionBars = setBarSlot(bars, bar, slot, spellId);
4136
+ return { ok: true };
4137
+ },
4138
+ setCompanionItemActionSlot(id, bar, slot, entry) {
4139
+ const comp = findCompanion(id);
4140
+ if (!comp) return { ok: false, reason: 'No such companion' };
4141
+ const bars = ensureCompanionActionBars(comp, state.char.level, ds);
4142
+ if (entry == null) {
4143
+ comp.actionBars = clearBarSlot(bars, bar, slot);
4144
+ return { ok: true };
4145
+ }
4146
+ const inv = comp.bags ?? emptyInventory();
4147
+ const already = bars.some((b) => b.items?.includes(entry));
4148
+ const onUseGear = equippedOnUseEntries(comp, companionLevel(comp, state.char.level), ds);
4149
+ const equippedOk = onUseGear.includes(entry);
4150
+ if (countItem(inv, entry) <= 0 && !already && !equippedOk) {
4151
+ return { ok: false, reason: 'Not in their bags or equipped on-use' };
4152
+ }
4153
+ const it = ds.item(entry);
4154
+ if (!it || !(it.useInCombat || it.useBuff || it.useSpell != null)) {
4155
+ return { ok: false, reason: 'Not a combat consumable' };
4156
+ }
4157
+ comp.actionBars = setBarItemSlot(bars, bar, slot, entry);
4158
+ return { ok: true };
4159
+ },
4160
+ addCompanionActionBar(id) {
4161
+ const comp = findCompanion(id);
4162
+ if (!comp) return { ok: false, reason: 'No such companion' };
4163
+ const bars = ensureCompanionActionBars(comp, state.char.level, ds);
4164
+ const next = addActionBarPure(bars);
4165
+ if (next.length === bars.length) return { ok: false, reason: 'No more action bars available' };
4166
+ comp.actionBars = next;
4167
+ return { ok: true };
4168
+ },
4169
+ removeCompanionActionBar(id) {
4170
+ const comp = findCompanion(id);
4171
+ if (!comp) return { ok: false, reason: 'No such companion' };
4172
+ const bars = ensureCompanionActionBars(comp, state.char.level, ds);
4173
+ const next = removeActionBarPure(bars);
4174
+ if (next.length === bars.length) return { ok: false, reason: 'They must keep at least one bar' };
4175
+ comp.actionBars = next;
4176
+ return { ok: true };
4177
+ },
4178
+ resetCompanionActionBars(id) {
4179
+ const comp = findCompanion(id);
4180
+ if (!comp) return { ok: false, reason: 'No such companion' };
4181
+ // Fresh rotation kit + on-use gear (1–3 bars), same as hire / first open.
4182
+ comp.actionBars = seedCompanionActionBars(comp, state.char.level, ds);
4183
+ return { ok: true };
4184
+ },
4185
+ squadBattleParty(targetEntry) {
4186
+ return buildSquadBattleParty(targetEntry);
4187
+ },
4188
+ applyPrePullBuffs() {
4189
+ return doApplyPrePullBuffs();
4190
+ },
4191
+ refreshPartyWeaponCoats() {
4192
+ return doRefreshPartyWeaponCoats();
4193
+ },
4194
+ // The swap path: cast what the board now says and nothing that is already up. Returns the
4195
+ // number of chips cast, so a caller can tell "changed something" from "nothing to do".
4196
+ refreshPaladinBuffs() {
4197
+ const cast = doApplyPaladinBuffs(Date.now(), { topUpOnly: true });
4198
+ // A live provider closes over the rotation it was built with, so a grid change mid-pull only
4199
+ // reached the NEXT fight. Same restamp `setCompanionRole` does at :2673.
4200
+ const live = combat.getLiveFightSession();
4201
+ if (live && !live.ended && typeof live.setAllyGroupRole === 'function') {
4202
+ const squad = state.squad ?? [];
4203
+ for (let i = 0; i < squad.length; i++) {
4204
+ const c = squad[i]!;
4205
+ if (c.cls !== CLASS.PALADIN) continue;
4206
+ live.setAllyGroupRole(
4207
+ `ally_${i}`,
4208
+ c.role === 'dps' ? 'damage' : c.role ?? 'damage',
4209
+ companionActionProvider(ensureCompanionRotation(c), undefined, {
4210
+ companion: c,
4211
+ isMultiSpell: (sid) => isMultiTargetCombatSpell(ds.spell(sid)),
4212
+ isOocKeepUp: (sid) => isCompanionKeepUpSpell(ds.spell(sid)),
4213
+ }),
4214
+ );
4215
+ }
4216
+ }
4217
+ return cast;
4218
+ },
4219
+ };
4220
+ self = api;
4221
+ return api;
4222
+ }