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,1882 @@
1
+ // Drives the windowed game headlessly: decodes raw key bytes, rolls a character, walks every tab, opens a
2
+ // quest, a vendor and a trainer, pages through quest text, runs a live fight tick by tick, runs the autopilot
3
+ // through a live fight, and edits the rotation through its menu. Run with `npm run check:tui`; pass --frames
4
+ // to see every screen.
5
+ import { HeadlessTerm, decodeKeys } from './term.mjs';
6
+ import { App } from './tui.mjs';
7
+ import { bootDataStore } from './bootData.mjs';
8
+ import { newGameState } from '../engine/game.ts';
9
+ import { strip } from './text.mjs';
10
+ import { visibleBars } from './tuiBars.mjs';
11
+ import { addItem, countItem } from '../domain/items/inventory.ts';
12
+ import { moneyText as moneyTextOf } from '../domain/economy/money.ts';
13
+
14
+ const showFrames = process.argv.includes('--frames');
15
+ let failures = 0;
16
+ const check = (label, cond) => { console.log(`${cond ? 'ok ' : 'FAIL'} ${label}`); if (!cond) failures++; };
17
+
18
+ // ---- raw key decoding (what a real terminal sends) --------------------------------------------------
19
+ const keyCases = [
20
+ ['\x1b', ['escape']], ['\x1b\x1b', ['escape', 'escape']], ['\x1b[A', ['up']], ['\x1b[B\x1b', ['down', 'escape']],
21
+ ['\x1b[5~', ['pageup']], ['\x1b[6~', ['pagedown']], ['\x1b[Z', ['shift-tab']], ['\x1bOH', ['home']], ['\x1b[F', ['end']],
22
+ ['\r', ['return']], ['\r\n', ['return']], ['\t', ['tab']], ['\x7f', ['backspace']], ['\b', ['backspace']], [' ', ['space']],
23
+ ['\x03', ['ctrl-c']], ['K', ['K']], [':', [':']], ['\x1b1', ['alt-1']], ['a\x1b[Bb', ['a', 'down', 'b']],
24
+ ];
25
+ check('raw key decoding', keyCases.every(([bytes, want]) => JSON.stringify(decodeKeys(bytes)) === JSON.stringify(want)));
26
+
27
+ const t0 = performance.now();
28
+ const ds = bootDataStore();
29
+ console.log(`store ready in ${Math.round(performance.now() - t0)} ms`);
30
+
31
+ const term = new HeadlessTerm(110, 34);
32
+ const repo = { saved: 0, async save() { this.saved++; }, async list() { return []; }, async load() { return null; } };
33
+ const app = new App({ term, ds, repo });
34
+ term.open();
35
+ const human = ds.races().find((r) => r.name === 'Human');
36
+ app.startGame(newGameState(ds, human.id, 1, 'Tuitest', 12345, 0), 'tuitest', Date.now(), true);
37
+ app.render();
38
+
39
+ const frame = () => term.text();
40
+ const home = () => { let n = 0; while (app.stack.length && n++ < 10) term.press('escape'); };
41
+ const show = (label) => { if (showFrames) console.log(`\n=== ${label} ===\n${frame()}`); };
42
+ const rowIndex = (pred) => app.rowsOf(app.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
43
+ /** Run a live fight to its end by hand. */
44
+ const finishFight = () => { let n = 0; while (app.fight && n < 3000) { app.fight.tick(); n++; } return n; };
45
+
46
+ show('world');
47
+ check('header shows the character', frame().includes('Tuitest') && frame().includes('Elwynn Forest'));
48
+ check('world lists creatures', rowIndex((r, s) => r.data?.creature && /Kobold/.test(s)) >= 0);
49
+ {
50
+ const rs = app.rowsOf(app.top()).filter((r) => r.header).map((r) => strip(r.text));
51
+ check('world lists quests last, after creatures', rs.indexOf('Quests') > rs.findIndex((s) => s.startsWith('Creatures')));
52
+ }
53
+
54
+ // Tabs 2..9 and 0 all render without throwing; Left/Right and Esc move between them.
55
+ for (const k of ['2', '3', '4', '5', '6', '7', '8', '9', '0']) { term.press(k); show(`tab ${k}`); }
56
+ check('rotation tab shows rules', (term.press('6'), /Combat \(checked/.test(frame())));
57
+ check('talents tab renders', (term.press('7'), /Talents unlock|point/.test(frame())));
58
+ check('0 opens the tenth tab', (term.press('0'), app.tab === 9));
59
+ const lastTab = app.tabs.length - 1;
60
+ check('character tab renders', (app.switchTab(lastTab), /Attributes/.test(frame())));
61
+ {
62
+ const charRows = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
63
+ check('the character tab lists weapon skills with Defense and the hand in use', /Weapon skills\|\s+Defense\s+\d+\/\d+\|/.test(charRows) && /\d+\/\d+( \+\d+)? +main hand/.test(charRows));
64
+ }
65
+ term.press('right'); check('Right wraps to the first tab', app.tab === 0);
66
+ term.press('left'); check('Left wraps to the last tab', app.tab === lastTab);
67
+ term.press('escape'); check('Esc on a tab returns to World', app.tab === 0);
68
+
69
+ // Open the first offered quest from the world tab, page through its text, accept it.
70
+ term.press('1');
71
+ const firstQuest = rowIndex((r, t) => t.startsWith('!'));
72
+ check('a quest is offered in the start zone', firstQuest > 0);
73
+ if (firstQuest > 0) {
74
+ app.top().cursor = firstQuest; term.press('return');
75
+ show('quest dialog');
76
+ check('quest dialog opened', app.top().id.startsWith('quest-'));
77
+ const rows = app.rowsOf(app.top());
78
+ const long = rows.length > app.layout().mainH;
79
+ term.press('pagedown');
80
+ check('PgDn pages the quest text', !long || app.top().scroll > 0);
81
+ term.press('pageup');
82
+ check('PgUp pages back', app.top().scroll === 0);
83
+ term.press('end');
84
+ check('End shows the bottom of the dialog', !long || app.top().scroll > 0);
85
+ // Accept is the first selectable row after the text; End left the cursor on Decline.
86
+ term.press('home'); term.press('return');
87
+ check('quest accepted', app.g.state.questlog.quests.length === 1);
88
+ check('Enter on Accept closed the dialog', app.stack.length === 0);
89
+ term.press('2'); show('quest log');
90
+ check('quest log lists it', /1 of 20/.test(frame()));
91
+ }
92
+
93
+ // Esc closes a window; the log window opens and closes.
94
+ home(); term.press('1');
95
+ term.press('l'); check('l opens the log window', app.top().id === 'log'); show('log window');
96
+ term.press('escape'); check('Esc closes the log window', app.stack.length === 0);
97
+
98
+ // Vendor: open the first vendor and read offers.
99
+ const vendorRow = rowIndex((r, t) => /<.*>/.test(t) && !/Trainer|Innkeeper|Flight/.test(t));
100
+ if (vendorRow > 0) {
101
+ app.top().cursor = vendorRow; term.press('return'); show('vendor');
102
+ check('vendor dialog opened', app.top().id.startsWith('vendor-'));
103
+ term.press('tab'); show('vendor sell');
104
+ check('vendor sell list shows bags', /Selling/.test(frame()));
105
+ const sold = app.g.state.inventory.stacks.find((s) => (ds.item(s.item)?.sellPrice ?? 0) > 0);
106
+ if (sold) {
107
+ const had = countItem(app.g.state.inventory, sold.item);
108
+ app.top().cursor = rowIndex((r) => r.data === sold); term.press('return');
109
+ term.press('tab'); show('vendor buyback');
110
+ check('a sale lands on the Buyback tab', /Buyback/.test(frame()) && strip(frame()).includes(ds.item(sold.item).name));
111
+ app.top().cursor = rowIndex((r, t) => t.includes(ds.item(sold.item).name)); term.press('return');
112
+ check('Enter buys it back', countItem(app.g.state.inventory, sold.item) === had && !(app.buyback ?? []).length);
113
+ term.press('shift-tab');
114
+ const doomed = app.g.state.inventory.stacks.find((s) => s.item === sold.item);
115
+ app.top().cursor = rowIndex((r) => r.data === doomed); term.press('d'); term.press('return');
116
+ check('d destroys a stack from the Selling tab', countItem(app.g.state.inventory, sold.item) === had - doomed.count && app.top().id.startsWith('vendor-'));
117
+ term.press('tab'); term.press('tab'); check('Tab reaches the companions mode', /Companions/.test(strip(frame())));
118
+ }
119
+ term.press('escape');
120
+ check('Esc closes the vendor', app.stack.length === 0);
121
+ }
122
+
123
+ // Trainer dialog.
124
+ const trainerRow = rowIndex((r, t) => /Trainer>/.test(t));
125
+ if (trainerRow > 0) { app.top().cursor = trainerRow; term.press('return'); show('trainer'); check('trainer dialog opened', app.top().id.startsWith('trainer-')); term.press('escape'); }
126
+
127
+ // Live fight against the lowest-level creature, ticked by hand.
128
+ home(); term.press('1');
129
+ const mobRow = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
130
+ app.top().cursor = mobRow; term.press('return');
131
+ check('live fight started', !!app.fight);
132
+ show('fight start');
133
+ const barName = app.fight?.abilityBar()[0]?.spell.name;
134
+ const hasBar = () => barName && frame().split('\n').some((l) => /^\s*1\s/.test(strip(l)) && strip(l).includes(barName));
135
+ check('auto-play hides the ability bar', !hasBar());
136
+ let ticks = 0;
137
+ let paneFrames = null;
138
+ while (app.fight && ticks < 3000) {
139
+ app.fight.tick(); ticks++;
140
+ if (ticks === 15) show('fight mid');
141
+ // The bottom pane: Tab walks log → meter → threat → loot → log, each drawn without throwing, mid-fight.
142
+ if (ticks === 40 && app.fight) {
143
+ paneFrames = {};
144
+ for (const id of ['meter', 'threat', 'loot', 'log']) { term.press('tab'); paneFrames[id] = { pane: app.fightPane, text: strip(frame()) }; show(`fight ${id} pane`); }
145
+ term.press('tab'); term.press('shift-tab');
146
+ // The meter's views: s walks all seven, f flips to the whole sitting and back.
147
+ app.fightPane = 'meter';
148
+ paneFrames.views = [];
149
+ for (let i = 0; i < 7; i++) { term.press('s'); paneFrames.views.push(strip(frame())); show(`meter view ${i + 1}`); }
150
+ term.press('f'); paneFrames.sitting = strip(frame()); show('meter whole sitting'); term.press('f');
151
+ app.fightPane = 'log';
152
+ }
153
+ }
154
+ check(`fight resolved in ${ticks} ticks`, !app.fight && ticks < 3000);
155
+ check('combat log has rows', app.log.some((l) => /hits|misses|crits|dies|Victory|Defeat/.test(strip(l))));
156
+ if (paneFrames) {
157
+ check('Tab cycles the fight pane', paneFrames.meter.pane === 'meter' && paneFrames.threat.pane === 'threat' && paneFrames.loot.pane === 'loot' && paneFrames.log.pane === 'log');
158
+ check('meter pane names the player', /Damage done/.test(paneFrames.meter.text) && /Tuitest/.test(paneFrames.meter.text));
159
+ check('meter s walks every view back to damage', ['DPS', 'Healing done', 'Damage taken', 'Deaths', 'Interrupts', 'Dispels', 'Damage done'].every((t, i) => paneFrames.views[i]?.includes(t)));
160
+ check('meter f shows the whole sitting', /this sitting/.test(paneFrames.sitting) && app.meterSegment === 'fight');
161
+ check('threat pane shows the enemy', /Threat on /.test(paneFrames.threat.text) && /aggro|Nobody has threat/.test(paneFrames.threat.text));
162
+ check('loot pane renders', /Loot this sitting/.test(paneFrames.loot.text));
163
+ check('Shift-Tab goes back to the log', app.fightPane === 'log');
164
+ } else check('fight lasted long enough to try the panes', false);
165
+ show('after fight');
166
+ check('loot tally fills from a win', !!app.lootTally && (app.lootTally.rows.length > 0 || app.lootTally.copper > 0) || app.log.every((l) => !/You receive/.test(strip(l))));
167
+
168
+ // Manual press path: start another fight, press ability 1, keep ticking.
169
+ app.top().cursor = mobRow; term.press('return');
170
+ if (app.fight) {
171
+ term.press('a'); // manual
172
+ check('manual play shows the ability bar', hasBar());
173
+ term.press('1');
174
+ check('manual fight resolved', finishFight() > 0 && !app.fight);
175
+ }
176
+
177
+ // The log window over a fight scrolls and closes without touching the fight.
178
+ app.top().cursor = mobRow; term.press('return');
179
+ if (app.fight) {
180
+ app.fight.tick(); term.press('l');
181
+ check('log window opens over a fight', app.top().id === 'log' && !!app.fight && /Log/.test(frame()));
182
+ term.press('pageup'); term.press('escape');
183
+ check('Esc closes the log without fleeing', app.stack.length === 0 && !!app.fight);
184
+ finishFight();
185
+ }
186
+
187
+ // Fleeing a fight.
188
+ app.top().cursor = mobRow; term.press('return');
189
+ if (app.fight) { app.fight.tick(); term.press('escape'); check('Esc runs from a fight', !app.fight && app.log.some((l) => /You run from/.test(strip(l)))); }
190
+
191
+ // Command line runs REPL verbs into the log.
192
+ term.press(':'); term.type('rest 30');
193
+ check('command line ran rest', app.log.some((l) => /You rest for 30 s/.test(strip(l))));
194
+
195
+ // Autopilot: steps by hand; its fights are live sessions that we tick to the end.
196
+ home(); term.press('1'); term.press('a');
197
+ check('autopilot started', !!app.auto);
198
+ const autoCtl = app.auto;
199
+ let steps = 0, liveFights = 0, fightTicks = 0;
200
+ while (app.auto && steps < 60) {
201
+ if (app.fight) {
202
+ if (!liveFights) { show('autopilot fight'); check('an autopilot fight says what it is for', /^ Fighting .* (for .+|hunting for xp)\s*$/m.test(strip(frame()))); }
203
+ liveFights++; fightTicks += finishFight(); continue;
204
+ }
205
+ if (!app.auto.step()) break;
206
+ steps++;
207
+ }
208
+ show('autopilot');
209
+ if (app.auto) {
210
+ if (app.fight) { liveFights++; fightTicks += finishFight(); }
211
+ check('between fights the autopilot keeps its own panel', /^\s*Autopilot\s/m.test(strip(frame())) && strip(frame()).includes(app.ch.name));
212
+ show('autopilot between fights');
213
+ term.press('3'); check('tabs can be viewed while the autopilot runs', app.tab === 2 && !!app.auto && /Bags|bag/i.test(strip(frame())));
214
+ term.press('w'); check('w goes back to watching the run', !app.auto.browsing && /^\s*Autopilot\s/m.test(strip(frame())));
215
+ term.press('l'); check('log window opens over the autopilot', app.top().id === 'log');
216
+ term.press('escape'); check('Esc closes the log and leaves the autopilot running', app.stack.length === 0 && !!app.auto);
217
+ term.press('1');
218
+ }
219
+ check(`autopilot fought ${liveFights} live fights over ${steps} steps (${fightTicks} ticks)`, liveFights > 0 && app.log.some((l) => /hunting:|quest target:/.test(strip(l))));
220
+ check('autopilot counted its fights', autoCtl.fights === liveFights);
221
+ if (app.auto) { term.press('escape'); check('Esc stops the autopilot', !app.auto); }
222
+
223
+ // Autopilot fight interrupted by Esc stops the run.
224
+ term.press('a');
225
+ while (app.auto && !app.fight && app.auto.step()) { /* reach a fight */ }
226
+ if (app.fight) { term.press('escape'); check('Esc in an autopilot fight stops the run', !app.fight && !app.auto); }
227
+ else { if (app.auto) app.auto.stop(); check('autopilot reached a fight to interrupt', false); }
228
+
229
+ // A quest fight lost: the quest is blacklisted and the run goes on instead of stopping.
230
+ {
231
+ const lost = app.cmds.createAutopilot('30', { liveFight: (step, settle) => { settle({ outcome: 'defeat' }); return { ok: true }; } });
232
+ let line = '';
233
+ for (let n = 0; n < 30 && !app.cmds.questBlacklist.size && line != null; n++) line = lost.next();
234
+ check('a quest the autopilot died on is blacklisted', app.cmds.questBlacklist.size > 0 && app.log.some((l) => /skipping .*died 2 times/.test(strip(l))));
235
+ check('dying on a quest did not stop the run', !lost.done);
236
+ }
237
+
238
+ // Do Quest: d on a quest log row runs the autopilot pinned to that quest, and clears its blacklist entry.
239
+ home(); term.press('2');
240
+ const doRow = rowIndex((r) => r.data?.qs?.status === 'active');
241
+ check('the quest log has an active quest to do', doRow >= 0);
242
+ if (doRow >= 0) {
243
+ const doEntry = app.rowsOf(app.top())[doRow].data.q.entry;
244
+ app.cmds.questBlacklist.add(doEntry);
245
+ app.top().cursor = doRow; term.press('d');
246
+ check('d on the quest log starts Do Quest', !!app.auto && !app.cmds.questBlacklist.has(doEntry));
247
+ show('do quest');
248
+ let n = 0;
249
+ while (app.auto && n < 400) { if (app.fight) { finishFight(); continue; } if (!app.auto.step()) break; n++; }
250
+ const st = app.g.state.questlog.quests.find((q) => q.entry === doEntry)?.status;
251
+ check(`Do Quest finished the quest (${st} after ${n} steps)`, st === 'turnedIn' || st === 'complete');
252
+ if (app.auto) app.auto.stop();
253
+ }
254
+
255
+ // Rotation editing through the menu: toggle a rule, add one, give it a condition, delete it.
256
+ home(); term.press('6');
257
+ show('rotation');
258
+ const ruleRow = app.rowsOf(app.top()).findIndex((r) => r.data?.rule);
259
+ const ruleData = app.rowsOf(app.top())[ruleRow].data;
260
+ app.top().cursor = ruleRow; term.press('return');
261
+ check('Enter on a rule opens its menu', app.top().id === 'pick');
262
+ show('rule menu');
263
+ term.press('return'); // first entry: turn it off
264
+ check('rule turned off through the menu', app.g.rotation().custom === true && app.g.rotation().phases[ruleData.phase][ruleData.index].enabled === false);
265
+ term.press('return'); term.press('return'); // back on
266
+ check('rule turned on again', app.g.rotation().phases[ruleData.phase][ruleData.index].enabled === true);
267
+ const before = app.g.rotation().phases.combat.length;
268
+ const addRow = rowIndex((r, t) => /\+ Add a rule to combat/.test(t));
269
+ check('combat list has an add row', addRow > 0);
270
+ app.top().cursor = addRow; term.press('return');
271
+ check('add-rule picker opened', app.top().id === 'pick');
272
+ term.press('return');
273
+ check('rule added to combat', app.g.rotation().phases.combat.length === before + 1);
274
+ check('cursor landed on the new rule', app.rowsOf(app.top())[app.top().cursor]?.data?.rule?.id?.startsWith('tui-'));
275
+ term.press('return'); term.press('down'); term.press('return'); // menu → change when it fires
276
+ check('condition picker opened', app.top().id === 'pick' && /fire only when/.test(app.top().title));
277
+ term.press('down'); term.press('return'); term.type('50');
278
+ check('condition applied', JSON.stringify(app.g.rotation().phases.combat).includes('"healthPct"'));
279
+ show('rotation edited');
280
+ term.press('return'); term.press('end'); term.press('return'); // menu → delete
281
+ check('rule deleted through the menu', app.g.rotation().phases.combat.length === before);
282
+
283
+ // Gear: a slot opens a picker of what fits it; the equipment helper marks and equips upgrades.
284
+ home();
285
+ {
286
+ const pick = (slot, q) => ds.items().find((i) => i.slot === slot && (i.requiredLevel ?? 1) <= app.ch.level && i.quality >= q && (i.armor ?? 0) > 0 && !i.allowClass?.length && i.entry > 100) ?? null;
287
+ const worn = app.g.state.char.equipped.chest != null ? app.ds.item(app.g.state.char.equipped.chest?.entry ?? app.g.state.char.equipped.chest) : null;
288
+ const chest = ds.items().filter((i) => i.slot === 'chest' && (i.requiredLevel ?? 1) <= app.ch.level && i.quality >= 2 && (i.armor ?? 0) > (worn?.armor ?? 0) + 20 && !i.allowClass?.length && i.entry > 100)[0] ?? pick('chest', 1);
289
+ if (chest) app.g.state.inventory = addItem(app.g.state.inventory, chest.entry, 1, 1);
290
+ term.press('4'); show('gear');
291
+ check('gear tab shows slot labels', /Head\s|Chest\s|Main Hand/.test(strip(frame())));
292
+ const chestRow = rowIndex((r, t) => t.startsWith('Chest'));
293
+ check('gear tab has a Chest slot row', chestRow >= 0);
294
+ app.top().cursor = chestRow; term.press('return'); show('slot picker');
295
+ check('Enter on a slot opens its picker', app.top().id === 'slot-chest');
296
+ const cand = rowIndex((r, t) => chest && t.includes(chest.name));
297
+ check('the picker lists the chest piece from the bags', !!chest && cand > 0);
298
+ if (cand > 0) {
299
+ app.top().cursor = cand; term.press('return');
300
+ check('Enter in the picker equips it and closes', app.stack.length === 0 && (app.g.state.char.equipped.chest?.entry ?? app.g.state.char.equipped.chest) === chest.entry);
301
+ }
302
+ const ups = app.g.suggestedEquipmentUpgrades();
303
+ check(`equipment helper answers (${ups.length} upgrades)`, Array.isArray(ups));
304
+ term.press('u'); // nothing or everything; must not throw
305
+ check('u on the gear tab runs the helper', app.tab === 3);
306
+ }
307
+
308
+ // Bags: an armor kit asks which slot it goes on, the picker greys refused slots with the reason, and Enter
309
+ // applies it; ammo loads from the bags; a plain material opens the item menu instead of "Cannot be consumed".
310
+ home();
311
+ {
312
+ const kit = ds.items().find((i) => i.name === 'Light Armor Kit');
313
+ const arrow = ds.items().find((i) => i.name === 'Rough Arrow');
314
+ const cloth = ds.items().find((i) => i.name === 'Linen Cloth');
315
+ for (const it of [kit, arrow, cloth]) if (it) app.g.state.inventory = addItem(app.g.state.inventory, it.entry, 1, 1);
316
+ const { useStack } = await import('./tuiBags.mjs');
317
+ const slotOf = (it) => app.g.state.inventory.stacks.findIndex((s) => s.item === it?.entry);
318
+ term.press('3');
319
+ useStack(app, slotOf(kit)); show('armor kit picker');
320
+ check('an armor kit opens the slot picker', app.top()?.id === 'pick' && /Apply Light Armor Kit/.test(strip(app.top().title)));
321
+ const rows = app.rowsOf(app.top()).map((r) => strip(r.text));
322
+ check('the picker lists an eligible slot and greys a refused one with its reason', rows.some((t) => /^Legs:/.test(t)) && rows.some((t) => /Main Hand:.*only goes on armor/.test(t)));
323
+ const refused = rowIndex((r, t) => /Main Hand:/.test(t));
324
+ app.top().cursor = refused; term.press('return');
325
+ check('Enter on a refused slot keeps the picker open', app.top()?.id === 'pick');
326
+ const legsBefore = JSON.stringify(app.g.state.char.equipped.legs);
327
+ app.top().cursor = rowIndex((r, t) => /^Legs:/.test(t)); term.press('return');
328
+ check('Enter on an eligible slot applies the kit and closes the picker', app.stack.length === 0 && JSON.stringify(app.g.state.char.equipped.legs) !== legsBefore && slotOf(kit) < 0);
329
+ useStack(app, slotOf(arrow));
330
+ check('Enter on ammo in the bags loads it', app.g.state.char.ammoItem === arrow?.entry);
331
+ useStack(app, slotOf(cloth)); show('item menu from cloth');
332
+ check('Enter on cloth opens the item menu', app.top()?.id === 'pick' && /Linen Cloth/.test(strip(app.top().title ?? '')));
333
+ home();
334
+ }
335
+
336
+ // Bags: Enter on a bag equips it into a bag slot; the Gear tab's Bag slot picker lists bags too.
337
+ home();
338
+ {
339
+ const bag = ds.items().find((i) => i.slot === 'bag' && i.itemClass !== 11 && (i.bagSlots ?? 0) >= 6 && (i.requiredLevel ?? 1) <= 1 && i.quality <= 1 && !i.allowClass?.length);
340
+ if (bag) app.g.state.inventory = addItem(app.g.state.inventory, bag.entry, 1, 1);
341
+ term.press('3'); show('bags with a bag');
342
+ const bagRow = rowIndex((r, t) => bag && t.includes(bag.name));
343
+ check('bags tab lists the bag', bagRow > 0);
344
+ const cap = app.g.state.inventory.stacks.length;
345
+ if (bagRow > 0) {
346
+ app.top().cursor = bagRow; term.press('return');
347
+ const slots = Object.entries(app.g.state.char.equipped).filter(([k, v]) => k.startsWith('bag') && v != null);
348
+ check('Enter on a bag equips it', slots.length === 1 && app.g.state.inventory.stacks.length === cap - 1);
349
+ term.press('4');
350
+ const bagSlotRow = rowIndex((r, t) => /^Bag 1\s/.test(t) && t.includes(bag.name));
351
+ check('gear tab shows the bag in Bag 1', bagSlotRow > 0);
352
+ term.press('3');
353
+ check('bags grew by the bag size', /\/(2[0-9]|3[0-9]) slots/.test(strip(frame())));
354
+ }
355
+ }
356
+
357
+ // City services: not offered in Elwynn; a capital lists the bank, the auction house and the mailbox.
358
+ home(); term.press('1');
359
+ check('no bank or auction house in the start zone', rowIndex((r, s) => /^(Bank|Auction House)\b/.test(s)) < 0);
360
+ term.press('3'); term.press('b');
361
+ check('b in Bags outside a bank town only flashes', app.stack.length === 0);
362
+ {
363
+ const back = app.g.state.char.zoneId;
364
+ app.g.state.char.zoneId = 'stormwind';
365
+ home(); term.press('1'); show('capital');
366
+ check('capital World tab lists Services above the people', /Services/.test(strip(frame())) && rowIndex((r, t) => t.startsWith('Bank')) > 0 && rowIndex((r, t) => t.startsWith('Auction House')) > 0 && rowIndex((r, t) => t.startsWith('Mailbox')) > 0 && rowIndex((r, t) => t.startsWith('Bank')) < app.rowsOf(app.top()).findIndex((r) => /^People/.test(strip(r.text))));
367
+ const bankRow = rowIndex((r, t) => t.startsWith('Bank'));
368
+ app.top().cursor = bankRow; term.press('return'); show('bank');
369
+ check('bank dialog opened', app.top().id === 'bank');
370
+ const stacks = app.g.state.inventory.stacks.length;
371
+ const dep = rowIndex((r) => r.data?.s);
372
+ if (dep >= 0) {
373
+ app.top().cursor = dep; term.press('return');
374
+ check('Enter deposits a stack', app.g.state.bank.stacks.length === 1 && app.g.state.inventory.stacks.length === stacks - 1);
375
+ term.press('tab'); show('bank withdraw');
376
+ check('Tab switches to the bank side', /Bank → your bags/.test(strip(frame())));
377
+ term.press('return');
378
+ check('Enter withdraws it again', app.g.state.bank.stacks.length === 0 && app.g.state.inventory.stacks.length === stacks);
379
+ }
380
+ term.press('escape');
381
+ term.press('3'); term.press('b'); check('b in Bags opens the bank in a capital', app.top().id === 'bank'); term.press('escape');
382
+ home(); term.press('1');
383
+ const ahRow = rowIndex((r, t) => t.startsWith('Auction House'));
384
+ app.top().cursor = ahRow; term.press('return'); show('auction house');
385
+ check('auction dialog opened', app.top().id === 'auction');
386
+ const listings = app.g.browseAuctions(Date.now()).length;
387
+ check(`auction house has bot listings (${listings})`, listings > 0);
388
+ term.press('s'); term.type('cloth'); show('auction search');
389
+ check('search filters the boards', /"cloth"/.test(strip(frame())));
390
+ term.press('tab'); show('auction sell');
391
+ check('Tab moves to the sell list', /Pick something from your bags/.test(strip(frame())));
392
+ // Sell flow: quantity, bid, buyout, duration; then cancel it from My auctions.
393
+ app.g.state.inventory = addItem(app.g.state.inventory, 2589, 3, 20); // Linen Cloth: a trade good, not vendor stock
394
+ app.render();
395
+ const sellRow = rowIndex((r, t) => /Linen Cloth/.test(t));
396
+ if (sellRow > 0) {
397
+ const bags = app.g.state.inventory.stacks.length;
398
+ app.top().cursor = sellRow; term.press('return');
399
+ check('selling asks how many', !!app.cmd && /How many/.test(app.cmd.label));
400
+ term.type('1'); term.type('5g'); term.type('9g'); // appended to the suggested prices: well above what a vendor pays
401
+ check('duration picker opened', app.top().id === 'pick' && /How long/.test(app.top().title));
402
+ term.press('return');
403
+ check('listing posted', app.g.ownAuctions().length === 1 && app.g.state.inventory.stacks.length <= bags);
404
+ show('after listing');
405
+ } else check('a sellable stack is in the bags', false);
406
+ term.press('tab'); show('my auctions');
407
+ check('Tab moves to my auctions', /Your listings/.test(strip(frame())));
408
+ const mine = rowIndex((r) => r.data?.auction);
409
+ if (mine > 0) { app.top().cursor = mine; term.press('return'); term.press('return'); check('Enter cancels the listing', app.g.ownAuctions().length === 0); }
410
+ term.press('escape');
411
+ const mailRow = rowIndex((r, t) => t.startsWith('Mailbox'));
412
+ app.top().cursor = mailRow; term.press('return'); show('mailbox');
413
+ check('mailbox opened', app.top().id === 'mail' && /No mail|letter/.test(strip(frame())));
414
+ term.press('escape');
415
+ app.g.state.char.zoneId = back;
416
+ }
417
+
418
+ // ---- professions and companions ------------------------------------------------------------------------
419
+ {
420
+ const g = app.g;
421
+ const tabOf = (id) => app.tabs.findIndex((t) => t.view.id === id);
422
+ const inv = () => g.state.inventory;
423
+ const rowsNow = () => app.rowsOf(app.top());
424
+ home(); term.press('9'); show('professions, none known');
425
+ check('9 opens the Professions tab', app.tab === tabOf('profs'));
426
+ check('with no professions it says where the trainers are', /no professions yet/.test(strip(frame())));
427
+ g.devLearnProfession('tailoring');
428
+ g.ensureProfessionStarters();
429
+ g.state.char.gold = 10_000_000;
430
+ // Exactly 4 Linen Cloth (the auction test left some behind).
431
+ g.state.inventory = addItem({ stacks: g.state.inventory.stacks.filter((s) => s.item !== 2589) }, 2589, 4, 20);
432
+ app.render(); show('professions tab');
433
+ const tailor = rowIndex((r, t) => /^Tailoring/.test(t));
434
+ check('the tab lists Tailoring', tailor >= 0);
435
+ if (tailor >= 0) {
436
+ app.top().cursor = tailor; term.press('return'); show('tailoring book');
437
+ check('Enter opens the recipe book', app.top().id === 'book-tailoring');
438
+ const bolt = rowIndex((r) => r.data?.recipe?.name === 'Bolt of Linen Cloth');
439
+ check('Bolt of Linen Cloth is ready twice from 4 cloth', bolt > 0 && /^Bolt of Linen Cloth\s+2 ready/.test(strip(rowsNow()[bolt].text).trim()));
440
+ if (bolt > 0) { app.top().cursor = bolt; term.press('A'); }
441
+ check('A makes all of them', countItem(inv(), 2996) === 2 && countItem(inv(), 2589) === 0);
442
+ const cloak = rowIndex((r) => r.data?.recipe?.name === 'Linen Cloak');
443
+ check('Linen Cloak shows the thread buy-in', cloak > 0 && /buys/.test(strip(rowsNow()[cloak].text)));
444
+ const gold = g.state.char.gold, product = ds.recipe(2387).product.item;
445
+ if (cloak > 0) { app.top().cursor = cloak; term.press('return'); }
446
+ check('crafting buys the Coarse Thread from the purse', countItem(inv(), product) === 1 && g.state.char.gold < gold);
447
+ check('one summary line says what was made', /You create Linen Cloak/.test(strip(frame())));
448
+ term.press('h'); check('h narrows the book to what can be made', !rowsNow().some((r) => r.data?.recipe && !r.data.chk.ok));
449
+ term.press('escape');
450
+ }
451
+
452
+ // Hire a companion at the Elwynn inn, teach them Tailoring, hand them cloth, and craft as them. The slate is
453
+ // within two levels of yours, and a primary profession trains at 5, so hire as a level 10.
454
+ g.state.char.level = 10;
455
+ home(); term.press('0'); show('companions, none');
456
+ check('0 opens the Companions tab', app.tab === tabOf('companions'));
457
+ const hire = rowIndex((r, t) => /^Hire a companion/.test(t));
458
+ check('the inn offers hires', hire >= 0);
459
+ if (hire >= 0) {
460
+ app.top().cursor = hire; term.press('return'); show('hire list');
461
+ check('the hire list opened', app.top().id === 'hire' && rowIndex(() => true) >= 0);
462
+ check('hire rows name their trades', /(Mining|Herb|Skin|Tailor|Smith|Engi|Alchemy|Enchant|Leather) \d+/.test(strip(app.rowsOf(app.top()).find((r) => !r.header)?.text ?? '')));
463
+ term.press('return'); show('candidate page');
464
+ check('Enter opens the candidate page', app.top().id.startsWith('cand-'));
465
+ check('the candidate page lists professions and stats', app.rowsOf(app.top()).some((r) => r.header && strip(r.text) === 'Professions') && rowIndex((r, t) => /^Health \d+/.test(t)) >= 0);
466
+ app.top().cursor = rowIndex((r, t) => /^Hire as/.test(t)); term.press('return'); show('after hire');
467
+ check('hiring returns to the hire list, not the tab', app.top()?.id === 'hire');
468
+ term.press('escape'); check('Esc closes the hire list', app.stack.length === 0);
469
+ const comp = (g.state.squad ?? [])[0];
470
+ check('a companion joined the party', !!comp);
471
+ if (comp) {
472
+ const row = rowIndex((r, t) => t.startsWith(comp.name));
473
+ app.top().cursor = row; term.press('return'); show('companion page');
474
+ check('Enter opens their page', app.top().id === `comp-${comp.id}`);
475
+ // Make room for Tailoring: drop one primary when they hold two.
476
+ const lines = g.companionProfessionsOf(comp.id)?.lines ?? [];
477
+ const primaries = lines.filter((l) => l.kind !== 'secondary');
478
+ if (!lines.some((l) => l.line === 'tailoring')) {
479
+ if (primaries.length >= 2) g.unlearnCompanionProfession(comp.id, primaries[0].line);
480
+ app.render();
481
+ const take = rowIndex((r, t) => /Take up a profession/.test(t));
482
+ app.top().cursor = take; term.press('return'); show('take up a profession');
483
+ const tl = rowIndex((r, t) => /^Tailoring/.test(t));
484
+ app.top().cursor = tl; term.press('return');
485
+ }
486
+ check('they take up Tailoring', !!g.companionProfessionsOf(comp.id)?.lines.some((l) => l.line === 'tailoring'));
487
+ home(); term.press('0');
488
+ g.state.inventory = addItem(g.state.inventory, 2589, 6, 20);
489
+ app.render();
490
+ term.press('g'); show('send goods');
491
+ check('g opens Send goods', app.top().id === 'send-goods');
492
+ const gift = rowIndex((r, t) => /Linen Cloth x6/.test(t) && t.includes(comp.name));
493
+ check('the cloth goes to the new tailor', gift > 0);
494
+ app.top().cursor = rowIndex((r, t) => t.startsWith('Apply')); term.press('return');
495
+ check('Apply hands it over', countItem(comp.bags ?? { stacks: [] }, 2589) === 6 && countItem(inv(), 2589) === 0);
496
+ home(); term.press('9');
497
+ const theirs = rowIndex((r, t) => t.startsWith(comp.name.slice(0, 10)) && /Tailoring/.test(t));
498
+ check('the Professions tab lists their Tailoring', theirs > 0);
499
+ if (theirs > 0) {
500
+ app.top().cursor = theirs; term.press('return'); show('companion book');
501
+ const bolt = rowIndex((r) => r.data?.recipe?.name === 'Bolt of Linen Cloth');
502
+ check('their book has Bolt of Linen Cloth ready', bolt > 0 && rowsNow()[bolt].data.chk.ok);
503
+ const had = countItem(comp.bags ?? { stacks: [] }, 2996); // Send goods may have handed over a spare bolt
504
+ if (bolt > 0) { app.top().cursor = bolt; term.press('return'); }
505
+ check('they craft it into their own bags', countItem(comp.bags ?? { stacks: [] }, 2996) === had + 1);
506
+ term.press('escape');
507
+ }
508
+ const party = rowIndex((r, t) => /Everything the party can make/.test(t));
509
+ app.top().cursor = party; term.press('return'); show('party book');
510
+ check('the party book opens', app.top().id === 'party-book' && /crafter/.test(strip(frame())));
511
+ term.press('escape');
512
+ }
513
+ }
514
+
515
+ // Vendor rows flag the reagents the party crafts with.
516
+ home(); term.press('1');
517
+ const vend = rowIndex((r, t) => /Trade Supplies|General Goods|Supplies/.test(t));
518
+ if (vend > 0) {
519
+ app.top().cursor = vend; term.press('return'); show('vendor with reagents');
520
+ const thread = rowIndex((r) => r.data?.itemEntry === 2320);
521
+ if (thread > 0) check('the vendor flags Coarse Thread as a reagent', /reagent/.test(strip(rowsNow()[thread].text)));
522
+ term.press('escape');
523
+ }
524
+
525
+ // Gathering lines list the zone's nodes on the World tab.
526
+ g.devLearnProfession('mining');
527
+ g.state.inventory = addItem(g.state.inventory, 2901, 1, 1); // Mining Pick
528
+ home(); term.press('1'); show('world with mining');
529
+ const vein = rowIndex((r, t) => /Copper Vein/.test(t));
530
+ check('the World tab lists Copper Vein for a miner', vein > 0);
531
+ if (vein > 0) {
532
+ app.top().cursor = vein; term.press('return');
533
+ check('Enter mines it', countItem(inv(), 2770) > 0);
534
+ }
535
+ }
536
+
537
+ // ---- companions fight beside you (squad mode) -------------------------------------------------------------
538
+ {
539
+ const g = app.g;
540
+ const tabOf = (id) => app.tabs.findIndex((t) => t.view.id === id);
541
+ if ((g.state.squad ?? []).length < 2) {
542
+ const cand = g.innCandidates()[0];
543
+ if (cand) g.hireCandidate(cand.bot.id, 'healer');
544
+ }
545
+ const squad = g.state.squad ?? [];
546
+ check(`two companions are seated (${squad.length})`, squad.length >= 2);
547
+ home(); app.switchTab(tabOf('companions'));
548
+ term.press('m'); show('squad mode picker');
549
+ check('m on Companions opens the squad mode picker', app.top().id === 'pick' && /How do your companions fight/.test(app.top().title));
550
+ app.top().cursor = rowIndex((r, t) => /^\S*\s*Party/.test(t)); term.press('return');
551
+ check('Party mode is set', g.autopilotConfig().questSquadAlways === true && /Fights: Party/.test(strip(frame())));
552
+ // A creature near your level so the companions earn xp from it.
553
+ home(); term.press('1');
554
+ const mobs = app.rowsOf(app.top()).map((r, i) => [r, i]).filter(([r]) => r.data?.creature && r.data.creature.level <= app.ch.level);
555
+ const [, mobAt] = mobs.sort(([a], [b]) => b.data.creature.level - a.data.creature.level)[0] ?? [null, -1];
556
+ app.g.regenTick(600, true);
557
+ const xpBefore = squad.map((c) => c.xp ?? 0);
558
+ const logFrom = app.log.length;
559
+ app.top().cursor = mobAt; term.press('return');
560
+ check('a party fight started', !!app.fight?.withSquad);
561
+ for (let n = 0; n < 8 && app.fight; n++) app.fight.tick();
562
+ show('party fight');
563
+ const f = strip(frame());
564
+ check('ally frames name both companions with a role letter', squad.every((c) => new RegExp(`[TDH·] ${c.name}`).test(f)));
565
+ // t: an order to the first companion. The fight waits while the pickers are open.
566
+ term.press('t');
567
+ check('t opens party orders and pauses the fight', app.top()?.title === 'Orders: who?' && app.fight.paused);
568
+ term.press('return'); show('orders for one companion');
569
+ const side = app.fight.session.alliesView()[0].side;
570
+ app.top().cursor = rowIndex((r, t) => /Hold ground/.test(t)); term.press('return');
571
+ check('a hold order lands and the fight resumes', app.fight.session.unitStanceOf?.(side) === 'hold' && !app.fight.paused && app.stack.length === 0);
572
+ term.press('t'); term.press('return');
573
+ const atk = rowIndex((r, t) => /^Attack /.test(t));
574
+ app.top().cursor = atk; term.press('return');
575
+ check('an attack order holds the companion on that target', !!app.fight.session.allyTargetOf(side));
576
+ finishFight();
577
+ const fightLog = app.log.slice(logFrom).map(strip);
578
+ check('the combat log names the companions, not ally_N', squad.some((c) => fightLog.some((l) => l.includes(c.name))) && !fightLog.some((l) => /ally_\d/.test(l)));
579
+ const xpNow = (c) => (g.state.squad ?? []).find((x) => x.id === c.id) ?? c;
580
+ check(`companion xp rose (${xpBefore.join(',')} -> ${squad.map((c) => xpNow(c).xp ?? 0).join(',')})`, squad.some((c, i) => (xpNow(c).xp ?? 0) > xpBefore[i] || xpNow(c).level > c.level));
581
+ // Solo mode keeps them home: the next pull has no allies.
582
+ term.press(':'); term.type('squad solo');
583
+ check(':squad solo sets Solo', g.autopilotConfig().questSquadAlways === false && g.autopilotConfig().questEliteMode === 'solo');
584
+ app.top().cursor = mobAt; term.press('return');
585
+ check('a solo pull brings nobody', !!app.fight && app.fight.session.alliesView().length === 0);
586
+ finishFight();
587
+ term.press(':'); term.type('squad party');
588
+ const quickFrom = app.log.length;
589
+ term.press(':'); term.type(`fight ${app.rowsOf(app.top())[mobAt].data.creature.entry}`);
590
+ check(':fight brings the party in Party mode', app.log.slice(quickFrom).some((l) => squad.some((c) => strip(l).includes(c.name))));
591
+ }
592
+
593
+ // ---- dungeons: the Deadmines as a text run ------------------------------------------------------------------
594
+ {
595
+ const g = app.g;
596
+ const { hasFlag } = await import('../world/instances.ts');
597
+ g.state.char.level = 20; g.state.char.gold = 10_000_000;
598
+ const bag = ds.items().find((i) => i.slot === 'bag' && i.itemClass !== 11 && (i.bagSlots ?? 0) >= 10 && (i.requiredLevel ?? 1) <= 20 && !i.allowClass?.length);
599
+ for (let i = 0; i < 4; i++) { g.state.inventory = addItem(g.state.inventory, bag.entry, 1, 1); g.equip(bag.entry); }
600
+ for (const c of [...(g.state.squad ?? [])]) g.dismissCompanion(c.id);
601
+ for (const [cls, role] of [[1, 'tank'], [2, 'healer'], [8, 'dps'], [4, 'dps']]) g.recruitCompanion(cls, role, 'twink');
602
+ for (const c of g.state.squad) c.level = 20;
603
+ g.state.char.zoneId = 'westfall'; g.regenTick(600, true);
604
+ home(); term.press('1'); show('westfall with a portal');
605
+ const portal = rowIndex((r) => r.data?.dungeon?.id === 'deadmines');
606
+ check('the World tab lists the Deadmines portal', portal > 0);
607
+ app.top().cursor = portal; term.press('return'); show('deadmines entry card');
608
+ check('Enter on the portal opens the entry card', app.top().id === 'enter-deadmines' && /Who walks in: 4 of 4/.test(strip(frame())));
609
+ app.top().cursor = rowIndex((r, t) => /^Route:/.test(t)); term.press('return');
610
+ check('the route toggles to Full', /Route: Full/.test(strip(frame())));
611
+ term.press('return');
612
+ app.top().cursor = rowIndex((r, t) => /^Enter the dungeon/.test(t)); term.press('return'); show('run window');
613
+ check('entering opens the run window', app.top()?.id === 'dungeon' && g.state.instance?.dungeonId === 'deadmines' && /Rhahk'Zor/.test(strip(frame())));
614
+ for (let n = 1; n <= 2; n++) {
615
+ app.top().cursor = rowIndex((r, t) => /◀ next/.test(t)); term.press('return');
616
+ check(`stop ${n} starts a live fight with the party`, !!app.fight?.withSquad && app.fight.session.alliesView().length >= 4);
617
+ finishFight();
618
+ check(`stop ${n} is cleared and the run window comes back`, g.state.instance.defeated.length === n && app.top()?.id === 'dungeon');
619
+ }
620
+ show('run window after two stops');
621
+ const chest = rowIndex((r) => r.data?.object?.kind === 'chest' && r.data.object.pin.name === 'Defias Gunpowder');
622
+ app.top().cursor = chest; term.press('return');
623
+ check('the Defias Gunpowder chest gives the gunpowder', countItem(g.state.inventory, 5397) === 1);
624
+ const cannon = rowIndex((r) => r.data?.object?.kind === 'gate');
625
+ app.top().cursor = cannon; term.press('return');
626
+ check('the Defias Cannon opens the iron-clad door', hasFlag(g.state.instance, 'deadmines-iron-clad-door'));
627
+ term.press('P'); show('paladin board');
628
+ check('P opens the paladin board with the paladin companion', app.top().id === 'pallypower' && rowIndex((r, t) => /^\s*Aura/.test(t)) > 0);
629
+ app.top().cursor = rowIndex((r, t) => /^\s*Aura/.test(t)); term.press('return');
630
+ check('Enter on the aura row assigns one', g.paladinBoard().rows.some((r) => r.assignedAura));
631
+ term.press('escape');
632
+ term.press('F'); term.press('return'); show('run report');
633
+ check('finish instantly ends on the run report', app.top()?.id === 'run-summary' && /Run report|Cleared|Partial|Wiped/.test(strip(frame())) && (app.dungeonPulls?.length ?? 0) > 2);
634
+ term.press('escape');
635
+ const cleared = g.state.instance.defeated.length;
636
+ term.press('L');
637
+ check('L leaves and the run is held', !g.state.instance && !!g.state.instanceSaves?.deadmines);
638
+ check('the portal row shows the held run', /your run is held/.test(strip(frame())));
639
+ app.top().cursor = rowIndex((r) => r.data?.dungeon?.id === 'deadmines'); term.press('return');
640
+ check('the entry card offers to go back in', /Go back in/.test(strip(frame())));
641
+ app.top().cursor = rowIndex((r, t) => /^Go back in/.test(t)); term.press('return');
642
+ check('going back in keeps the cleared stops', g.state.instance?.defeated.length === cleared);
643
+ term.press('R'); term.press('return');
644
+ check('R resets the run', g.state.instance?.defeated.length === 0);
645
+ // The continuous floor (2i): walk the Deadmines on its nav mesh; what sees you on the way pulls.
646
+ {
647
+ const { loadNavMesh, stepFloor, navMeshDir } = await import('./tuiFloor.mjs');
648
+ const def = ds.instance('deadmines');
649
+ g.regenTick(600, true);
650
+ if (loadNavMesh(def)) {
651
+ term.press('W'); show('the floor');
652
+ check('W opens the floor with the stops by distance', app.top()?.id === 'floor' && /Where to/.test(strip(frame())));
653
+ const f = app.floor;
654
+ const at0 = { x: f.world.party.x, y: f.world.party.y };
655
+ app.top().cursor = rowIndex((r) => r.data === undefined && !!r.keys?.P); term.press('return');
656
+ check('Enter on a stop starts the walk', f.walking && !!f.target);
657
+ let pulled = false;
658
+ for (let i = 0; i < 3000 && !pulled; i++) pulled = stepFloor(app, f) || !!app.fight;
659
+ check('the party walks the mesh', f.world.walkedYd > 1 && Math.hypot(f.world.party.x - at0.x, f.world.party.y - at0.y) > 1);
660
+ check('what sees you on the way pulls, with the party', pulled && !!app.fight && app.fight.session.alliesView().length >= 4);
661
+ const before = g.state.instance.defeated.length;
662
+ finishFight();
663
+ check('a won floor pull clears its stop and its bodies', g.state.instance.defeated.length === before + 1 && f.world.clearedGuids.length > 0 && app.top()?.id === 'floor');
664
+ term.press('escape');
665
+ term.press('R'); term.press('return');
666
+ check('a reset forgets the floor too', !app.floor && g.state.instance?.defeated.length === 0);
667
+ } else {
668
+ term.press('W');
669
+ check(`no mesh at ${navMeshDir()}: W says so and the run stays sequential`, app.top()?.id === 'dungeon' && /nav mesh/.test(app.flash?.text ?? ''));
670
+ }
671
+ }
672
+ term.press('L');
673
+ check('leaving a fresh run holds nothing', !g.state.instance && !g.state.instanceSaves?.deadmines);
674
+ // A raid door: the walk-in picker and the 20-man group, on Zul'Gurub's card.
675
+ g.state.char.zoneId = 'stranglethorn';
676
+ home(); term.press('1');
677
+ app.top().cursor = rowIndex((r) => r.data?.dungeon?.id === 'zul-gurub'); term.press('return'); show('zul-gurub card');
678
+ check('a raid card offers the raid group', app.top().id === 'enter-zul-gurub' && /Group: Raid/.test(strip(frame())) && /Raid group \(20-man\)/.test(strip(frame())));
679
+ app.top().cursor = rowIndex((r, t) => /^Who walks in/.test(t)); term.press('return');
680
+ term.press('return');
681
+ check('a pick at the door sets the instance roster', g.state.instanceRoster?.dungeonId === 'zul-gurub');
682
+ term.press('c'); check('c goes back to auto', !g.state.instanceRoster);
683
+ term.press('escape');
684
+ app.top().cursor = rowIndex((r, t) => /^Raid group/.test(t)); term.press('return'); term.press('f');
685
+ check('f fills the 20-man group', g.state.raidLineup?.size === 20 && g.state.raidLineup.ids.length > 0);
686
+ term.press('c'); home();
687
+ g.state.char.zoneId = 'westfall';
688
+ }
689
+
690
+ // ---- companions: settings, hire search, rotation, bars, bags, bank, share, pets, duel ------------------------
691
+ {
692
+ const g = app.g;
693
+ const tabOf = (id) => app.tabs.findIndex((t) => t.view.id === id);
694
+ const comp = () => g.state.squad[2];
695
+ const openPage = () => { home(); app.switchTab(tabOf('companions')); app.top().cursor = rowIndex((r, t) => t.startsWith(comp().name)); term.press('return'); };
696
+ openPage(); show('companion page with settings');
697
+ check('the companion page has settings rows', /How they play/.test(strip(frame())) || rowIndex((r, t) => /^Area spells:/.test(t)) > 0);
698
+ app.top().cursor = rowIndex((r, t) => /^Area spells:/.test(t)); term.press('return');
699
+ check('Enter cycles their AoE mode', comp().aoeMode === 'aoe');
700
+ app.top().cursor = rowIndex((r, t) => /^Emergency heals:/.test(t)); term.press('return');
701
+ check('Enter switches off-heal', comp().offHeal === true);
702
+ const locked = comp().xpLocked === true;
703
+ app.top().cursor = rowIndex((r, t) => /^Experience:/.test(t)); term.press('return');
704
+ check('Enter switches their experience lock', (comp().xpLocked === true) !== locked);
705
+ term.press('return');
706
+ const specs = ds.botSpecs(comp().cls) ?? [];
707
+ app.top().cursor = rowIndex((r, t) => /^Spec:/.test(t)); term.press('return');
708
+ const before = comp().specIdx;
709
+ app.top().cursor = app.rowsOf(app.top()).findIndex((r, i) => i !== before); term.press('return');
710
+ check(`a spec pick respecs them (${specs.length} builds)`, specs.length < 2 || comp().specIdx !== before);
711
+ // Bags: give them a potion, use it on them is refused or fine; sell needs a vendor; bank and back.
712
+ const potion = ds.items().find((i) => i.useInCombat?.effect === 'restore_health' && (i.requiredLevel ?? 1) <= 5);
713
+ g.state.inventory = addItem(g.state.inventory, potion.entry, 3, 5);
714
+ g.giveItemToBot(comp().id, potion.entry, 3);
715
+ openPage();
716
+ const potRow = rowIndex((r, t) => t.startsWith(potion.name));
717
+ check('their bags list what you gave', potRow > 0);
718
+ app.top().cursor = potRow; term.press('b');
719
+ check('b puts it in their bank', (comp().bank?.stacks ?? []).some((s) => s.item === potion.entry));
720
+ app.top().cursor = rowIndex((r, t) => t.startsWith(potion.name)); term.press('return');
721
+ check('Enter in their bank takes it back to their bags', (comp().bags?.stacks ?? []).some((s) => s.item === potion.entry));
722
+ // Action bars: their kit bar opens and a slot fills from their spells.
723
+ app.top().cursor = rowIndex((r, t) => /^Action bars/.test(t)); term.press('return'); show('companion bars');
724
+ check('their action bars open', app.top().id === 'bars' && /action bars/.test(strip(frame())));
725
+ term.press('escape'); term.press('escape');
726
+ // Rotation tab: switch to the companion, add a profile, switch back.
727
+ home(); term.press('6'); term.press('w');
728
+ app.top().cursor = rowIndex((r, t) => t.includes(comp().name)); term.press('return'); show('companion rotation');
729
+ check('w shows the companion rotation', strip(frame()).includes(`${comp().name}:`));
730
+ term.press('p'); app.top().cursor = rowIndex((r, t) => /copy of this one/.test(t)); term.press('return'); term.type('Second');
731
+ check('p adds a named companion rotation', g.companionRotationProfiles(comp().id).some((p) => p.name === 'Second' && p.active));
732
+ term.press('w'); term.press('return');
733
+ check('w back to your own rotation', !app.tabs[5].view.whose);
734
+ // Player action bars: bar 1 from the Spells tab, and the fight's number row reads it.
735
+ home(); term.press('5'); term.press('b'); show('player bars');
736
+ check('b on Spells opens your action bars', app.top().id === 'bars' && /Bar 1/.test(strip(frame())));
737
+ term.press('escape');
738
+ // Share consumables and the party vault from the Companions tab.
739
+ g.state.inventory = addItem(g.state.inventory, potion.entry, 8, 5);
740
+ home(); app.switchTab(tabOf('companions')); term.press('s'); show('share consumables');
741
+ check('s opens Share consumables listing the potion', app.top().id === 'share' && strip(frame()).includes(potion.name));
742
+ term.press('A');
743
+ check('A hands potions out', g.state.squad.some((c) => (c.bags?.stacks ?? []).some((s) => s.item === potion.entry)));
744
+ term.press('escape'); term.press('v');
745
+ check('v opens party storage', app.top().id === 'vault' && rowIndex((r, t) => t.includes(potion.name)) > 0);
746
+ app.top().cursor = rowIndex((r, t) => t.includes(potion.name)); term.press('return');
747
+ check('Enter in party storage takes it all back', countItem(g.state.inventory, potion.entry) > 0 && g.state.squad.every((c) => !(c.bags?.stacks ?? []).some((s) => s.item === potion.entry)));
748
+ term.press('escape');
749
+ // A hunter on the bench with a pet picker; a tavern duel at an inn.
750
+ const hunter = g.recruitCompanion(3, 'dps', 'field');
751
+ const hc = Object.values(g.state.botRoster ?? {}).find((c) => c.id === hunter.id) ?? g.state.squad.find((c) => c.id === hunter.id);
752
+ if (hc) hc.level = 20;
753
+ const benched = g.dismissCompanion(g.state.squad[3].id);
754
+ g.activateCompanion(hunter.id);
755
+ home(); app.switchTab(tabOf('companions')); app.top().cursor = rowIndex((r, t) => t.startsWith(hc?.name ?? '?')); term.press('return');
756
+ const petRow = rowIndex((r, t) => /^Tame another beast/.test(t));
757
+ check('a hunter companion has a pet picker', petRow > 0 && benched.ok);
758
+ if (petRow > 0) {
759
+ app.top().cursor = petRow; term.press('return'); term.press('down'); term.press('return');
760
+ check('picking a beast gives them that pet', !!(g.state.squad.find((c) => c.id === hunter.id)?.pet));
761
+ }
762
+ g.state.char.zoneId = 'elwynn';
763
+ home(); app.switchTab(tabOf('companions')); app.top().cursor = rowIndex((r, t) => t.startsWith(comp().name)); term.press('return');
764
+ const duelRow = rowIndex((r, t) => /^Duel /.test(t));
765
+ check('at an inn a companion can be duelled', duelRow > 0);
766
+ if (duelRow > 0) {
767
+ const hp = g.state.char.health;
768
+ app.top().cursor = duelRow; term.press('return');
769
+ check('the duel runs in the live fight view', !!app.fight && app.fight.label === 'duel');
770
+ finishFight();
771
+ check('a duel costs nothing and ends on a yield', !app.fight && app.log.slice(-6).some((l) => /yield|gives way/.test(strip(l))) && g.state.char.health > 0 && hp > 0);
772
+ }
773
+ // Hire search from the hire list.
774
+ home(); app.switchTab(tabOf('companions'));
775
+ app.top().cursor = rowIndex((r, t) => /^Hire a companion/.test(t)); term.press('return');
776
+ term.press('/'); term.type('mage');
777
+ check('/ in the hire list searches the faction', app.top().id === 'hire-search' && app.rowsOf(app.top()).some((r) => !r.header && /Mage/.test(strip(r.text))));
778
+ home();
779
+ }
780
+
781
+ // ---- a hunter's pet: tame, orders, autocast, stable, pet trainer ----------------------------------------------
782
+ {
783
+ const term2 = new HeadlessTerm(110, 34);
784
+ const app2 = new App({ term: term2, ds, repo });
785
+ term2.open();
786
+ const dwarf = ds.races().find((r) => r.name === 'Dwarf');
787
+ app2.startGame(newGameState(ds, dwarf.id, 3, 'Pettest', 99, 0), 'pettest', Date.now(), true);
788
+ const g2 = app2.g;
789
+ g2.state.char.level = 12; g2.state.char.gold = 1_000_000;
790
+ app2.render();
791
+ const at = (pred) => app2.rowsOf(app2.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
792
+ app2.top().cursor = at((r) => r.data?.creature?.tameable && r.data.creature.level <= 12); term2.press('t');
793
+ check('t on a tameable beast tames it', !!g2.state.char.pet);
794
+ app2.top().cursor = at((r, t) => /^Pet:/.test(t)); term2.press('return');
795
+ if (showFrames) console.log(`\n=== pet page ===\n${term2.text()}`);
796
+ check('the World tab Pet row opens the pet page', app2.top().id === 'pet');
797
+ app2.top().cursor = at((r, t) => /^Command:/.test(t)); term2.press('return');
798
+ check('Enter switches the pet to stay', g2.state.char.pet.command === 'stay');
799
+ app2.top().cursor = at((r, t) => /^Reaction:/.test(t)); term2.press('return');
800
+ check('Enter cycles its reaction', g2.state.char.pet.react === 'passive' || g2.state.char.pet.react === 'aggressive');
801
+ const ability = at((r, t) => /^✓ /.test(t));
802
+ app2.top().cursor = ability; term2.press('return');
803
+ check('Enter turns an ability off autocast', (g2.state.char.pet.autocastOff ?? []).length > 0);
804
+ app2.top().cursor = at((r, t) => /^Buy a stable slot/.test(t)); term2.press('return');
805
+ check('a stable slot can be bought', (g2.state.char.stableSlots ?? 0) === 1);
806
+ const name = g2.state.char.pet.name;
807
+ app2.top().cursor = at((r, t) => /^Stable /.test(t)); term2.press('return');
808
+ check('the pet goes into the stable', !g2.state.char.pet && g2.state.char.stablePets?.[0]?.name === name);
809
+ app2.top().cursor = at((r, t) => /Slot 1:/.test(t)); term2.press('return');
810
+ check('and comes back out', g2.state.char.pet?.name === name);
811
+ term2.press('escape');
812
+ g2.state.char.zoneId = 'dunmorogh'; app2.render();
813
+ const trainer = at((r, t) => /<Pet Trainer>/.test(t));
814
+ check('Dun Morogh lists a pet trainer', trainer > 0);
815
+ if (trainer > 0) {
816
+ app2.top().cursor = trainer; term2.press('return');
817
+ const rank = at((r, t) => !/level \d+/.test(t) && !/^✓/.test(t));
818
+ app2.top().cursor = rank; term2.press('return');
819
+ check('a pet trainer rank can be bought', (g2.state.char.petSpells ?? []).length > 0);
820
+ }
821
+ term2.close?.();
822
+ }
823
+
824
+ // Loot window: auto-loot off holds the corpse's drops and coin; Enter takes a row, a takes the rest, Esc leaves it.
825
+ {
826
+ home(); term.press('1');
827
+ term.press(':'); for (const ch of 'autoloot off') term.press(ch === ' ' ? 'space' : ch); term.press('return');
828
+ check(':autoloot off turns auto-loot off', app.prefs.get('autoLoot') === false);
829
+ let held = false;
830
+ for (let i = 0; i < 8 && !held; i++) {
831
+ app.idleTick(600, true);
832
+ const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
833
+ if (mob < 0) break;
834
+ app.top().cursor = mob; term.press('return'); finishFight();
835
+ held = app.top()?.id === 'loot';
836
+ if (!held && app.g.pendingLoot()) app.g.closeLoot();
837
+ }
838
+ // Nothing dropped in eight kills: fill the container by hand so the window's verbs still get exercised.
839
+ if (!held) {
840
+ const linen = ds.allItems().find((it) => /^Linen Cloth$/.test(it.name));
841
+ app.g.openLoot({ kind: 'corpse', title: 'Test Corpse' }, [{ item: linen.entry, count: 2 }], 37);
842
+ term.press('escape'); term.press(':'); for (const ch of 'autoloot off') term.press(ch === ' ' ? 'space' : ch); term.press('return');
843
+ app.push((await import('./tuiLoot.mjs')).lootDialog(app));
844
+ }
845
+ show('loot window');
846
+ check('a won fight with auto-loot off opens the loot window', app.top()?.id === 'loot' && !!app.g.pendingLoot());
847
+ const before = app.g.pendingLoot();
848
+ const n0 = before.rows.length + (before.copper > 0 ? 1 : 0);
849
+ const gold0 = app.ch.gold, bag0 = app.g.state.inventory.stacks.reduce((n, st) => n + st.count, 0);
850
+ term.press('return');
851
+ const after1 = app.g.pendingLoot();
852
+ check('Enter takes one row out of the container', (after1 ? after1.rows.length + (after1.copper > 0 ? 1 : 0) : 0) === n0 - 1 && (app.ch.gold > gold0 || app.g.state.inventory.stacks.reduce((n, st) => n + st.count, 0) > bag0));
853
+ if (app.g.pendingLoot()) term.press('a');
854
+ check('a takes the rest and closes the window', !app.g.pendingLoot() && app.top()?.id !== 'loot');
855
+ // Esc on a held corpse leaves the rest behind.
856
+ const linen = ds.allItems().find((it) => /^Linen Cloth$/.test(it.name));
857
+ app.g.openLoot({ kind: 'corpse', title: 'Test Corpse' }, [{ item: linen.entry, count: 1 }], 5);
858
+ app.push((await import('./tuiLoot.mjs')).lootDialog(app));
859
+ term.press('escape');
860
+ check('Esc closes the window and leaves the leftovers', !app.g.pendingLoot() && app.top()?.id !== 'loot');
861
+ app.prefs.set('autoLoot', true);
862
+ }
863
+
864
+ // Bag housekeeping: open a container, split, move, sort, wrap and unwrap, destroy, the keyring.
865
+ {
866
+ const g = app.g;
867
+ home(); app.switchTab(2);
868
+ const bagRow = (entry) => rowIndex((r) => r.data?.item === entry);
869
+ const lvl = g.state.char.level;
870
+ const box = ds.allItems().find((it) => !(it.lockId > 0) && !it.questItem && (it.requiredLevel ?? 0) <= lvl && ds.lootTable(`item:${it.entry}`).length > 0 && ds.lootTable(`item:${it.entry}`).every((l) => !l.questRequired));
871
+ if (box) {
872
+ g.state.inventory = addItem(g.state.inventory, box.entry, 2, box.stackable ?? 1);
873
+ app.render(); app.top().cursor = bagRow(box.entry); show('bags with a container');
874
+ check('a container shows as openable', /open/.test(strip(app.rowsOf(app.top())[app.top().cursor].text)));
875
+ app.prefs.set('autoLoot', false);
876
+ term.press('return'); show('opened container');
877
+ check('Enter opens a container into the loot window', countItem(g.state.inventory, box.entry) === 1 && (app.top()?.id === 'loot' || !g.pendingLoot()));
878
+ if (app.top()?.id === 'loot') term.press('a');
879
+ app.prefs.set('autoLoot', true);
880
+ check('the container is empty afterwards', !g.pendingLoot());
881
+ home(); app.switchTab(2);
882
+ } else check('the data has an openable container', false);
883
+ // A trade good the character does not hold yet, so every count below is exact.
884
+ const linen = ds.allItems().find((it) => it.itemClass === 7 && (it.stackable ?? 1) >= 10 && it.quality === 1 && !it.questItem && countItem(g.state.inventory, it.entry) === 0);
885
+ g.state.inventory = addItem(g.state.inventory, linen.entry, 5, linen.stackable);
886
+ app.render();
887
+ const stacksOf = (entry) => g.state.inventory.stacks.filter((x) => x.item === entry).map((x) => x.count);
888
+ app.top().cursor = bagRow(linen.entry); term.press('/'); term.press('2'); term.press('return');
889
+ check('/ splits a stack', stacksOf(linen.entry).includes(2) && stacksOf(linen.entry).length >= 2);
890
+ const at = g.state.inventory.stacks.findIndex((x) => x.item === linen.entry);
891
+ app.top().cursor = bagRow(linen.entry);
892
+ const next = g.state.inventory.stacks[at + 1];
893
+ term.press(']');
894
+ check('] moves a stack down a slot', g.state.inventory.stacks[at + 1]?.item === linen.entry || (next?.item === linen.entry && stacksOf(linen.entry).length === 1));
895
+ term.press('S');
896
+ check('S sorts the bags back into one linen stack', stacksOf(linen.entry).length === 1);
897
+ app.top().cursor = bagRow(linen.entry); term.press('m'); show('item menu');
898
+ check('m opens the item menu', app.top().id === 'pick' && /Split the stack/.test(strip(frame())) && /Destroy/.test(strip(frame())));
899
+ term.press('escape');
900
+ const paper = ds.allItems().find((it) => /Wrapping Paper$/.test(it.name) && !/\(/.test(it.name) && it.itemClass === 0);
901
+ if (paper) {
902
+ g.state.inventory = addItem(g.state.inventory, paper.entry, 1, 1);
903
+ app.render(); app.top().cursor = bagRow(paper.entry); term.press('return');
904
+ app.top().cursor = rowIndex((r, t) => t === linen.name || t.startsWith(`${linen.name} x`)); term.press('return');
905
+ const gift = g.state.inventory.stacks.find((x) => x.wrappedItemEntry === linen.entry);
906
+ check('wrapping paper wraps an item into a gift', !!gift && countItem(g.state.inventory, paper.entry) === 0);
907
+ if (gift) {
908
+ app.top().cursor = rowIndex((r) => r.data === gift); term.press('return');
909
+ check('Enter on a gift unwraps it', countItem(g.state.inventory, linen.entry) === 5 && !g.state.inventory.stacks.some((x) => x.wrappedItemEntry));
910
+ }
911
+ } else check('the data has wrapping paper', false);
912
+ app.top().cursor = bagRow(linen.entry);
913
+ const had = countItem(g.state.inventory, linen.entry), doomed = app.rowsOf(app.top())[app.top().cursor].data.count;
914
+ term.press('d'); term.press('return');
915
+ check('d destroys that one stack after a yes', countItem(g.state.inventory, linen.entry) === had - doomed);
916
+ const key = ds.allItems().find((it) => it.itemClass === 13);
917
+ if (key) {
918
+ g.state.keyring = { stacks: [{ item: key.entry, count: 1 }] };
919
+ app.render();
920
+ check('the keyring is listed under the bags', /Keyring/.test(strip(app.rowsOf(app.top()).map((r) => r.text).join(' '))));
921
+ app.top().cursor = rowIndex((r) => r.data?.keyring === 0); term.press('d'); term.press('return');
922
+ check('d destroys a key from the keyring', !(g.state.keyring?.stacks ?? []).length);
923
+ }
924
+ home();
925
+ }
926
+
927
+ // Bulk: disenchant a band and learn every book, both as a list you switch rows off in, then G.
928
+ {
929
+ const g = app.g;
930
+ g.devLearnProfession('enchanting');
931
+ const { isDisenchantable, disenchantRuleFor } = await import('../domain/professions/enchanting.ts');
932
+ const green = ds.allItems().find((it) => it.quality === 2 && it.slot && it.slot !== 'bag' && isDisenchantable(it) && disenchantRuleFor(it, ds) && (it.itemLevel ?? 0) >= 15 && (it.itemLevel ?? 0) <= 25 && countItem(g.state.inventory, it.entry) === 0);
933
+ g.state.inventory = addItem(g.state.inventory, green.entry, 1, 1);
934
+ home(); app.switchTab(2); term.press('D'); show('disenchant all');
935
+ check('D opens Disenchant all on the greens', app.top().id === 'bulk-de' && /Greens/.test(strip(frame())));
936
+ const deRow = rowIndex((r, t) => t.includes(green.name));
937
+ check('the green in the bags is listed', deRow > 0);
938
+ app.top().cursor = deRow;
939
+ if (!/^✓/.test(strip(app.rowsOf(app.top())[deRow].text))) term.press('return');
940
+ term.press('G');
941
+ check('G breaks it down into enchanting mats', countItem(g.state.inventory, green.entry) === 0 && app.log.some((l) => /You create/.test(strip(l))));
942
+ term.press('tab'); check('Tab moves to the Blues band', /\[Blues/.test(strip(frame())));
943
+ term.press('escape');
944
+ const known = new Set(g.state.char.knownRecipes);
945
+ const book = ds.allItems().find((it) => it.teachRecipe != null && ds.recipe(it.teachRecipe)?.skill === 'tailoring' && !known.has(it.teachRecipe) && (ds.recipe(it.teachRecipe)?.requiredSkill ?? 0) <= 75 && (it.requiredLevel ?? 0) <= g.state.char.level);
946
+ if (book) {
947
+ g.state.inventory = addItem(g.state.inventory, book.entry, 1, 1);
948
+ term.press('L'); show('learn all');
949
+ check('L opens Learn all with the pattern', app.top().id === 'learn-all' && rowIndex((r, t) => t.includes(book.name)) > 0);
950
+ term.press('G');
951
+ check('G learns it and eats the book', g.state.char.knownRecipes.includes(book.teachRecipe) && countItem(g.state.inventory, book.entry) === 0);
952
+ term.press('escape');
953
+ } else check('the data has a tailoring pattern to learn', false);
954
+ home();
955
+ }
956
+
957
+ // Gear upkeep: durability on the slots, repair one piece, repair all, load ammo, auto consumables.
958
+ {
959
+ const g = app.g;
960
+ home(); app.switchTab(3);
961
+ const worn = ['chest', 'legs', 'mainhand', 'feet'].find((sl) => g.state.char.equipped[sl] != null);
962
+ g.state.char.durability = { ...(g.state.char.durability ?? {}), [worn]: 1 };
963
+ g.state.char.gold += 100000;
964
+ app.render(); show('gear with a worn piece');
965
+ const slotRow = rowIndex((r) => r.data?.slot === worn);
966
+ check('a worn-down slot shows its durability', / 1\/\d+/.test(strip(app.rowsOf(app.top())[slotRow].text)));
967
+ const quote = g.repairQuote();
968
+ check('the Repair all row prices the bill', rowIndex((r, t) => t.startsWith('Repair all') && t.includes(moneyTextOf(quote.cost))) > 0);
969
+ if (quote.vendorName) {
970
+ app.top().cursor = slotRow; term.press('r');
971
+ check('r on a slot repairs that piece', g.state.char.durability?.[worn] == null);
972
+ } else check(`a repairer is in reach in ${g.state.char.zoneId}`, false);
973
+ const bow = ds.allItems().find((it) => it.slot === 'ranged' && it.itemSubclass === 2 && (it.requiredLevel ?? 0) <= g.state.char.level && it.quality <= 2);
974
+ const arrows = ds.allItems().find((it) => it.itemClass === 6 && it.itemSubclass === 2 && (it.requiredLevel ?? 0) <= g.state.char.level);
975
+ g.state.inventory = addItem(addItem(g.state.inventory, bow.entry, 1, 1), arrows.entry, 200, arrows.stackable ?? 200);
976
+ g.equip(bow.entry, 'ranged');
977
+ app.render();
978
+ app.top().cursor = rowIndex((r, t) => t.startsWith('Ammo')); term.press('return'); show('ammo picker');
979
+ app.top().cursor = rowIndex((r, t) => t.includes(arrows.name)); term.press('return');
980
+ check('Enter on Ammo loads arrows', g.state.char.ammoItem === arrows.entry);
981
+ app.top().cursor = rowIndex((r, t) => t.startsWith('Ammo')); term.press('return');
982
+ app.top().cursor = rowIndex((r, t) => t.trim() === 'No ammo'); term.press('return');
983
+ check('and No ammo puts them away', g.state.char.ammoItem == null);
984
+ const bread = ds.allItems().find((it) => it.consumable?.effect === 'food' && (it.requiredLevel ?? 0) <= g.state.char.level && !it.useBuff);
985
+ g.state.inventory = addItem(g.state.inventory, bread.entry, 5, bread.stackable ?? 20);
986
+ app.render();
987
+ app.top().cursor = rowIndex((r, t) => t.startsWith('Auto use')); term.press('return'); show('auto consumables');
988
+ check('Auto use opens the auto consumables list', app.top().id === 'auto-cons');
989
+ app.top().cursor = rowIndex((r, t) => t.includes(bread.name)); term.press('return');
990
+ check('Enter sets a food to eat on its own', g.state.char.autoConsumables?.food === bread.entry);
991
+ term.press('return');
992
+ check('Enter again takes it off', g.state.char.autoConsumables?.food !== bread.entry);
993
+ home();
994
+ }
995
+
996
+ // Outfitter: save what is worn, wear it back after taking a piece off, edit slots, build for a stat, delete.
997
+ {
998
+ const g = app.g;
999
+ const typeLine = (text) => { for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1000
+ const outfitRow = (name) => rowIndex((r) => r.data?.outfit?.name === name);
1001
+ home(); app.switchTab(3); term.press('o'); show('outfitter');
1002
+ check('o on Gear opens the Outfitter', app.top().id === 'outfitter');
1003
+ term.press('n'); typeLine('Kit');
1004
+ check('n saves what is worn as an outfit', g.outfitterState().outfits.some((o) => o.name === 'Kit'));
1005
+ const chest = g.state.char.equipped.chest;
1006
+ term.press('escape');
1007
+ g.unequip('chest');
1008
+ term.press('o');
1009
+ app.top().cursor = outfitRow('Kit'); term.press('return'); show('outfit worn');
1010
+ check('Enter on the outfit puts the missing piece back on', g.state.char.equipped.chest != null && JSON.stringify(g.state.char.equipped.chest) === JSON.stringify(chest));
1011
+ app.top().cursor = outfitRow('Kit'); term.press('e'); show('outfit slots');
1012
+ check('e lists the outfit slots', app.top().id === 'outfit-slots' && /Chest/.test(strip(frame())));
1013
+ app.top().cursor = rowIndex((r, t) => /Chest/.test(t)); term.press('return');
1014
+ const kit = () => g.outfitterState().outfits.find((o) => o.name === 'Kit');
1015
+ check('Enter drops a slot from the set', !kit().items.chest);
1016
+ term.press('escape');
1017
+ term.press('N'); app.top().cursor = rowIndex((r, t) => /Stamina$/.test(t)); term.press('return');
1018
+ check('N builds an outfit for a stat', g.outfitterState().outfits.some((o) => o.statId === 'sta'));
1019
+ app.top().cursor = outfitRow('Kit'); term.press('x'); term.press('return');
1020
+ check('x deletes an outfit after a yes', !kit());
1021
+ home();
1022
+ }
1023
+
1024
+ // Settings: auto-loot, helper mode, auto-disenchant on both layers, junk boxes and lists, gather colours.
1025
+ {
1026
+ const g = app.g;
1027
+ const row = (re) => rowIndex((r, t) => re.test(t));
1028
+ const enter = (re) => { app.top().cursor = row(re); term.press('return'); };
1029
+ home(); term.press(':'); for (const ch of 'settings') term.press(ch); term.press('return'); show('settings');
1030
+ check(':settings opens the Settings window', app.top().id === 'settings');
1031
+ enter(/^Auto-loot/); check('Enter flips auto-loot', app.prefs.get('autoLoot') === false); enter(/^Auto-loot/);
1032
+ enter(/^Upgrades/); check('Enter switches the helper to put upgrades on', g.equipmentHelperConfig().mode === 'auto'); enter(/^Upgrades/);
1033
+ enter(/^This character/); check('this character disenchants greens on its own', g.characterAutoDisenchantConfig().enabled && g.characterAutoDisenchantConfig().maxQuality === 2);
1034
+ enter(/^Every character/); check('the every-character layer lands in the prefs file', app.prefs.get('autoDisenchant')?.enabled === true);
1035
+ enter(/sell Common$/); check('a sell box edits the shared junk rules', g.globalJunkFilterConfig().sellQuality[1] === true && app.prefs.get('junkFilter')?.sellQuality?.[1] === true);
1036
+ enter(/^Rules/); check('Rules gives this character its own junk rules', g.characterJunkFilterConfig().characterSpecific === true);
1037
+ enter(/destroy Poor$/); check('edits then land on the character layer', g.characterJunkFilterConfig().destroyQuality[0] === true && !g.globalJunkFilterConfig().destroyQuality[0]);
1038
+ enter(/^ *. gray$/); check('a gather colour box flips', g.gatherFilterConfig().takeColor.gray === true);
1039
+ term.press('escape');
1040
+ app.switchTab(2);
1041
+ const st = g.state.inventory.stacks[0];
1042
+ app.top().cursor = rowIndex((r) => r.data === st); term.press('m');
1043
+ app.top().cursor = rowIndex((r, t) => t === 'Always keep'); term.press('return');
1044
+ check('the bag item menu puts an item on the keep list', g.characterJunkFilterConfig().keepEntries.includes(st.item));
1045
+ // Back to the defaults so nothing after this runs under these rules.
1046
+ g.setCharacterAutoDisenchantConfig({ enabled: false, maxQuality: 2, protectCompanionUpgrades: true });
1047
+ g.setCharacterJunkFilterConfig({ ...g.characterJunkFilterConfig(), characterSpecific: false, keepEntries: [], destroyQuality: { 0: false, 1: false, 2: false, 3: false, 4: false } });
1048
+ app.prefs.set('autoDisenchant', undefined); app.prefs.set('junkFilter', undefined);
1049
+ home();
1050
+ }
1051
+
1052
+ // Action bars: bind a key, the fight's number row fires it; add and remove a bar; the global bar fills and fires.
1053
+ {
1054
+ const g = app.g;
1055
+ const typeLine = (text) => { for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1056
+ home(); term.press('5'); term.press('b');
1057
+ const slotRow = (b, i) => rowIndex((r) => r.data?.b === b && r.data?.i === i);
1058
+ app.top().cursor = slotRow(0, 4); term.press('return');
1059
+ // The engine de-dupes across bars: placing Heroic Strike here moves it from slot 1.
1060
+ const spellName = ds.spell(78).name;
1061
+ const pickRow = rowIndex((r, t) => t.trim().replace(/ \(Rank \d+\)$/, '') === spellName);
1062
+ app.top().cursor = pickRow; term.press('return');
1063
+ check('Enter fills a bar slot with a spell on the page for the current form', visibleBars(g)[0].slots[4] === 78);
1064
+ app.top().cursor = slotRow(0, 4); term.press('k'); typeLine('7');
1065
+ check('k binds a key to the slot', g.state.actionBars[0].keys[4] === '7');
1066
+ const bars = g.state.actionBars.length;
1067
+ term.press('+'); check('+ adds a bar', g.state.actionBars.length === bars + 1);
1068
+ term.press('-'); check('- removes it again', g.state.actionBars.length === bars);
1069
+ const potion = ds.allItems().find((it) => it.useInCombat?.effect === 'restore_health' && (it.requiredLevel ?? 0) <= g.state.char.level);
1070
+ g.state.inventory = addItem(g.state.inventory, potion.entry, 3, potion.stackable ?? 5);
1071
+ app.top().cursor = rowIndex((r, t) => /^\d+\s+empty$/.test(t.trim()) && !('b' in (r.data ?? {}))); term.press('return');
1072
+ app.top().cursor = rowIndex((r, t) => t.trim() === potion.name); term.press('return');
1073
+ check('a global bar slot takes a bag item', (g.state.globalActionBar?.items ?? []).includes(potion.entry));
1074
+ term.press('escape'); home(); term.press('1');
1075
+ g.state.char.health = Math.max(1, Math.floor(g.state.char.health / 2));
1076
+ const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
1077
+ if (mob >= 0) {
1078
+ app.top().cursor = mob; term.press('return');
1079
+ const row = app.fight?.abilityBar() ?? [];
1080
+ check('the fight number row fires the bound key', row.some((x) => x.key === '7' && x.label === spellName));
1081
+ finishFight();
1082
+ } else check('a creature to fight for the bar check', false);
1083
+ home();
1084
+ }
1085
+
1086
+ // Macros: write one line by line, see what it would do, account macros in the prefs file, a template, fire it in a fight.
1087
+ {
1088
+ const g = app.g;
1089
+ const typeLine = (text) => { for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1090
+ home(); term.press('5'); term.press('m'); show('macros');
1091
+ check('m on Spells opens the macros', app.top().id === 'macros');
1092
+ app.top().cursor = rowIndex((r, t) => t === '+ New macro'); term.press('return'); typeLine('Strike');
1093
+ check('a new macro opens in the editor', app.top().id === 'macro-edit' && g.macroByName('Strike') != null);
1094
+ app.top().cursor = rowIndex((r, t) => t === '+ Add a line'); term.press('return'); typeLine('/cast Heroic Strike');
1095
+ show('macro editor');
1096
+ check('a line goes into the body', g.macroByName('Strike').body.trim() === '/cast Heroic Strike');
1097
+ check('the editor says what a press would do', /casts Heroic Strike/.test(strip(frame())));
1098
+ app.top().cursor = rowIndex((r) => r.data?.line === 0); term.press('o'); typeLine('/cast Battle Shout');
1099
+ check('o adds a line under it', g.macroByName('Strike').body.split('\n').length === 2);
1100
+ app.top().cursor = rowIndex((r) => r.data?.line === 1); term.press('x');
1101
+ check('x deletes a line', g.macroByName('Strike').body.split('\n').length === 1);
1102
+ term.press('escape');
1103
+ const acc = g.saveMacro('account', { name: 'Shared', body: '/cast Battle Shout' });
1104
+ check('an account macro is kept in the prefs file', acc.ok && (app.prefs.get('accountMacros') ?? []).some((m) => m.name === 'Shared'));
1105
+ const before = g.macros().length;
1106
+ term.press('T'); term.press('return');
1107
+ check('T adds a template macro', g.macros().length > before);
1108
+ home();
1109
+ const strike = g.macroByName('Strike');
1110
+ g.setMacroActionSlot(0, 7, strike.id); g.setActionKey(0, 7, '8');
1111
+ term.press('1');
1112
+ const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
1113
+ if (mob >= 0) {
1114
+ app.top().cursor = mob; term.press('return');
1115
+ check('a macro on bar 1 shows on the fight number row', (app.fight?.abilityBar() ?? []).some((x) => x.key === '8' && x.macro === strike.id));
1116
+ term.press('8');
1117
+ check('its key queues the macro cast', app.fight?.pressed === 78);
1118
+ finishFight();
1119
+ } else check('a creature for the macro fight', false);
1120
+ home();
1121
+ }
1122
+
1123
+ // Cancel an aura: from the Character tab, and with c inside a fight.
1124
+ {
1125
+ const g = app.g;
1126
+ const fort = ds.spell(1243);
1127
+ const give = () => { g.state.char.activeAuras = [...(g.state.char.activeAuras ?? []).filter((x) => x.spellId !== 1243), { spellId: 1243, expiresAt: Date.now() + 600000 }]; };
1128
+ give();
1129
+ home(); app.switchTab(app.tabs.length - 1); show('character auras');
1130
+ const at = rowIndex((r, t) => t.includes(fort.name));
1131
+ check('the Character tab lists your auras', at > 0);
1132
+ app.top().cursor = at; term.press('return');
1133
+ check('Enter on a buff cancels it', !(g.state.char.activeAuras ?? []).some((x) => x.spellId === 1243));
1134
+ give(); home(); term.press('1');
1135
+ const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
1136
+ app.top().cursor = mob; term.press('return');
1137
+ if (app.fight && app.fight.session.playerView().buffs.some((b) => b.spellId === 1243)) {
1138
+ term.press('c');
1139
+ check('c in a fight lists the buffs you can cancel', app.top()?.title === 'Cancel which buff?');
1140
+ app.top().cursor = rowIndex((r, t) => t.includes(fort.name)); term.press('return');
1141
+ check('picking one cancels it in the fight', !app.fight.session.playerView().buffs.some((b) => b.spellId === 1243) && !app.stack.length);
1142
+ } else check('the buff carries into the fight', false);
1143
+ finishFight(); home();
1144
+ }
1145
+
1146
+ // Pick pocket: a rogue in Stealth presses P on a humanoid; the pocket empties, or a resist starts the fight.
1147
+ {
1148
+ const term3 = new HeadlessTerm(110, 34);
1149
+ const app3 = new App({ term: term3, ds, repo });
1150
+ term3.open();
1151
+ app3.startGame(newGameState(ds, human.id, 4, 'Picktest', 77, 0), 'picktest', Date.now(), true);
1152
+ const g3 = app3.g;
1153
+ g3.state.char.level = 10;
1154
+ g3.state.spellbook = { known: [...g3.state.spellbook.known, 921, 1784] };
1155
+ g3.state.char.power = 100;
1156
+ const at = (pred) => app3.rowsOf(app3.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1157
+ const { pickpocketLootKey } = await import('../domain/professions/pickpocket.ts');
1158
+ const mark = at((r) => r.data?.creature && ds.lootTable(pickpocketLootKey(r.data.creature.entry)).length > 0);
1159
+ check('Elwynn has a creature with pockets to pick', mark > 0);
1160
+ if (mark > 0) {
1161
+ const cr = app3.rowsOf(app3.top())[mark].data.creature;
1162
+ app3.top().cursor = mark; term3.press('P');
1163
+ check('P outside Stealth is refused', !app3.fight && app3.log.some((l) => /stealth/i.test(strip(l))));
1164
+ const st = g3.castOutOfCombat(1784);
1165
+ app3.render();
1166
+ check('in Stealth the creature row offers P', /P: pick pocket/.test(strip(app3.rowsOf(app3.top())[mark].text)));
1167
+ g3.state.char.power = 100;
1168
+ term3.press('P');
1169
+ const picked = !!g3.state.pickpocketRestock?.[`${g3.state.char.zoneId}:${cr.entry}`];
1170
+ check(`P picks the pocket or a resist starts the fight (stealth cast ${st.ok ? 'ok' : st.reason})`, picked || !!app3.fight);
1171
+ while (app3.fight) app3.fight.tick();
1172
+ }
1173
+ term3.close?.();
1174
+ }
1175
+
1176
+ // Your rotation profiles: add a copy, rename it, switch between them, delete one.
1177
+ {
1178
+ const g = app.g;
1179
+ const typeLine = (text) => { for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1180
+ const pickRow = (re) => { app.top().cursor = rowIndex((r, t) => re.test(t)); term.press('return'); };
1181
+ home(); term.press('6');
1182
+ const n0 = g.rotationProfiles().length;
1183
+ term.press('p'); pickRow(/New rotation \(a copy/); typeLine('Aoe');
1184
+ check('p adds a rotation profile and plays it', g.rotationProfiles().length === n0 + 1 && g.rotationProfiles().find((x) => x.active)?.name === 'Aoe');
1185
+ term.press('p'); pickRow(/^Rename this one/); for (let i = 0; i < 3; i++) term.press('backspace'); typeLine('Cleave');
1186
+ check('it can be renamed', g.rotationProfiles().some((x) => x.name === 'Cleave'));
1187
+ const other = g.rotationProfiles().find((x) => !x.active);
1188
+ term.press('p'); pickRow(new RegExp(`^\s*${other.name}$`));
1189
+ check('another profile can be picked', g.rotationProfiles().find((x) => x.active)?.id === other.id);
1190
+ term.press('p'); pickRow(/Cleave/); term.press('p'); pickRow(/^Delete this one/); term.press('return');
1191
+ check('and deleted', !g.rotationProfiles().some((x) => x.name === 'Cleave') && g.rotationProfiles().length === n0);
1192
+ home();
1193
+ }
1194
+
1195
+ // Talent builds: a premade spec, save it, respec, put the saved build back, paste a string, delete the build.
1196
+ {
1197
+ const g = app.g;
1198
+ const typeLine = (text) => { for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1199
+ const pickRow = (re) => { app.top().cursor = rowIndex((r, t) => re.test(t)); term.press('return'); };
1200
+ const spent = () => Object.values(g.state.char.talents ?? {}).reduce((n, v) => n + v, 0);
1201
+ const { talentLinkFromTalents } = await import('../domain/character/talentLink.ts');
1202
+ const link = () => talentLinkFromTalents(g.state.char.talents ?? {}, g.state.char.cls, ds);
1203
+ g.state.char.gold += 1_000_000;
1204
+ home(); term.press('7'); term.press('B'); show('talent builds');
1205
+ check('B on Talents lists the premade specs', app.top().title === 'Talent builds' && /premade/.test(strip(frame())));
1206
+ pickRow(/premade/); if (app.top()?.id === 'pick' && /respec/.test(app.top().title)) term.press('return');
1207
+ const premade = link();
1208
+ check('a premade spec spends the points', spent() > 0 && premade.length > 0);
1209
+ term.press('B'); pickRow(/^Save my talents/); typeLine('Mine');
1210
+ check('the current talents save as a build', (g.state.talentBuilds ?? []).some((b) => b.name === 'Mine' && b.link === premade));
1211
+ g.respec();
1212
+ check('a respec empties the tree', spent() === 0);
1213
+ term.press('B'); pickRow(/saved\s+Mine/); pickRow(/^Use this build/);
1214
+ check('the saved build goes back on', link() === premade);
1215
+ g.respec();
1216
+ term.press('B'); pickRow(/^Paste a talent string/); typeLine(premade);
1217
+ check('a pasted talent string is applied', link() === premade);
1218
+ term.press('B'); pickRow(/saved\s+Mine/); pickRow(/^Delete it/); term.press('return');
1219
+ check('a saved build can be deleted', !(g.state.talentBuilds ?? []).some((b) => b.name === 'Mine'));
1220
+ home();
1221
+ }
1222
+
1223
+ // Target dummy: D on the Rotation tab parses the rotation; the report names DPS and the abilities that did damage.
1224
+ {
1225
+ home(); term.press('6'); term.press('D'); show('target dummy');
1226
+ check('D on Rotation opens the target dummy', app.top().id === 'sim-lab');
1227
+ app.top().cursor = rowIndex((r, t) => /^Fights/.test(t));
1228
+ while (app.top().sim.iterations !== 20) term.press('return');
1229
+ app.top().cursor = rowIndex((r, t) => /^Dummy/.test(t));
1230
+ while (app.top().sim.entry !== 2673) term.press('return');
1231
+ term.press('G');
1232
+ const sim = app.top().sim;
1233
+ while (!sim.run.done) sim.run.step(1000);
1234
+ sim.report = sim.run.report();
1235
+ app.render(); show('dummy parse');
1236
+ check('the parse reports DPS', sim.report.dps.avg > 0 && /DPS/.test(strip(frame())));
1237
+ check('and which abilities did the damage', sim.report.abilities.length > 0 && rowIndex(() => true) >= 0 && app.rowsOf(app.top()).some((r) => strip(r.text).startsWith(sim.report.abilities[0].name)));
1238
+ home();
1239
+ }
1240
+
1241
+ // Autopilot settings: a switch, a choice, a percentage and a count, each through the form.
1242
+ {
1243
+ const g = app.g;
1244
+ const typeLine = (text) => { for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1245
+ const field = (key) => { app.top().cursor = rowIndex((r) => r.data?.key === key); };
1246
+ home(); term.press(':'); for (const ch of 'autopilot') term.press(ch); term.press('return'); show('autopilot settings');
1247
+ check(':autopilot opens the autopilot settings', app.top().id === 'autopilot-settings');
1248
+ const before = g.autopilotConfig();
1249
+ field('autoGather'); term.press('return');
1250
+ check('Enter flips a switch', g.autopilotConfig().autoGather === !before.autoGather);
1251
+ field('aoeMode'); term.press('return');
1252
+ check('Enter steps a choice', g.autopilotConfig().aoeMode !== before.aoeMode);
1253
+ field('healthEnterPct'); term.press('return'); for (let i = 0; i < 4; i++) term.press('backspace'); typeLine('60');
1254
+ check('a percentage is typed in', Math.abs(g.autopilotConfig().healthEnterPct - 0.6) < 1e-9);
1255
+ field('pullNormalCount'); term.press('return'); term.press('backspace'); typeLine('3');
1256
+ check('a count is typed in', g.autopilotConfig().pullNormalCount === 3);
1257
+ field('pullNormalCount'); term.press('return'); term.press('backspace'); typeLine('99');
1258
+ check('an out-of-range number is refused', g.autopilotConfig().pullNormalCount === 3);
1259
+ g.setAutopilotConfig(before);
1260
+ home();
1261
+ }
1262
+
1263
+ // Real-time travel: with a 1x pace a trip takes time; the header counts down, the World tab carries the journey
1264
+ // card (speed, pause, arrive now, turn back), and the game loop lands it.
1265
+ {
1266
+ const g = app.g;
1267
+ const pickRow = (re) => { app.top().cursor = rowIndex((r, t) => re.test(t)); term.press('return'); };
1268
+ home(); term.press(':'); for (const ch of 'settings') term.press(ch); term.press('return');
1269
+ pickRow(/^Travel pace/);
1270
+ check('Settings sets a real-time travel pace', app.prefs.get('travelPace') === 1);
1271
+ home();
1272
+ const here = g.state.char.zoneId;
1273
+ const dest = (ds.zone(here)?.neighbors ?? []).map((id) => ds.zone(id)).find((z) => z && g.previewTravel(z.id).ok);
1274
+ term.press('8'); app.top().cursor = rowIndex((r, t) => t.startsWith(dest.name)); term.press('return');
1275
+ show('on the road');
1276
+ check('a trip at 1x takes time', !!g.state.char.journey && g.state.char.zoneId !== dest.id);
1277
+ check('the header counts it down', strip(frame()).split('\n')[0].includes(`→ ${dest.name}`));
1278
+ check('the World tab opens on the journey card', app.tab === 0 && /On the road to/.test(strip(frame())));
1279
+ app.top().cursor = rowIndex((r, t) => /journey options/.test(t)); term.press('return');
1280
+ pickRow(/^Travel at 4x/);
1281
+ check('the trip can be sped up', g.journeyStatus()?.timeScale === 4);
1282
+ app.top().cursor = rowIndex((r, t) => /journey options/.test(t)); term.press('return'); pickRow(/^Turn back/);
1283
+ check('and turned back', !g.state.char.journey);
1284
+ term.press('8'); app.top().cursor = rowIndex((r, t) => t.startsWith(dest.name)); term.press('return');
1285
+ app.top().cursor = rowIndex((r, t) => /journey options/.test(t)); term.press('return'); pickRow(/^Arrive now/);
1286
+ check('Arrive now lands it at once', !g.state.char.journey && g.state.char.zoneId === dest.id);
1287
+ term.press('8'); app.top().cursor = rowIndex((r, t) => t.startsWith(ds.zone(here).name)); term.press('return');
1288
+ g.journeyTick(Date.now() + 24 * 3600_000);
1289
+ check('the clock running out lands the trip too', !g.state.char.journey && g.state.char.zoneId === here);
1290
+ app.prefs.set('travelPace', 'instant');
1291
+ home();
1292
+ }
1293
+
1294
+ // Offline rest: a save last written eight hours ago comes back with rested xp.
1295
+ {
1296
+ const term4 = new HeadlessTerm(110, 34);
1297
+ const app4 = new App({ term: term4, ds, repo });
1298
+ term4.open();
1299
+ const st = newGameState(ds, human.id, 1, 'Resttest', 5, 0);
1300
+ st.char.level = 10; st.char.restedXp = 0;
1301
+ app4.startGame(st, 'resttest', Date.now() - 9 * 3600_000, false, Date.now() - 8 * 3600_000);
1302
+ check('time away is rested xp on load', (app4.g.state.char.restedXp ?? 0) > 0 && app4.log.some((l) => /You were away 8 h\. \+\d+ rested xp/.test(strip(l))));
1303
+ term4.close?.();
1304
+ }
1305
+
1306
+ // Riding: an instructor teaches Apprentice Riding, a mount in the bags puts you on and takes you off.
1307
+ {
1308
+ const g = app.g;
1309
+ const { isMountItem } = await import('./tuiWorld.mjs');
1310
+ const keep = { level: g.state.char.level, zone: g.state.char.zoneId };
1311
+ g.state.char.level = 40; g.state.char.gold += 2_000_000;
1312
+ const rz = ds.zones().find((z) => (z.ridingTrainers ?? []).some((e) => ds.creature(e) && g.previewTravel(z.id).ok) && (z.faction ?? 'Alliance') !== 'Horde');
1313
+ g.state.char.zoneId = rz.id;
1314
+ home(); term.press('1');
1315
+ const instructor = rowIndex((r, t) => /<Riding Instructor>/.test(t));
1316
+ check(`${rz.name} lists a riding instructor`, instructor > 0);
1317
+ app.top().cursor = instructor; term.press('return'); show('riding instructor');
1318
+ app.top().cursor = rowIndex((r, t) => /^Riding 75/.test(t)); term.press('return');
1319
+ check('Enter on a rank teaches riding', (g.state.char.ridingSkill ?? 0) >= 75);
1320
+ const horse = ds.allItems().find((it) => isMountItem(it) && (it.requiredLevel ?? 0) <= 40 && (!it.allowRace?.length || it.allowRace.includes(g.state.char.race)) && (it.requiredSkillRank ?? 0) <= 75);
1321
+ g.state.inventory = addItem(g.state.inventory, horse.entry, 1, 1);
1322
+ app.switchTab(2); app.top().cursor = rowIndex((r) => r.data?.item === horse.entry); term.press('return');
1323
+ check('Enter on a mount in the bags mounts it', g.state.char.mountedItem === horse.entry && /mounted/.test(strip(frame()).split('\n')[0]));
1324
+ term.press('return');
1325
+ check('and Enter again gets off', g.state.char.mountedItem == null);
1326
+ g.state.char.level = keep.level; g.state.char.zoneId = keep.zone;
1327
+ home();
1328
+ }
1329
+
1330
+ // World objects: a chest opens into the loot window, a poster offers its quest, a book pages.
1331
+ {
1332
+ const g = app.g;
1333
+ const keep = g.state.char.zoneId;
1334
+ g.state.char.zoneId = 'elwynn';
1335
+ home(); term.press('1');
1336
+ const chest = rowIndex((r) => r.data?.object?.name === 'Battered Chest');
1337
+ check('Elwynn lists objects with a chest', chest > 0);
1338
+ app.prefs.set('autoLoot', false);
1339
+ app.top().cursor = chest; term.press('return'); show('chest opened');
1340
+ check('Enter on a chest opens it', app.top()?.id === 'loot' || app.log.some((l) => /Battered Chest/.test(strip(l))));
1341
+ if (app.top()?.id === 'loot') term.press('a');
1342
+ app.prefs.set('autoLoot', true);
1343
+ home(); term.press('1');
1344
+ const poster = rowIndex((r, t) => r.data?.object && t.startsWith('! '));
1345
+ if (poster > 0) {
1346
+ app.top().cursor = poster; term.press('return');
1347
+ check('a quest object offers its quest', app.top()?.id === 'pick' && /!/.test(strip(frame())));
1348
+ term.press('escape');
1349
+ }
1350
+ // Readable pages are baked for dungeon books; the reader is the same window wherever the book stands.
1351
+ const { pageText } = await import('../data/pageTexts.ts');
1352
+ const { bookDialog } = await import('./tuiWorld.mjs');
1353
+ let first = null;
1354
+ for (let id = 1; id < 5000 && first == null; id++) { const p = pageText(id); if (p?.next) first = id; }
1355
+ if (first != null) {
1356
+ app.push(bookDialog(app, 'A book', first)); show('book');
1357
+ check('a book opens on its first page', app.top()?.id === 'book' && /page 1 of/.test(strip(frame())));
1358
+ app.top().cursor = rowIndex((r, t) => t === 'Next page'); term.press('return');
1359
+ check('Next page turns it', /page 2 of/.test(strip(frame())));
1360
+ term.press('b'); check('b goes back a page', /page 1 of/.test(strip(frame())));
1361
+ term.press('escape');
1362
+ } else check('the data has a book with two pages', false);
1363
+ g.state.char.zoneId = keep;
1364
+ home();
1365
+ }
1366
+
1367
+ // Quests: the next quest in a chain opens after a turn-in, gray quests go in one press, a bag item starts a quest.
1368
+ {
1369
+ const term5 = new HeadlessTerm(110, 34);
1370
+ const app5 = new App({ term: term5, ds, repo });
1371
+ term5.open();
1372
+ app5.startGame(newGameState(ds, human.id, 1, 'Chaintest', 11, 0), 'chaintest', Date.now(), true);
1373
+ const g5 = app5.g;
1374
+ const at = (pred) => app5.rowsOf(app5.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1375
+ const { offeredQuests, chainFollowUpOffer } = await import('../quests/quests.ts');
1376
+ // A Threat Within (speak to the Marshal) leads to Kobold Camp Cleanup: talk to him, then hand it in through the quest window.
1377
+ let chained = ds.quest(783);
1378
+ if (!g5.acceptQuest(783).ok) chained = null;
1379
+ else g5.talkToNpc({ npcName: chained.enderName });
1380
+ if (chained) {
1381
+ app5.switchTab(1); app5.render();
1382
+ app5.top().cursor = at((r) => r.data?.q?.entry === chained.entry); term5.press('return');
1383
+ app5.top().cursor = at((r, t) => t === 'Complete the quest'); term5.press('return');
1384
+ if (app5.top()?.id === 'pick') term5.press('return');
1385
+ const next = g5.state.questlog.quests.find((x) => x.entry === chained.entry)?.status === 'turnedIn' || !g5.state.questlog.quests.some((x) => x.entry === chained.entry);
1386
+ check(`turning in ${chained.title} opens the next quest in the chain`, next && /^quest-/.test(app5.top()?.id ?? '') && app5.top().id !== `quest-${chained.entry}`);
1387
+ } else check('Northshire has a talk quest with a follow-up', false);
1388
+ app5.stack = [];
1389
+ // Gray quests: at level 30 the Northshire quests are gray.
1390
+ const o = offeredQuests(ds, g5.state.char.zoneId, g5.state.char, g5.state.questlog).find((x) => x.canAccept);
1391
+ if (o && g5.acceptQuest(o.quest.entry).ok) {
1392
+ g5.state.char.level = 30;
1393
+ app5.switchTab(1); term5.press('G'); term5.press('return');
1394
+ check('G abandons the gray quests', !g5.state.questlog.quests.some((x) => x.entry === o.quest.entry && x.status === 'active'));
1395
+ g5.state.char.level = 1;
1396
+ }
1397
+ const { bagQuestStartState } = await import('../quests/quests.ts');
1398
+ const starter = ds.allItems().filter((it) => (it.startQuest ?? 0) > 0).find((it) => {
1399
+ const inv = addItem(g5.state.inventory, it.entry, 1, 1);
1400
+ return bagQuestStartState(ds, g5.state.char, g5.state.questlog, inv, it.entry)?.kind === 'offer';
1401
+ });
1402
+ if (starter) {
1403
+ g5.state.inventory = addItem(g5.state.inventory, starter.entry, 1, 1);
1404
+ app5.switchTab(2); app5.render();
1405
+ app5.top().cursor = at((r) => r.data?.item === starter.entry); term5.press('return');
1406
+ check(`${starter.name} in the bags opens its quest`, app5.top()?.id === `quest-${starter.startQuest}`);
1407
+ app5.top().cursor = at((r, t) => t === 'Accept the quest'); term5.press('return');
1408
+ check('and the quest can be accepted from it', g5.state.questlog.quests.some((x) => x.entry === starter.startQuest));
1409
+ } else check('the data has an item that starts a quest', false);
1410
+ term5.close?.();
1411
+ }
1412
+
1413
+ // Reputation: the window lists factions by group; watch one on the xp bar, declare war on one that allows it.
1414
+ {
1415
+ const g = app.g;
1416
+ const pickRow = (re) => { app.top().cursor = rowIndex((r, t) => re.test(t)); term.press('return'); };
1417
+ // A neutral faction you have met (Booty Bay) can be declared war on; the home cities cannot.
1418
+ g.state.char.reputations = { ...(g.state.char.reputations ?? {}), 21: g.state.char.reputations?.[21] ?? 0 };
1419
+ home(); term.press(':'); for (const ch of 'rep') term.press(ch); term.press('return'); show('reputation');
1420
+ check(':rep opens the reputation window', app.top().id === 'reputation');
1421
+ const first = rowIndex((r) => r.data?.faction);
1422
+ check('it lists factions', first > 0);
1423
+ const f = app.rowsOf(app.top())[first].data.faction;
1424
+ app.top().cursor = first; term.press('return'); pickRow(/^Watch it/);
1425
+ check('Enter watches a faction', g.state.char.watchedFaction === f.id);
1426
+ const rows = app.rowsOf(app.top()).filter((r) => r.data?.faction);
1427
+ let warred = null;
1428
+ for (const r of rows) {
1429
+ app.top().cursor = rowIndex((x) => x.data?.faction === r.data.faction); term.press('return');
1430
+ const war = rowIndex((x, t) => /Declare war/.test(t));
1431
+ if (war >= 0) { app.top().cursor = war; term.press('return'); warred = r.data.faction; break; }
1432
+ term.press('escape');
1433
+ }
1434
+ check('a faction that allows it can be declared war on', warred != null && g.atWarFactions().has(warred.id));
1435
+ if (warred) g.setFactionAtWar(warred.id, false);
1436
+ g.setWatchedFaction(0);
1437
+ home();
1438
+ }
1439
+
1440
+ // Meeting stone: the stone by the Deadmines queues you for it; the World tab carries the queue card until you leave.
1441
+ {
1442
+ const g = app.g;
1443
+ const keep = g.state.char.zoneId;
1444
+ g.state.char.zoneId = 'westfall';
1445
+ home(); term.press('1');
1446
+ const stone = rowIndex((r) => r.data?.object?.meetingstone);
1447
+ check('Westfall lists the Deadmines meeting stone', stone > 0);
1448
+ if (stone > 0) {
1449
+ app.top().cursor = stone; term.press('return'); show('meeting stone');
1450
+ app.top().cursor = rowIndex((r, t) => /^Join the queue/.test(t)); term.press('return');
1451
+ check('the stone queues you', g.state.meetingStoneQueue?.status === 1);
1452
+ check('the World tab shows the queue card', /Looking for more for/.test(strip(frame())));
1453
+ app.top().cursor = rowIndex((r, t) => /Looking for more/.test(t)); term.press('return');
1454
+ check('Enter on the card leaves the queue', !g.state.meetingStoneQueue);
1455
+ }
1456
+ g.state.char.zoneId = keep;
1457
+ home();
1458
+ }
1459
+
1460
+ // Repeat a fight: r in a plain fight pulls the same creature again after each win, until r again or Esc.
1461
+ {
1462
+ const g = app.g;
1463
+ const { farmNext } = await import('./tuiFight.mjs');
1464
+ home(); term.press('1');
1465
+ g.state.char.health = 100000; app.idleTick(600, true);
1466
+ const mob = app.rowsOf(app.top()).findIndex((r) => r.data?.creature);
1467
+ const entry = app.rowsOf(app.top())[mob].data.creature.entry;
1468
+ app.top().cursor = mob; term.press('return');
1469
+ term.press('r');
1470
+ check('r marks the fight to repeat', app.farmEntry === entry && /stop repeating/.test(app.fight.hint()));
1471
+ finishFight();
1472
+ farmNext(app);
1473
+ check('a won repeat fight pulls the same creature again', !!app.fight && app.fight.enemyEntry === entry);
1474
+ term.press('r');
1475
+ check('r again stops the repeats', app.farmEntry == null);
1476
+ finishFight(); farmNext(app);
1477
+ check('and no new fight starts', !app.fight);
1478
+ home();
1479
+ }
1480
+
1481
+ // Profession specialisation: a leatherworker who did the Dragonscale quest learns it from Peter Galen in Azshara.
1482
+ {
1483
+ const term6 = new HeadlessTerm(110, 34);
1484
+ const app6 = new App({ term: term6, ds, repo });
1485
+ term6.open();
1486
+ app6.startGame(newGameState(ds, human.id, 1, 'Leathertest', 13, 0), 'leathertest', Date.now(), true);
1487
+ const g6 = app6.g;
1488
+ g6.state.char.level = 60; g6.state.char.gold = 10_000_000;
1489
+ g6.devLearnProfession('leatherworking');
1490
+ g6.state.questlog.quests.push({ entry: 5141, status: 'turnedIn', progress: {} });
1491
+ g6.state.char.zoneId = 'azshara'; app6.render();
1492
+ const at = (pred) => app6.rowsOf(app6.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1493
+ app6.top().cursor = at((r, t) => t.startsWith('Peter Galen')); term6.press('return');
1494
+ if (showFrames) console.log(`\n=== leather trainer ===\n${term6.text()}`);
1495
+ const row = at((r, t) => /Become a specialist/.test(t));
1496
+ check('the trainer offers Dragonscale Leatherworking', row >= 0);
1497
+ if (row >= 0) {
1498
+ app6.top().cursor = row; term6.press('return');
1499
+ check('Enter makes you a Dragonscale leatherworker', g6.state.spellbook.known.includes(10656));
1500
+ }
1501
+ term6.close?.();
1502
+ }
1503
+
1504
+ // Mail to your own characters: a letter with an item lands in the other save; they send it back. Bank bags and deposit-all.
1505
+ {
1506
+ const { toSave, summarize } = await import('../persistence/saveShape.ts');
1507
+ const tick = () => new Promise((r) => setTimeout(r, 0));
1508
+ const store = new Map();
1509
+ const repo7 = {
1510
+ async list() { return [...store.values()].map(summarize); },
1511
+ async load(slot) { const s = store.get(slot); return s ? structuredClone(s) : null; },
1512
+ async save(save) { store.set(save.slot, structuredClone(save)); },
1513
+ };
1514
+ const now = Date.now();
1515
+ store.set('alt', toSave('alt', newGameState(ds, human.id, 1, 'Alttest', 21, 0), now, now));
1516
+ const term7 = new HeadlessTerm(110, 34);
1517
+ const app7 = new App({ term: term7, ds, repo: repo7 });
1518
+ term7.open();
1519
+ const main = newGameState(ds, human.id, 1, 'Mailtest', 22, 0);
1520
+ app7.startGame(main, 'main', now, true);
1521
+ await repo7.save(toSave('main', app7.g.snapshot(), now, now));
1522
+ const g7 = app7.g;
1523
+ g7.state.char.gold = 100000; g7.state.char.zoneId = 'stormwind';
1524
+ const linen = ds.allItems().find((it) => it.name === 'Linen Cloth');
1525
+ g7.state.inventory = addItem(g7.state.inventory, linen.entry, 7, linen.stackable);
1526
+ const at = (pred) => app7.rowsOf(app7.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1527
+ term7.press(':'); for (const ch of 'mail') term7.press(ch); term7.press('return'); await tick();
1528
+ term7.press('s');
1529
+ check('s in the mailbox opens a letter', app7.top()?.id === 'mail-compose');
1530
+ const compose = app7.top();
1531
+ app7.top().cursor = at((r, t) => t.startsWith('To')); term7.press('return'); await compose.picking;
1532
+ app7.top().cursor = at((r, t) => t.startsWith('Alttest')); term7.press('return');
1533
+ app7.top().cursor = at((r) => r.data?.slot === g7.state.inventory.stacks.findIndex((x) => x.item === linen.entry)); term7.press('return');
1534
+ term7.press('S'); await compose.sending; await tick();
1535
+ const altBox = store.get('alt').state.mailbox;
1536
+ check('the letter lands in the other character\'s mailbox', altBox.length === 1 && altBox[0].attachments.some((x) => x.entry === linen.entry) && countItem(g7.state.inventory, linen.entry) === 0);
1537
+ term7.close?.();
1538
+ // The other character sends it back.
1539
+ const term8 = new HeadlessTerm(110, 34);
1540
+ const app8 = new App({ term: term8, ds, repo: repo7 });
1541
+ term8.open();
1542
+ const alt = await repo7.load('alt');
1543
+ app8.startGame(alt.state, 'alt', alt.createdAt, false, alt.updatedAt);
1544
+ term8.press(':'); for (const ch of 'mail') term8.press(ch); term8.press('return'); await tick();
1545
+ app8.top().cursor = app8.rowsOf(app8.top()).findIndex((r) => r.data?.mail); term8.press('r'); term8.press('return');
1546
+ for (let i = 0; i < 5; i++) await tick();
1547
+ check('r returns it to the sender\'s mailbox', (store.get('main').state.mailbox ?? []).some((m) => m.attachments?.some((x) => x.entry === linen.entry)));
1548
+ term8.close?.();
1549
+ // Bank: a bag in a bank bag slot comes back out; D deposits every stack of an item.
1550
+ const bag = ds.allItems().find((it) => it.slot === 'bag' && (it.bagSlots ?? 0) >= 4 && !it.bagFamily);
1551
+ g7.state.inventory = addItem(addItem(g7.state.inventory, bag.entry, 1, 1), linen.entry, 25, linen.stackable);
1552
+ app7.push((await import('./tuiCity.mjs')).bankDialog(app7));
1553
+ term7.press('+'); term7.press('return');
1554
+ app7.top().cursor = at((r) => r.data?.s?.item === bag.entry); term7.press('B');
1555
+ check('B puts a bag in a bought bank bag slot', g7.state.bankBags.some((b) => b != null));
1556
+ app7.top().cursor = at((r) => r.data?.bankBag != null); term7.press('return');
1557
+ check('Enter on the bank bag takes it back out', g7.state.bankBags.every((b) => b == null) && countItem(g7.state.inventory, bag.entry) === 1);
1558
+ app7.top().cursor = at((r) => r.data?.s?.item === linen.entry); term7.press('D');
1559
+ check('D deposits every stack of the item', countItem(g7.state.inventory, linen.entry) === 0 && countItem(g7.state.bank, linen.entry) === 25);
1560
+ }
1561
+
1562
+ // Honor: the window shows rank, kills and the week; a City Defender gets free capital teleports.
1563
+ {
1564
+ const g = app.g;
1565
+ const { newHonorState, CITY_DEFENDER_MIN_RP } = await import('../domain/run/honor.ts');
1566
+ home(); term.press(':'); for (const ch of 'honor') term.press(ch); term.press('return'); show('honor');
1567
+ const honorText = strip(app.rowsOf(app.top()).map((r) => r.text).join('|'));
1568
+ check(':honor opens the honor window', app.top().id === 'honor' && /This week/.test(honorText) && /Standings posted/.test(honorText));
1569
+ term.press('escape');
1570
+ const keep = g.state.char.honor;
1571
+ g.state.char.honor = { ...(keep ?? newHonorState()), rp: CITY_DEFENDER_MIN_RP + 1, todayHk: 3 };
1572
+ term.press(':'); for (const ch of 'honor') term.press(ch); term.press('return');
1573
+ check('a City Defender sees the free capital teleports', /Capital teleports/.test(strip(app.rowsOf(app.top()).map((r) => r.text).join(' '))));
1574
+ check('the rank title shows', !/No rank/.test(strip(app.rowsOf(app.top())[0].text)));
1575
+ g.state.char.honor = keep;
1576
+ home();
1577
+ }
1578
+
1579
+ // Arena: the 1v1 and a 2v2 from the Honor window play in the live fight view and move the rating either way.
1580
+ {
1581
+ const g = app.g;
1582
+ const { arenaRatingOf } = await import('../domain/bg/arenaRating.ts');
1583
+ home(); term.press(':'); for (const ch of 'honor') term.press(ch); term.press('return');
1584
+ const duel = rowIndex((r, t) => /1v1|Arena 1/.test(t) || /gauntlet/.test(t));
1585
+ check('the honor window lists the arena 1v1', duel > 0);
1586
+ const r1 = arenaRatingOf(g.state.arenaRating, 1);
1587
+ app.top().cursor = duel; term.press('return'); show('arena duel');
1588
+ check('Enter starts the duel in the live fight view', !!app.fight && app.fight.label === 'arena');
1589
+ finishFight();
1590
+ check('the 1v1 moves the rating', arenaRatingOf(g.state.arenaRating, 1) !== r1 && app.log.some((l) => /Arena rating/.test(strip(l))));
1591
+ home(); term.press(':'); for (const ch of 'honor') term.press(ch); term.press('return');
1592
+ const two = rowIndex((r, t) => /companion\b/.test(t));
1593
+ const r2 = arenaRatingOf(g.state.arenaRating, 2);
1594
+ if (two > 0) {
1595
+ app.top().cursor = two; term.press('return');
1596
+ check('the 2v2 starts with a companion beside you', !!app.fight && app.fight.session.alliesView().length >= 1);
1597
+ finishFight();
1598
+ check('the 2v2 moves its own rating', arenaRatingOf(g.state.arenaRating, 2) !== r2);
1599
+ } else check('the honor window lists the 2v2', false);
1600
+ home();
1601
+ }
1602
+
1603
+ // Outdoor PvP: the Eastern Plaguelands towers and who holds them are on the World tab.
1604
+ {
1605
+ const g = app.g;
1606
+ const keep = g.state.char.zoneId;
1607
+ g.state.char.zoneId = 'easternplaguelands';
1608
+ home(); term.press('1');
1609
+ const rows = app.rowsOf(app.top());
1610
+ check('Eastern Plaguelands lists its four towers', rows.filter((r) => r.data?.tower).length === 4 && rows.some((r) => /^Towers/.test(strip(r.text))));
1611
+ g.state.char.zoneId = keep;
1612
+ home();
1613
+ }
1614
+
1615
+ // :dev verbs, and a random world-bot character from the create screen.
1616
+ {
1617
+ const g = app.g;
1618
+ const cmd = (text) => { term.press(':'); for (const ch of text) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1619
+ home();
1620
+ const linen = ds.allItems().find((it) => it.name === 'Linen Cloth');
1621
+ const had = countItem(g.state.inventory, linen.entry);
1622
+ cmd('dev item linen cloth 3');
1623
+ check(':dev item gives the item', countItem(g.state.inventory, linen.entry) === had + 3);
1624
+ cmd('dev prof cooking');
1625
+ check(':dev prof learns a profession at its cap', (g.state.char.skills.cooking?.max ?? 0) >= 300);
1626
+ const known = new Set(g.state.char.knownRecipes);
1627
+ const recipe = ds.recipes().find((r) => r.skill === 'cooking' && !known.has(r.id));
1628
+ cmd(`dev recipe ${recipe.id}`);
1629
+ check(':dev recipe learns it', g.state.char.knownRecipes.includes(recipe.id));
1630
+ cmd('dev quest 783');
1631
+ check(':dev quest finishes a quest', g.state.questlog.quests.some((q) => q.entry === 783 && q.status === 'turnedIn') || app.log.some((l) => /Quest done/.test(strip(l))));
1632
+ cmd('dev nothing');
1633
+ check(':dev with no verb lists them', app.log.some((l) => /:dev item/.test(strip(l))));
1634
+ const term9 = new HeadlessTerm(110, 34);
1635
+ const app9 = new App({ term: term9, ds, repo });
1636
+ term9.open();
1637
+ const { characterCreation } = await import('./tui.mjs');
1638
+ let created = null;
1639
+ characterCreation(app9, (state) => { created = state; });
1640
+ app9.top().cursor = app9.rowsOf(app9.top()).length - 1; term9.press('return');
1641
+ check('the create screen rolls a random world bot', !!created?.char?.name && created.char.level >= 1);
1642
+ term9.close?.();
1643
+ home();
1644
+ }
1645
+
1646
+ // Saves: from the picker, back up, delete and bring back, restore, export and import (a real save folder in tmp).
1647
+ {
1648
+ const { mkdtempSync, existsSync, rmSync } = await import('node:fs');
1649
+ const { tmpdir } = await import('node:os');
1650
+ const { join } = await import('node:path');
1651
+ const { FileSaveRepository } = await import('./fileRepository.mjs');
1652
+ const { toSave } = await import('../persistence/saveShape.ts');
1653
+ const { savePicker } = await import('./tuiSaves.mjs');
1654
+ const tick = async (n = 5) => { for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0)); };
1655
+ const dir = mkdtempSync(join(tmpdir(), 'azsh-saves-'));
1656
+ const repo10 = new FileSaveRepository(join(dir, 'saves'));
1657
+ const now = Date.now();
1658
+ await repo10.save(toSave('keeper', newGameState(ds, human.id, 1, 'Keeper', 31, 0), now, now));
1659
+ const term10 = new HeadlessTerm(110, 34);
1660
+ const app10 = new App({ term: term10, ds, repo: repo10 });
1661
+ term10.open();
1662
+ let played = null;
1663
+ const open = async () => { app10.stack = []; await savePicker(app10, { onPlay: (s2) => { played = s2; }, onNew: () => {} }); };
1664
+ const at = (pred) => app10.rowsOf(app10.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1665
+ const choose = async (re) => { app10.top().cursor = at((r, t) => re.test(t)); term10.press('return'); await tick(); };
1666
+ await open(); show('save picker');
1667
+ check('the picker lists the save', at((r, t) => /^Keeper/.test(t)) >= 0);
1668
+ app10.top().cursor = at((r, t) => /^Keeper/.test(t)); term10.press('m');
1669
+ await choose(/^Back it up now/);
1670
+ check('a save can be backed up by hand', (await repo10.listBackups('keeper')).length === 1);
1671
+ app10.top().cursor = at((r, t) => /^Keeper/.test(t)); term10.press('m');
1672
+ await choose(/^Export to a file/);
1673
+ const exported = app10.log.map(strip).reverse().find((l) => /^Wrote /.test(l))?.slice(6);
1674
+ check('a save exports to a file', !!exported && existsSync(exported));
1675
+ app10.top().cursor = at((r, t) => /^Keeper/.test(t)); term10.press('m');
1676
+ await choose(/^Delete/); term10.press('return'); await tick();
1677
+ check('delete removes it from the list', !(await repo10.list()).some((m) => m.slot === 'keeper'));
1678
+ await open();
1679
+ await choose(/^Bring back Keeper/);
1680
+ check('and Bring back restores it', (await repo10.list()).some((m) => m.slot === 'keeper'));
1681
+ await open();
1682
+ await choose(/^Import a save file/);
1683
+ for (const ch of exported) term10.press(ch === ' ' ? 'space' : ch); term10.press('return'); await tick();
1684
+ check('an exported file imports as a second copy', (await repo10.list()).filter((m) => m.name === 'Keeper').length === 2);
1685
+ await open();
1686
+ await choose(/^Keeper/);
1687
+ check('Enter on a save still plays it', played?.state?.char?.name === 'Keeper');
1688
+ term10.close?.();
1689
+ rmSync(dir, { recursive: true, force: true });
1690
+ if (exported) rmSync(exported, { force: true });
1691
+ }
1692
+
1693
+ // Wiki: / searches every catalog, a page lists its links, Enter follows one, Esc comes back.
1694
+ {
1695
+ home(); term.press('1');
1696
+ term.press('/'); for (const ch of 'linen cloth') term.press(ch === ' ' ? 'space' : ch); term.press('return'); show('wiki search');
1697
+ check('/ opens the wiki and searches', app.top().id === 'wiki' && /Linen Cloth/.test(strip(frame())));
1698
+ app.top().cursor = rowIndex((r, t) => t.startsWith('Linen Cloth')); term.press('return'); show('wiki item page');
1699
+ const page = app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
1700
+ check('the item page says where it drops', app.top().id === 'wiki-page' && /Dropped by/.test(page) && /Reagent for/.test(page));
1701
+ const drop = rowIndex((r, t) => !!r.act && /%/.test(t));
1702
+ app.top().cursor = drop; term.press('return');
1703
+ check('Enter follows a link to the NPC page', app.top().id === 'wiki-page' && app.stack.filter((v) => v.id === 'wiki-page').length === 2);
1704
+ term.press('escape');
1705
+ check('Esc goes back to the item', app.rowsOf(app.top()).some((r) => /Reagent for/.test(strip(r.text))));
1706
+ home(); term.press(':'); for (const ch of 'wiki hogger') term.press(ch === ' ' ? 'space' : ch); term.press('return');
1707
+ check(':wiki <words> opens on the search', app.top().id === 'wiki' && /Hogger/.test(strip(frame())));
1708
+ term.press('tab'); term.press('tab'); term.press('tab'); term.press('tab');
1709
+ check('Tab narrows to one kind', /\[NPCs\]/.test(strip(frame())));
1710
+ home();
1711
+ }
1712
+
1713
+ // Alterac Valley: from the Honor window it is a raid run in the dungeon run window.
1714
+ {
1715
+ const term11 = new HeadlessTerm(110, 34);
1716
+ const app11 = new App({ term: term11, ds, repo });
1717
+ term11.open();
1718
+ app11.startGame(newGameState(ds, human.id, 1, 'Valleytest', 41, 0), 'valleytest', Date.now(), true);
1719
+ const g11 = app11.g;
1720
+ g11.state.char.level = 60;
1721
+ const at = (pred) => app11.rowsOf(app11.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1722
+ term11.press(':'); for (const ch of 'honor') term11.press(ch); term11.press('return');
1723
+ const av = at((r, t) => /Alterac Valley/.test(t));
1724
+ check('the honor window lists Alterac Valley', av >= 0);
1725
+ app11.top().cursor = av; term11.press('return');
1726
+ if (showFrames) console.log(`\n=== alterac valley ===\n${term11.text()}`);
1727
+ check('Enter starts the valley as a raid run', /^alterac-valley-/.test(g11.state.instance?.dungeonId ?? '') && app11.top()?.id === 'dungeon');
1728
+ term11.close?.();
1729
+ }
1730
+
1731
+ // Warsong Gulch from the Honor window: pick your job, the match plays out, honor and marks settle, you are out.
1732
+ {
1733
+ const term12 = new HeadlessTerm(110, 34);
1734
+ const app12 = new App({ term: term12, ds, repo });
1735
+ term12.open();
1736
+ app12.startGame(newGameState(ds, human.id, 1, 'Gulchtest', 51, 0), 'gulchtest', Date.now(), true);
1737
+ const g12 = app12.g;
1738
+ g12.state.char.level = 30;
1739
+ const { stepBg } = await import('./tuiBg.mjs');
1740
+ const at = (pred) => app12.rowsOf(app12.top()).findIndex((r) => !r.header && pred(r, strip(r.text)));
1741
+ term12.press(':'); for (const ch of 'honor') term12.press(ch); term12.press('return');
1742
+ app12.top().cursor = at((r, t) => /Warsong Gulch/.test(t)); term12.press('return');
1743
+ if (showFrames) console.log(`\n=== warsong gulch ===\n${term12.text()}`);
1744
+ check('the honor window starts Warsong Gulch', app12.top()?.id === 'bg' && !!g12.state.battleground && /flag/.test(strip(term12.text())));
1745
+ app12.top().cursor = at((r, t) => /Your job/.test(t)); term12.press('return');
1746
+ app12.top().cursor = at((r, t) => /guard our flag/.test(t)); term12.press('return');
1747
+ check('you can change your job', app12.bg.world.units.get(app12.bg.selfId).role === 'defense');
1748
+ const bg = app12.bg;
1749
+ const marks0 = g12.state.inventory.stacks.length;
1750
+ let n = 0;
1751
+ while (!bg.world.ended && n < 40000) { stepBg(app12, bg, 200); n += 200; }
1752
+ app12.render();
1753
+ check('the match plays to an end', bg.world.ended && bg.settled);
1754
+ check('honor and marks of honor settle', bg.marks > 0 && g12.state.inventory.stacks.length > marks0 && (g12.state.char.honor?.cp ?? 0) > 0);
1755
+ check('and you are out of the battleground', !g12.state.battleground);
1756
+ app12.top().cursor = at((r, t) => /Leave the battleground/.test(t)); term12.press('return');
1757
+ check('Leave closes the match window', app12.top()?.id !== 'bg');
1758
+ term12.close?.();
1759
+ }
1760
+
1761
+ // Stratholme on the floor: the Gauntlet gate arms the Baron's Ultimatum, Ysida is caged, the 45 minutes run out.
1762
+ {
1763
+ const { loadNavMesh, stepFloor } = await import('./tuiFloor.mjs');
1764
+ const { setFlag, hasFlag } = await import('../world/instances.ts');
1765
+ const { STRATH_BARON_RUN } = await import('../data/dungeons/stratholme-undead.ts');
1766
+ const def = ds.instance('stratholme-undead');
1767
+ if (def && loadNavMesh(def)) {
1768
+ const term13 = new HeadlessTerm(110, 34);
1769
+ const app13 = new App({ term: term13, ds, repo });
1770
+ term13.open();
1771
+ app13.startGame(newGameState(ds, human.id, 1, 'Barontest', 61, 0), 'barontest', Date.now(), true);
1772
+ const g13 = app13.g;
1773
+ g13.state.char.level = 60;
1774
+ check('Stratholme (Undead) can be entered', g13.enterInstance('stratholme-undead', Date.now()).ok);
1775
+ app13.stack = []; (await import('./tuiDungeon.mjs')).openRun(app13);
1776
+ term13.press('W');
1777
+ const f = app13.floor;
1778
+ check('the Stratholme floor carries its script', app13.top()?.id === 'floor' && !!f.script);
1779
+ g13.state.instance = setFlag(g13.state.instance, STRATH_BARON_RUN.runFlag);
1780
+ f.world.entities = f.world.entities.filter((e) => e.party); // nothing to pull: only the clock matters here
1781
+ stepFloor(app13, f);
1782
+ check('the gate arms the Baron\'s Ultimatum and cages Ysida', app13.log.some((l) => /45 minutes/.test(strip(l))) && app13.log.some((l) => /Ysida Harmon/.test(strip(l))));
1783
+ f.runMs += STRATH_BARON_RUN.durationMs;
1784
+ stepFloor(app13, f);
1785
+ check('the 45 minutes run out', hasFlag(g13.state.instance, STRATH_BARON_RUN.failFlag) && app13.log.some((l) => /expired/.test(strip(l))));
1786
+ term13.close?.();
1787
+ } else check('Stratholme has a nav mesh to walk', false);
1788
+ }
1789
+
1790
+ // Events on the floor (2j): Fenrus arms as you come near, running fails it and holds it, his voidwalkers join the
1791
+ // fight you are in, the stop clears. Then Willix: an escort quest in the log starts the walk and the party keeps up.
1792
+ {
1793
+ const { loadNavMesh, stepFloor } = await import('./tuiFloor.mjs');
1794
+ const { openRun } = await import('./tuiDungeon.mjs');
1795
+ const sfk = ds.instance('shadowfang-keep'), rfk = ds.instance('razorfen-kraul');
1796
+ if (sfk && rfk && loadNavMesh(sfk) && loadNavMesh(rfk)) {
1797
+ const term14 = new HeadlessTerm(110, 34);
1798
+ const app14 = new App({ term: term14, ds, repo });
1799
+ term14.open();
1800
+ app14.startGame(newGameState(ds, human.id, 1, 'Eventtest', 71, 0), 'eventtest', Date.now(), true);
1801
+ const g14 = app14.g;
1802
+ g14.state.char.level = 30;
1803
+ for (const [cls, role] of [[1, 'tank'], [2, 'healer'], [8, 'dps'], [4, 'dps']]) g14.recruitCompanion(cls, role, 'twink');
1804
+ for (const c of g14.state.squad) c.level = 30;
1805
+ g14.regenTick(600, true);
1806
+ const fightOut = () => { let n = 0; while (app14.fight && n++ < 8000) app14.fight.tick(); };
1807
+ const stepUntil = (pred, max = 400) => { for (let i = 0; i < max && !pred(); i++) stepFloor(app14, app14.floor); return pred(); };
1808
+ check('Shadowfang Keep can be entered', g14.enterInstance('shadowfang-keep', Date.now()).ok);
1809
+ app14.stack = []; openRun(app14); term14.press('W');
1810
+ let f = app14.floor, ctx = f.script;
1811
+ const fenrus = sfk.encounters.find((e) => e.id === 'fenrus');
1812
+ const fenrusRow = app14.rowsOf(app14.top()).find((r) => /Fenrus/.test(strip(r.text ?? '')));
1813
+ check('the floor marks Fenrus as an event', !!fenrusRow && /^\s*W /.test(strip(fenrusRow.text)) && fenrusRow.detail().some((l) => /event in 2 waves/.test(strip(l))));
1814
+ Object.assign(f.world.party, { x: fenrus.origin.x + 20, y: fenrus.origin.y, z: fenrus.origin.z });
1815
+ check('coming within 50 yards starts the event and its first wave', stepUntil(() => !!app14.fight) && ctx.gauntlet?.encId === 'fenrus' && app14.log.some((l) => /the event begins/.test(strip(l))));
1816
+ check('the wave fights under its world guid', !!app14.fight.session.sideOfGuid(fenrus.spawnGuids[0]));
1817
+ app14.fight.flee();
1818
+ check('running away fails the event', !ctx.gauntlet && app14.log.some((l) => /The event fails/.test(strip(l))));
1819
+ stepUntil(() => !!ctx.gauntlet, 100);
1820
+ check('and it waits before it arms again', !ctx.gauntlet && !app14.fight);
1821
+ g14.regenTick(600, true);
1822
+ f.runMs += 60_000;
1823
+ check('a minute on, it arms again', stepUntil(() => !!app14.fight) && ctx.gauntlet?.idx === 1);
1824
+ ctx.callGauntletWave(); // the voidwalkers early, while Fenrus still stands
1825
+ const voids = app14.fight?.session.enemiesView().filter((v) => /Voidwalker/.test(v.name)) ?? [];
1826
+ check('a wave called mid-fight joins the fight you are in', voids.length === 4 && ctx.gauntlet.spawned.every((gd) => !!app14.fight.session.sideOfGuid(gd)));
1827
+ fightOut();
1828
+ stepUntil(() => !ctx.gauntlet || !!app14.fight, 50);
1829
+ check('the event ends and the stop is cleared', !ctx.gauntlet && !app14.fight && g14.state.instance.defeated.includes('fenrus') && app14.log.some((l) => /Fenrus the Devourer is done/.test(strip(l))));
1830
+ // Willix the Importer: the quest is in the log, so the escort starts and the party follows him.
1831
+ app14.stack = [];
1832
+ g14.leaveInstance(Date.now());
1833
+ check('Razorfen Kraul can be entered', g14.enterInstance('razorfen-kraul', Date.now()).ok);
1834
+ openRun(app14); term14.press('W');
1835
+ f = app14.floor; ctx = f.script;
1836
+ const willix = f.world.entities.find((e) => e.entry === 4508);
1837
+ check('the floor carries friendly NPCs (Willix)', !!willix && !willix.hostile);
1838
+ Object.assign(f.world.party, { x: willix.x + 2, y: willix.y, z: willix.z });
1839
+ g14.state.questlog.quests.push({ entry: 1144, status: 'active', kills: {}, objects: {}, spells: {}, explored: false });
1840
+ check('an escort quest in the log starts the escort', stepUntil(() => !!ctx.floorEscort, 40) && ctx.floorEscort.status === 'active' && app14.log.some((l) => /Escort started/.test(strip(l))));
1841
+ check('the floor shows whom you escort', app14.rowsOf(app14.top()).some((r) => /Escorting Willix the Importer/.test(strip(r.text ?? ''))));
1842
+ const at = { x: willix.x, y: willix.y };
1843
+ const gap = () => Math.hypot(f.world.party.x - willix.x, f.world.party.y - willix.y);
1844
+ for (let i = 0; i < 1500 && ctx.floorEscort.waypointIndex < 6; i++) { if (app14.fight) fightOut(); else stepFloor(app14, f); }
1845
+ // He walks on while you fight and waits at 50 yards; out of the fight the party catches up.
1846
+ for (let i = 0; i < 600 && gap() > 12; i++) { if (app14.fight) fightOut(); else stepFloor(app14, f); }
1847
+ check('Willix walks his path and the party keeps up', ctx.floorEscort.waypointIndex >= 6 && Math.hypot(willix.x - at.x, willix.y - at.y) > 5 && gap() <= 12);
1848
+ term14.close?.();
1849
+ } else check('Shadowfang Keep and Razorfen Kraul have nav meshes to walk', false);
1850
+ }
1851
+
1852
+ // World search: s on the World tab, a vendor item lists who sells it and where, a node its zones, a dungeon its door.
1853
+ {
1854
+ const txt = () => app.rowsOf(app.top()).map((r) => strip(r.text)).join('|');
1855
+ const search = (q) => { home(); term.press('1'); term.press('s'); for (const ch of q) term.press(ch === ' ' ? 'space' : ch); term.press('return'); };
1856
+ search('mining pick'); show('world search');
1857
+ check('s opens the world search', app.top().id === 'world-search' && /Mining Pick/.test(txt()));
1858
+ check('an item lists the vendors that sell it and where they stand', /sold by .*<.*>.*(here|Stormwind|Westfall|Loch Modan|Redridge)/.test(txt()));
1859
+ search('copper vein'); show('world search node');
1860
+ check('a gather node lists the zones it grows in', /Copper Vein.*Mining.*(here|Dun Morogh|Durotar|Mulgore|Westfall|Loch Modan)/.test(txt()));
1861
+ search('deadmines');
1862
+ check('a dungeon shows its entrance', /Deadmines.*Dungeon.*entrance in Westfall/.test(txt()));
1863
+ search('hoger');
1864
+ check('a typo still finds the creature (fuzzy)', /Hogger L11.*here/.test(txt()));
1865
+ search('westfall');
1866
+ app.top().cursor = rowIndex((r, t) => /^Westfall/.test(t)); term.press('return');
1867
+ check('Enter on a zone elsewhere asks before travelling', app.top().id === 'pick' && /Travel to Westfall/.test(strip(frame())));
1868
+ term.press('escape');
1869
+ search('elwynn');
1870
+ app.top().cursor = rowIndex((r, t) => /^Elwynn Forest/.test(t)); term.press('return');
1871
+ check('Enter on the zone you stand in just says so', app.top().id === 'world-search' && /already here/.test(app.flash?.text ?? ''));
1872
+ home();
1873
+ }
1874
+
1875
+ // Help overlay and escape.
1876
+ term.press('?'); check('help overlay', app.top().id === 'help'); term.press('escape');
1877
+ check('help closed', app.stack.length === 0);
1878
+
1879
+ check('frames were drawn', term.frames > 20);
1880
+ check('saves happened', repo.saved > 0);
1881
+ console.log(failures ? `\n${failures} check(s) failed` : '\nall TUI checks passed');
1882
+ process.exit(failures ? 1 : 0);