azeroth-shell 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -2
- package/bin/azsh.mjs +19 -0
- package/docs/aifid/eventai-texts.json +4414 -0
- package/docs/dungeons/atlas/atlas-rows-dungeons.json +8989 -0
- package/docs/dungeons/survey/pack-id-migration.json +2418 -0
- package/package.json +35 -4
- package/src/autopilot/bossSkip.ts +26 -0
- package/src/autopilot/combatRotation.ts +824 -0
- package/src/autopilot/config.ts +533 -0
- package/src/autopilot/consumables.ts +58 -0
- package/src/autopilot/encounterStand.ts +121 -0
- package/src/autopilot/objectGatePlan.ts +53 -0
- package/src/autopilot/pathEngage.ts +229 -0
- package/src/autopilot/plan.ts +1643 -0
- package/src/autopilot/questPolicy.ts +274 -0
- package/src/autopilot/routeStep.ts +69 -0
- package/src/autopilot/sessionReport.ts +88 -0
- package/src/autopilot/topOffPower.ts +26 -0
- package/src/autopilot/view.ts +476 -0
- package/src/cli/bootCheck.mjs +34 -0
- package/src/cli/bootData.mjs +57 -0
- package/src/cli/combatLogFilter.ts +153 -0
- package/src/cli/combatLogFormat.ts +613 -0
- package/src/cli/fileRepository.mjs +105 -0
- package/src/cli/gzLoader.mjs +31 -0
- package/src/cli/liveGauntletEscort.ts +978 -0
- package/src/cli/liveStratUndead.ts +305 -0
- package/src/cli/macroHostCombat.ts +545 -0
- package/src/cli/macroHostWorld.ts +330 -0
- package/src/cli/prefs.mjs +31 -0
- package/src/cli/register.mjs +4 -0
- package/src/cli/repl.mjs +693 -0
- package/src/cli/rotationText.mjs +226 -0
- package/src/cli/squad.mjs +105 -0
- package/src/cli/term.mjs +144 -0
- package/src/cli/text.mjs +122 -0
- package/src/cli/tui.mjs +626 -0
- package/src/cli/tuiArena.mjs +109 -0
- package/src/cli/tuiAutopilotSettings.mjs +93 -0
- package/src/cli/tuiBags.mjs +180 -0
- package/src/cli/tuiBars.mjs +133 -0
- package/src/cli/tuiBg.mjs +243 -0
- package/src/cli/tuiBulk.mjs +162 -0
- package/src/cli/tuiCheck.mjs +1882 -0
- package/src/cli/tuiCity.mjs +396 -0
- package/src/cli/tuiCompanionKit.mjs +300 -0
- package/src/cli/tuiCompanions.mjs +471 -0
- package/src/cli/tuiDev.mjs +62 -0
- package/src/cli/tuiDungeon.mjs +606 -0
- package/src/cli/tuiEnchantTarget.mjs +120 -0
- package/src/cli/tuiFight.mjs +577 -0
- package/src/cli/tuiFightPanes.mjs +228 -0
- package/src/cli/tuiFloor.mjs +506 -0
- package/src/cli/tuiGear.mjs +158 -0
- package/src/cli/tuiHonor.mjs +74 -0
- package/src/cli/tuiLoot.mjs +97 -0
- package/src/cli/tuiMacros.mjs +144 -0
- package/src/cli/tuiOutfitter.mjs +115 -0
- package/src/cli/tuiPet.mjs +116 -0
- package/src/cli/tuiProfessions.mjs +800 -0
- package/src/cli/tuiRaid.mjs +127 -0
- package/src/cli/tuiRep.mjs +65 -0
- package/src/cli/tuiSaves.mjs +69 -0
- package/src/cli/tuiSearch.mjs +363 -0
- package/src/cli/tuiSettings.mjs +151 -0
- package/src/cli/tuiSimLab.mjs +80 -0
- package/src/cli/tuiTalents.mjs +60 -0
- package/src/cli/tuiViews.mjs +1178 -0
- package/src/cli/tuiWiki.mjs +181 -0
- package/src/cli/tuiWorld.mjs +361 -0
- package/src/cli/wikiIndex.ts +259 -0
- package/src/cli/wikiSources.ts +355 -0
- package/src/data/alteracValley.ts +176 -0
- package/src/data/appearanceOptions.ts +534 -0
- package/src/data/applyLootChanceSanity.ts +21 -0
- package/src/data/createFlavor.ts +96 -0
- package/src/data/customItems.ts +33 -0
- package/src/data/dataStore.ts +1904 -0
- package/src/data/dungeonGameObjects.ts +84 -0
- package/src/data/dungeonPlateCover.ts +66 -0
- package/src/data/dungeonSpawns.ts +294 -0
- package/src/data/dungeons/alterac-valley.ts +135 -0
- package/src/data/dungeons/apply-dungeon-rooms.ts +29 -0
- package/src/data/dungeons/apply-encounter-outdoors.ts +72 -0
- package/src/data/dungeons/apply-instance-geometry.ts +65 -0
- package/src/data/dungeons/apply-spawn-placement.ts +171 -0
- package/src/data/dungeons/blackfathom-deeps.ts +539 -0
- package/src/data/dungeons/blackrock-depths.ts +1410 -0
- package/src/data/dungeons/blackwing-lair.ts +129 -0
- package/src/data/dungeons/boss-eventai.ts +8 -0
- package/src/data/dungeons/deadmines.ts +410 -0
- package/src/data/dungeons/dire-maul-east.ts +980 -0
- package/src/data/dungeons/dire-maul-north.ts +661 -0
- package/src/data/dungeons/dire-maul-west.ts +785 -0
- package/src/data/dungeons/dungeon-rooms.json +5479 -0
- package/src/data/dungeons/encounter-outdoors.json +359 -0
- package/src/data/dungeons/eventai/blackfathom-deeps.ts +308 -0
- package/src/data/dungeons/eventai/blackrock-depths.ts +1257 -0
- package/src/data/dungeons/eventai/deadmines.ts +264 -0
- package/src/data/dungeons/eventai/dire-maul-east.ts +320 -0
- package/src/data/dungeons/eventai/gnomeregan.ts +213 -0
- package/src/data/dungeons/eventai/helpers.ts +79 -0
- package/src/data/dungeons/eventai/index.ts +133 -0
- package/src/data/dungeons/eventai/lbrs.ts +372 -0
- package/src/data/dungeons/eventai/maraudon.ts +323 -0
- package/src/data/dungeons/eventai/ragefire-chasm.ts +108 -0
- package/src/data/dungeons/eventai/razorfen-downs.ts +207 -0
- package/src/data/dungeons/eventai/razorfen-kraul.ts +326 -0
- package/src/data/dungeons/eventai/scholomance.ts +680 -0
- package/src/data/dungeons/eventai/shadowfang-keep.ts +230 -0
- package/src/data/dungeons/eventai/sm-cathedral.ts +75 -0
- package/src/data/dungeons/eventai/sm-graveyard.ts +150 -0
- package/src/data/dungeons/eventai/sm-library.ts +55 -0
- package/src/data/dungeons/eventai/stratholme-undead.ts +127 -0
- package/src/data/dungeons/eventai/sunken-temple.ts +582 -0
- package/src/data/dungeons/eventai/the-stockade.ts +228 -0
- package/src/data/dungeons/eventai/ubrs.ts +225 -0
- package/src/data/dungeons/eventai/uldaman.ts +355 -0
- package/src/data/dungeons/eventai/wailing-caverns.ts +396 -0
- package/src/data/dungeons/eventai/zul-farrak.ts +591 -0
- package/src/data/dungeons/gnomeregan.ts +909 -0
- package/src/data/dungeons/index.ts +126 -0
- package/src/data/dungeons/instance-geometry.json +5233 -0
- package/src/data/dungeons/key-sources.json +169 -0
- package/src/data/dungeons/lbrs.ts +996 -0
- package/src/data/dungeons/maraudon.ts +786 -0
- package/src/data/dungeons/molten-core.ts +194 -0
- package/src/data/dungeons/naxxramas.ts +173 -0
- package/src/data/dungeons/onyxias-lair.ts +71 -0
- package/src/data/dungeons/pack-from-clusters.ts +1256 -0
- package/src/data/dungeons/ragefire-chasm.ts +238 -0
- package/src/data/dungeons/razorfen-downs.ts +698 -0
- package/src/data/dungeons/razorfen-kraul.ts +766 -0
- package/src/data/dungeons/ruins-of-ahnqiraj.ts +121 -0
- package/src/data/dungeons/scholomance.ts +1076 -0
- package/src/data/dungeons/shadowfang-keep.ts +740 -0
- package/src/data/dungeons/sm-armory.ts +266 -0
- package/src/data/dungeons/sm-cathedral.ts +430 -0
- package/src/data/dungeons/sm-graveyard.ts +482 -0
- package/src/data/dungeons/sm-library.ts +337 -0
- package/src/data/dungeons/stratholme-live.ts +808 -0
- package/src/data/dungeons/stratholme-undead.ts +1164 -0
- package/src/data/dungeons/sunken-temple.ts +931 -0
- package/src/data/dungeons/temple-of-ahnqiraj.ts +119 -0
- package/src/data/dungeons/the-stockade.ts +282 -0
- package/src/data/dungeons/ubrs.ts +1286 -0
- package/src/data/dungeons/uldaman.ts +685 -0
- package/src/data/dungeons/wailing-caverns.ts +356 -0
- package/src/data/dungeons/zul-farrak.ts +686 -0
- package/src/data/dungeons/zul-gurub.ts +142 -0
- package/src/data/dungeons.ts +42 -0
- package/src/data/fixtures.ts +446 -0
- package/src/data/gameobject-data.json.gz +0 -0
- package/src/data/item-data.json.gz +0 -0
- package/src/data/loot-data.json.gz +0 -0
- package/src/data/maps/atlas-approach-maps.json +1425 -0
- package/src/data/maps/atlas-landmarks.json +1711 -0
- package/src/data/maps/dungeon-viewports.json +392 -0
- package/src/data/materializeModDamagePercentTaken.ts +70 -0
- package/src/data/materializeModHealthRegen.ts +89 -0
- package/src/data/materializeSpellReputation.ts +26 -0
- package/src/data/pageTexts.ts +19 -0
- package/src/data/profession-data.json.gz +0 -0
- package/src/data/raid-plans/blackfathom-deeps.json +86 -0
- package/src/data/raid-plans/blackrock-depths.json +419 -0
- package/src/data/raid-plans/blackwing-lair.json +328 -0
- package/src/data/raid-plans/deadmines.json +55 -0
- package/src/data/raid-plans/dire-maul-east.json +94 -0
- package/src/data/raid-plans/dire-maul-north.json +70 -0
- package/src/data/raid-plans/dire-maul-west.json +75 -0
- package/src/data/raid-plans/gnomeregan.json +131 -0
- package/src/data/raid-plans/lbrs.json +176 -0
- package/src/data/raid-plans/maraudon.json +102 -0
- package/src/data/raid-plans/molten-core.json +169 -0
- package/src/data/raid-plans/naxxramas.json +354 -0
- package/src/data/raid-plans/onyxias-lair.json +18 -0
- package/src/data/raid-plans/ragefire-chasm.json +30 -0
- package/src/data/raid-plans/razorfen-downs.json +75 -0
- package/src/data/raid-plans/razorfen-kraul.json +53 -0
- package/src/data/raid-plans/ruins-of-ahnqiraj.json +110 -0
- package/src/data/raid-plans/scholomance.json +170 -0
- package/src/data/raid-plans/shadowfang-keep.json +87 -0
- package/src/data/raid-plans/sm-armory.json +53 -0
- package/src/data/raid-plans/sm-cathedral.json +72 -0
- package/src/data/raid-plans/sm-graveyard.json +63 -0
- package/src/data/raid-plans/sm-library.json +23 -0
- package/src/data/raid-plans/stratholme-live.json +129 -0
- package/src/data/raid-plans/stratholme-undead.json +73 -0
- package/src/data/raid-plans/sunken-temple.json +159 -0
- package/src/data/raid-plans/temple-of-ahnqiraj.json +163 -0
- package/src/data/raid-plans/the-stockade.json +42 -0
- package/src/data/raid-plans/ubrs.json +53 -0
- package/src/data/raid-plans/uldaman.json +131 -0
- package/src/data/raid-plans/wailing-caverns.json +90 -0
- package/src/data/raid-plans/zul-farrak.json +156 -0
- package/src/data/raid-plans/zul-gurub.json +307 -0
- package/src/data/raidInstanceCreatures.mjs +473 -0
- package/src/data/rotations/index.ts +152 -0
- package/src/data/rotations/layout.mjs +72 -0
- package/src/data/rotations/pve/druid/balance.json +754 -0
- package/src/data/rotations/pve/druid/feral-tank.json +2448 -0
- package/src/data/rotations/pve/druid/feral.json +2660 -0
- package/src/data/rotations/pve/druid/restoration.json +424 -0
- package/src/data/rotations/pve/hunter/beast-mastery.json +1288 -0
- package/src/data/rotations/pve/hunter/marksmanship.json +1260 -0
- package/src/data/rotations/pve/hunter/survival.json +1373 -0
- package/src/data/rotations/pve/mage/arcane-leveling.json +1798 -0
- package/src/data/rotations/pve/mage/fire-leveling.json +2003 -0
- package/src/data/rotations/pve/mage/frost-leveling.json +1912 -0
- package/src/data/rotations/pve/paladin/holy.json +755 -0
- package/src/data/rotations/pve/paladin/protection.json +962 -0
- package/src/data/rotations/pve/paladin/retribution.json +924 -0
- package/src/data/rotations/pve/priest/discipline.json +772 -0
- package/src/data/rotations/pve/priest/holy.json +707 -0
- package/src/data/rotations/pve/priest/shadow.json +787 -0
- package/src/data/rotations/pve/rogue/assassination.json +2477 -0
- package/src/data/rotations/pve/rogue/combat.json +2483 -0
- package/src/data/rotations/pve/rogue/subtlety.json +2523 -0
- package/src/data/rotations/pve/shaman/elemental.json +1157 -0
- package/src/data/rotations/pve/shaman/enhancement.json +1085 -0
- package/src/data/rotations/pve/shaman/restoration.json +592 -0
- package/src/data/rotations/pve/warlock/affliction.json +1185 -0
- package/src/data/rotations/pve/warlock/demonology.json +1165 -0
- package/src/data/rotations/pve/warlock/destruction.json +1177 -0
- package/src/data/rotations/pve/warrior/arms.json +3020 -0
- package/src/data/rotations/pve/warrior/fury.json +2996 -0
- package/src/data/rotations/pve/warrior/protection.json +2509 -0
- package/src/data/rotations/pvp/druid/balance.json +593 -0
- package/src/data/rotations/pvp/druid/feral.json +3276 -0
- package/src/data/rotations/pvp/druid/restoration.json +662 -0
- package/src/data/rotations/pvp/hunter/beast-mastery.json +665 -0
- package/src/data/rotations/pvp/hunter/marksmanship.json +2406 -0
- package/src/data/rotations/pvp/hunter/survival.json +635 -0
- package/src/data/rotations/pvp/mage/arcane.json +574 -0
- package/src/data/rotations/pvp/mage/fire.json +570 -0
- package/src/data/rotations/pvp/mage/frost.json +4048 -0
- package/src/data/rotations/pvp/paladin/holy.json +1498 -0
- package/src/data/rotations/pvp/paladin/protection.json +567 -0
- package/src/data/rotations/pvp/paladin/retribution.json +1110 -0
- package/src/data/rotations/pvp/priest/discipline.json +1848 -0
- package/src/data/rotations/pvp/priest/holy.json +6340 -0
- package/src/data/rotations/pvp/priest/shadow.json +2329 -0
- package/src/data/rotations/pvp/rogue/assassination.json +1322 -0
- package/src/data/rotations/pvp/rogue/combat.json +5258 -0
- package/src/data/rotations/pvp/rogue/subtlety.json +586 -0
- package/src/data/rotations/pvp/shaman/elemental.json +1404 -0
- package/src/data/rotations/pvp/shaman/enhancement.json +1277 -0
- package/src/data/rotations/pvp/shaman/restoration.json +2490 -0
- package/src/data/rotations/pvp/warlock/affliction.json +1017 -0
- package/src/data/rotations/pvp/warlock/demonology.json +1503 -0
- package/src/data/rotations/pvp/warlock/destruction.json +638 -0
- package/src/data/rotations/pvp/warrior/arms.json +5665 -0
- package/src/data/rotations/pvp/warrior/fury.json +850 -0
- package/src/data/rotations/pvp/warrior/protection.json +777 -0
- package/src/data/schema/bundle.ts +511 -0
- package/src/data/schema/constants.ts +49 -0
- package/src/data/schema/creature.ts +454 -0
- package/src/data/schema/encounter.ts +155 -0
- package/src/data/schema/faction.ts +48 -0
- package/src/data/schema/item.ts +297 -0
- package/src/data/schema/itemEnchant.ts +105 -0
- package/src/data/schema/npc.ts +44 -0
- package/src/data/schema/primitives.ts +295 -0
- package/src/data/schema/profession.ts +226 -0
- package/src/data/schema/quest.ts +154 -0
- package/src/data/schema/spell.ts +1149 -0
- package/src/data/schema/talent.ts +74 -0
- package/src/data/schema/world.ts +124 -0
- package/src/data/schema.ts +19 -0
- package/src/data/skinNodeGapFill.ts +68 -0
- package/src/data/spell-data.json.gz +0 -0
- package/src/data/spell-script-targets.json +1 -0
- package/src/data/tables/ahItemValues.json.gz +0 -0
- package/src/data/tables/alteracValley.json.gz +0 -0
- package/src/data/tables/areatriggerTeleports.json.gz +0 -0
- package/src/data/tables/botNames.json.gz +0 -0
- package/src/data/tables/botPopulation.json.gz +0 -0
- package/src/data/tables/botSpecs.json.gz +0 -0
- package/src/data/tables/classLevelStats.json.gz +0 -0
- package/src/data/tables/combatConditions.json.gz +0 -0
- package/src/data/tables/combatSkillMeta.json.gz +0 -0
- package/src/data/tables/conditions.json.gz +0 -0
- package/src/data/tables/createInfo.json.gz +0 -0
- package/src/data/tables/creatureAggroGroups.json.gz +0 -0
- package/src/data/tables/creatureClassLevelStats.json.gz +0 -0
- package/src/data/tables/creaturePatrolPacks.json.gz +0 -0
- package/src/data/tables/creatureWeapons.json.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/tables/dbScripts.json.gz +0 -0
- package/src/data/tables/dungeonAuthoredRoutes.json.gz +0 -0
- package/src/data/tables/dungeonCensus.json.gz +0 -0
- package/src/data/tables/dungeonGameObjects.json.gz +0 -0
- package/src/data/tables/dungeonPacks.json.gz +0 -0
- package/src/data/tables/dungeonPlateCover.json.gz +0 -0
- package/src/data/tables/dungeonSpawns.json.gz +0 -0
- package/src/data/tables/dungeonSurvey.json.gz +0 -0
- package/src/data/tables/durabilityCosts.json.gz +0 -0
- package/src/data/tables/durabilityQuality.json.gz +0 -0
- package/src/data/tables/enchantApplicators.json.gz +0 -0
- package/src/data/tables/entranceYards.json.gz +0 -0
- package/src/data/tables/explorationBasexp.json.gz +0 -0
- package/src/data/tables/factionTemplates.json.gz +0 -0
- package/src/data/tables/factions.json.gz +0 -0
- package/src/data/tables/gameEvents.json.gz +0 -0
- package/src/data/tables/gameObjects.json.gz +0 -0
- package/src/data/tables/gossipMenus.json.gz +0 -0
- package/src/data/tables/graveyards.json.gz +0 -0
- package/src/data/tables/instanceMaps.json.gz +0 -0
- package/src/data/tables/itemEnchantmentTemplates.json.gz +0 -0
- package/src/data/tables/itemEnchants.json.gz +0 -0
- package/src/data/tables/itemRandomProperties.json.gz +0 -0
- package/src/data/tables/itemSets.json.gz +0 -0
- package/src/data/tables/items.json.gz +0 -0
- package/src/data/tables/levelStats.json.gz +0 -0
- package/src/data/tables/locks.json.gz +0 -0
- package/src/data/tables/map-pin-overrides.json.gz +0 -0
- package/src/data/tables/nonCombatTrash.json.gz +0 -0
- package/src/data/tables/npcTexts.json.gz +0 -0
- package/src/data/tables/pageTexts.json.gz +0 -0
- package/src/data/tables/petAbilityTP.json.gz +0 -0
- package/src/data/tables/petGenericSpells.json.gz +0 -0
- package/src/data/tables/petLevelStats.json.gz +0 -0
- package/src/data/tables/playerbot-weightscales.json.gz +0 -0
- package/src/data/tables/pools.json.gz +0 -0
- package/src/data/tables/quests.json.gz +0 -0
- package/src/data/tables/races.json.gz +0 -0
- package/src/data/tables/reputationRanks.json.gz +0 -0
- package/src/data/tables/reputationRates.json.gz +0 -0
- package/src/data/tables/reputationSpillover.json.gz +0 -0
- package/src/data/tables/ridingSpells.json.gz +0 -0
- package/src/data/tables/scriptTexts.json.gz +0 -0
- package/src/data/tables/soundClipVolume.json.gz +0 -0
- package/src/data/tables/spellChain.json.gz +0 -0
- package/src/data/tables/spellTeleports.json.gz +0 -0
- package/src/data/tables/spells.json.gz +0 -0
- package/src/data/tables/talents.json.gz +0 -0
- package/src/data/tables/tavernTriggers.json.gz +0 -0
- package/src/data/tables/taxiNodes.json.gz +0 -0
- package/src/data/tables/taxiRoutes.json.gz +0 -0
- package/src/data/tables/trainers.json.gz +0 -0
- package/src/data/tables/transports.json.gz +0 -0
- package/src/data/tables/travelAnchors.json.gz +0 -0
- package/src/data/tables/travelEdges.json.gz +0 -0
- package/src/data/tables/unitConditions.json.gz +0 -0
- package/src/data/tables/vendors.json.gz +0 -0
- package/src/data/tables/weaponEnchantProcs.json.gz +0 -0
- package/src/data/tables/xp.json.gz +0 -0
- package/src/data/tables/zoneWeather.json.gz +0 -0
- package/src/data/tables/zones.json.gz +0 -0
- package/src/data/world-data.ts +56 -0
- package/src/data/zone-music-sets.json +2751 -0
- package/src/dev/etl/lib/loot-chance-sanity.mjs +180 -0
- package/src/domain/audioQuality.mjs +145 -0
- package/src/domain/bg/ab.ts +433 -0
- package/src/domain/bg/arenaComps.ts +644 -0
- package/src/domain/bg/arenaLab.ts +695 -0
- package/src/domain/bg/arenaPace.ts +31 -0
- package/src/domain/bg/arenaRating.ts +110 -0
- package/src/domain/bg/av.ts +758 -0
- package/src/domain/bg/avEnemyPush.ts +219 -0
- package/src/domain/bg/avField.ts +283 -0
- package/src/domain/bg/avInstanceNodes.ts +197 -0
- package/src/domain/bg/avInstanceScore.ts +123 -0
- package/src/domain/bg/avQuestItems.ts +55 -0
- package/src/domain/bg/avQuests.ts +210 -0
- package/src/domain/bg/avSession.ts +196 -0
- package/src/domain/bg/bgConsumes.ts +269 -0
- package/src/domain/bg/bgPrebuff.ts +153 -0
- package/src/domain/bg/bgRest.ts +91 -0
- package/src/domain/bg/bgRoster.ts +305 -0
- package/src/domain/bg/bgTier.ts +61 -0
- package/src/domain/bg/bgWorld.ts +862 -0
- package/src/domain/bg/match.ts +215 -0
- package/src/domain/bg/strategies.ts +552 -0
- package/src/domain/bg/types.ts +291 -0
- package/src/domain/bg/wsg.ts +264 -0
- package/src/domain/bots/actionProviderSpec.ts +38 -0
- package/src/domain/bots/autoConsumables.ts +285 -0
- package/src/domain/bots/betweenPullRest.ts +239 -0
- package/src/domain/bots/botGear.ts +248 -0
- package/src/domain/bots/botInventory.ts +486 -0
- package/src/domain/bots/botPet.ts +535 -0
- package/src/domain/bots/botPool.ts +291 -0
- package/src/domain/bots/botProfessions.ts +334 -0
- package/src/domain/bots/companionActionBar.ts +209 -0
- package/src/domain/bots/companionHireCost.ts +236 -0
- package/src/domain/bots/companionMissions.ts +582 -0
- package/src/domain/bots/companionPrep.ts +254 -0
- package/src/domain/bots/companionQuests.ts +270 -0
- package/src/domain/bots/companionRep.ts +75 -0
- package/src/domain/bots/companionRotation.ts +708 -0
- package/src/domain/bots/companionXp.ts +66 -0
- package/src/domain/bots/consumableShare.ts +811 -0
- package/src/domain/bots/dpsMate.ts +1072 -0
- package/src/domain/bots/groupCompReport.ts +132 -0
- package/src/domain/bots/healthstoneSupply.ts +198 -0
- package/src/domain/bots/outfitter.ts +1056 -0
- package/src/domain/bots/paladinPrePull.ts +231 -0
- package/src/domain/bots/partyRole.ts +40 -0
- package/src/domain/bots/playerbotEquipCache.ts +412 -0
- package/src/domain/bots/playerbotItemEligibility.ts +60 -0
- package/src/domain/bots/playerbotStatWeight.ts +853 -0
- package/src/domain/bots/playerbotUnavailableItems.ts +147 -0
- package/src/domain/bots/prePullBuffs.ts +219 -0
- package/src/domain/bots/prePullStealth.ts +82 -0
- package/src/domain/bots/runSupply.ts +251 -0
- package/src/domain/bots/runSupplyPrefs.ts +28 -0
- package/src/domain/bots/sendTradeGoods.ts +394 -0
- package/src/domain/bots/soulShardCap.ts +35 -0
- package/src/domain/bots/squad.ts +842 -0
- package/src/domain/bots/squadRun.ts +81 -0
- package/src/domain/character/actionbar.ts +346 -0
- package/src/domain/character/appearance.ts +165 -0
- package/src/domain/character/buildCombatant.ts +1726 -0
- package/src/domain/character/character.ts +356 -0
- package/src/domain/character/characterName.ts +43 -0
- package/src/domain/character/characterRatings.ts +424 -0
- package/src/domain/character/characterSpells.ts +282 -0
- package/src/domain/character/characterStats.ts +1559 -0
- package/src/domain/character/characterTalents.ts +398 -0
- package/src/domain/character/combatSkills.ts +994 -0
- package/src/domain/character/displayForm.ts +72 -0
- package/src/domain/character/formTalentAuras.ts +171 -0
- package/src/domain/character/forms.ts +499 -0
- package/src/domain/character/races.ts +40 -0
- package/src/domain/character/resurrectionSickness.ts +49 -0
- package/src/domain/character/spellbook.ts +169 -0
- package/src/domain/character/stanceBar.ts +354 -0
- package/src/domain/character/stancePrereq.ts +70 -0
- package/src/domain/character/talentBuilds.ts +63 -0
- package/src/domain/character/talentImport.ts +130 -0
- package/src/domain/character/talentLink.ts +123 -0
- package/src/domain/character/talents.ts +201 -0
- package/src/domain/core/conditionSnapshot.ts +92 -0
- package/src/domain/core/hover.ts +14 -0
- package/src/domain/core/ids.ts +58 -0
- package/src/domain/core/internalNameTags.ts +5 -0
- package/src/domain/core/runtimeDataPatches.ts +357 -0
- package/src/domain/creature/aiTargeting.ts +588 -0
- package/src/domain/creature/combatCondition.ts +157 -0
- package/src/domain/creature/creatureAggro.ts +804 -0
- package/src/domain/creature/creatureAi.ts +206 -0
- package/src/domain/creature/creatureAiConstants.ts +661 -0
- package/src/domain/creature/creatureCastList.ts +1007 -0
- package/src/domain/creature/creatureEventAi.ts +1409 -0
- package/src/domain/creature/creatureGroups.ts +144 -0
- package/src/domain/creature/creatureLinking.ts +333 -0
- package/src/domain/creature/creatureScaling.ts +320 -0
- package/src/domain/creature/creatureTrigger.ts +53 -0
- package/src/domain/creature/creatureWander.ts +74 -0
- package/src/domain/creature/eventAiTexts.ts +145 -0
- package/src/domain/creature/faction.ts +287 -0
- package/src/domain/creature/forcedReaction.ts +48 -0
- package/src/domain/creature/outdoorHuntable.ts +74 -0
- package/src/domain/creature/patrolPacks.ts +210 -0
- package/src/domain/creature/reputation.ts +967 -0
- package/src/domain/creature/unitFlags.ts +74 -0
- package/src/domain/creature/worldBosses.ts +393 -0
- package/src/domain/dungeonMusic.mjs +48 -0
- package/src/domain/economy/auction.ts +282 -0
- package/src/domain/economy/auctionBot.ts +1060 -0
- package/src/domain/economy/honorPrice.ts +100 -0
- package/src/domain/economy/mail.ts +958 -0
- package/src/domain/economy/money.ts +42 -0
- package/src/domain/economy/syntheticClassTrainers.ts +62 -0
- package/src/domain/economy/trading.ts +296 -0
- package/src/domain/economy/vendorSellAll.ts +252 -0
- package/src/domain/economy/vendorStock.ts +146 -0
- package/src/domain/items/ammo.ts +817 -0
- package/src/domain/items/bank.ts +162 -0
- package/src/domain/items/consumableFamily.ts +205 -0
- package/src/domain/items/craftUpgrades.ts +83 -0
- package/src/domain/items/createItem.ts +93 -0
- package/src/domain/items/durability.ts +581 -0
- package/src/domain/items/equipment.ts +441 -0
- package/src/domain/items/equipmentHelper.ts +767 -0
- package/src/domain/items/inventory.ts +561 -0
- package/src/domain/items/itemBonding.ts +93 -0
- package/src/domain/items/itemContainer.ts +245 -0
- package/src/domain/items/itemDisplayName.ts +33 -0
- package/src/domain/items/itemInstance.ts +181 -0
- package/src/domain/items/itemRandomEnchantOutcomes.ts +315 -0
- package/src/domain/items/itemRandomProps.ts +77 -0
- package/src/domain/items/itemRestrictions.ts +329 -0
- package/src/domain/items/itemTeaches.ts +82 -0
- package/src/domain/items/itemUseCooldown.ts +52 -0
- package/src/domain/items/itemUseTarget.ts +90 -0
- package/src/domain/items/junkCategories.ts +174 -0
- package/src/domain/items/junkFilter.ts +368 -0
- package/src/domain/items/keyring.ts +131 -0
- package/src/domain/items/loot.ts +221 -0
- package/src/domain/items/mounts.ts +190 -0
- package/src/domain/items/onUsePayload.ts +102 -0
- package/src/domain/items/pendingLoot.ts +107 -0
- package/src/domain/items/relic.ts +105 -0
- package/src/domain/items/rocketBoots.ts +36 -0
- package/src/domain/items/sortContainer.ts +139 -0
- package/src/domain/items/spareContainers.ts +71 -0
- package/src/domain/items/spellReagentIndex.ts +131 -0
- package/src/domain/items/staleConsumables.ts +66 -0
- package/src/domain/items/weaponSpender.ts +110 -0
- package/src/domain/listDungeonIds.mjs +28 -0
- package/src/domain/macro/compose.ts +239 -0
- package/src/domain/macro/conditions.ts +260 -0
- package/src/domain/macro/creatureTypes.ts +40 -0
- package/src/domain/macro/describe.ts +169 -0
- package/src/domain/macro/icon.ts +60 -0
- package/src/domain/macro/index.ts +20 -0
- package/src/domain/macro/normalize.ts +112 -0
- package/src/domain/macro/parse.ts +316 -0
- package/src/domain/macro/plan.ts +288 -0
- package/src/domain/macro/rotation.ts +590 -0
- package/src/domain/macro/rotationEnv.ts +173 -0
- package/src/domain/macro/slots.ts +101 -0
- package/src/domain/macro/snapshot.ts +114 -0
- package/src/domain/macro/templates.ts +446 -0
- package/src/domain/macro/types.ts +70 -0
- package/src/domain/macro/units.ts +93 -0
- package/src/domain/macro/weaponTypes.ts +83 -0
- package/src/domain/nav/cityTeleports.ts +10 -0
- package/src/domain/nav/mounting.ts +113 -0
- package/src/domain/nav/nav.ts +43 -0
- package/src/domain/nav/navCollision.ts +606 -0
- package/src/domain/nav/navOutdoors.ts +91 -0
- package/src/domain/nav/navRegistry.ts +179 -0
- package/src/domain/nav/navRoomGraph.ts +302 -0
- package/src/domain/nav/navTypes.ts +43 -0
- package/src/domain/nav/navmesh.ts +1715 -0
- package/src/domain/nav/speed.ts +200 -0
- package/src/domain/nav/storey.ts +10 -0
- package/src/domain/nav/travel.ts +725 -0
- package/src/domain/nav/travelTypes.ts +75 -0
- package/src/domain/pet/pet.ts +1150 -0
- package/src/domain/pet/petActionBar.ts +180 -0
- package/src/domain/pet/petSpellbook.ts +39 -0
- package/src/domain/pet/petStable.ts +81 -0
- package/src/domain/professions/autoDisenchant.ts +236 -0
- package/src/domain/professions/characterEnchants.ts +176 -0
- package/src/domain/professions/craftCooldown.ts +52 -0
- package/src/domain/professions/enchantApply.ts +62 -0
- package/src/domain/professions/enchantTargets.ts +114 -0
- package/src/domain/professions/enchanting.ts +236 -0
- package/src/domain/professions/gatherFilter.ts +149 -0
- package/src/domain/professions/gearEnchant.ts +137 -0
- package/src/domain/professions/heldTools.ts +29 -0
- package/src/domain/professions/learnAllRecipes.ts +146 -0
- package/src/domain/professions/miningHarvest.ts +69 -0
- package/src/domain/professions/partyDisenchant.ts +294 -0
- package/src/domain/professions/partyWork.ts +83 -0
- package/src/domain/professions/pickpocket.ts +172 -0
- package/src/domain/professions/poisons.ts +240 -0
- package/src/domain/professions/professions.ts +677 -0
- package/src/domain/professions/rareNodes.ts +45 -0
- package/src/domain/professions/skinLoot.ts +27 -0
- package/src/domain/professions/vendorReagents.ts +152 -0
- package/src/domain/professions/weaponImbueHand.ts +171 -0
- package/src/domain/questFarmFilter.mjs +42 -0
- package/src/domain/quests/doQuest.ts +1204 -0
- package/src/domain/quests/gossip.ts +308 -0
- package/src/domain/quests/gossipTalk.ts +298 -0
- package/src/domain/quests/interpreter/canComplete.ts +150 -0
- package/src/domain/quests/interpreter/claim.ts +137 -0
- package/src/domain/quests/interpreter/dbscript/handlers.ts +1371 -0
- package/src/domain/quests/interpreter/dbscript/handlersBatch.ts +1251 -0
- package/src/domain/quests/interpreter/dbscript/hooks.ts +331 -0
- package/src/domain/quests/interpreter/dbscript/index.ts +212 -0
- package/src/domain/quests/interpreter/dbscript/opcodes.ts +257 -0
- package/src/domain/quests/interpreter/dbscript/registry.ts +1194 -0
- package/src/domain/quests/interpreter/dbscript/registryAreatriggerScripts.ts +539 -0
- package/src/domain/quests/interpreter/dbscript/registryAshenvale.ts +553 -0
- package/src/domain/quests/interpreter/dbscript/registryAzshara.ts +272 -0
- package/src/domain/quests/interpreter/dbscript/registryDarkshore.ts +637 -0
- package/src/domain/quests/interpreter/dbscript/registryDesolace.ts +501 -0
- package/src/domain/quests/interpreter/dbscript/registryDustwallowMarsh.ts +735 -0
- package/src/domain/quests/interpreter/dbscript/registryFelwood.ts +493 -0
- package/src/domain/quests/interpreter/dbscript/registryFeralas.ts +485 -0
- package/src/domain/quests/interpreter/dbscript/registryGoScripts.ts +203 -0
- package/src/domain/quests/interpreter/dbscript/registryMoonglade.ts +288 -0
- package/src/domain/quests/interpreter/dbscript/registryNpcProfessions.ts +230 -0
- package/src/domain/quests/interpreter/dbscript/registryNpcsSpecial.ts +545 -0
- package/src/domain/quests/interpreter/dbscript/registryRazorfenDowns.ts +293 -0
- package/src/domain/quests/interpreter/dbscript/registryRazorfenKraul.ts +221 -0
- package/src/domain/quests/interpreter/dbscript/registryRedridge.ts +112 -0
- package/src/domain/quests/interpreter/dbscript/registrySearingGorge.ts +125 -0
- package/src/domain/quests/interpreter/dbscript/registrySiblings.ts +51 -0
- package/src/domain/quests/interpreter/dbscript/registrySilverpineForest.ts +172 -0
- package/src/domain/quests/interpreter/dbscript/registryStonetalonMountains.ts +180 -0
- package/src/domain/quests/interpreter/dbscript/registryStormwindCity.ts +369 -0
- package/src/domain/quests/interpreter/dbscript/registryStranglethornVale.ts +115 -0
- package/src/domain/quests/interpreter/dbscript/registryStratholme.ts +146 -0
- package/src/domain/quests/interpreter/dbscript/registrySunkenTemple.ts +149 -0
- package/src/domain/quests/interpreter/dbscript/registrySwampOfSorrows.ts +163 -0
- package/src/domain/quests/interpreter/dbscript/registryTanaris.ts +318 -0
- package/src/domain/quests/interpreter/dbscript/registryTeldrassil.ts +120 -0
- package/src/domain/quests/interpreter/dbscript/registryTheBarrens.ts +603 -0
- package/src/domain/quests/interpreter/dbscript/registryThousandNeedles.ts +499 -0
- package/src/domain/quests/interpreter/dbscript/registryTirisfalGlades.ts +222 -0
- package/src/domain/quests/interpreter/dbscript/registryUngoroCrater.ts +291 -0
- package/src/domain/quests/interpreter/dbscript/registryWarEffort.ts +773 -0
- package/src/domain/quests/interpreter/dbscript/registryWesternPlaguelands.ts +206 -0
- package/src/domain/quests/interpreter/dbscript/registryWestfall.ts +190 -0
- package/src/domain/quests/interpreter/dbscript/registryWetlands.ts +186 -0
- package/src/domain/quests/interpreter/dbscript/registryWinterspring.ts +235 -0
- package/src/domain/quests/interpreter/dbscript/types.ts +817 -0
- package/src/domain/quests/interpreter/dbscript/vm.ts +171 -0
- package/src/domain/quests/interpreter/diff.ts +349 -0
- package/src/domain/quests/interpreter/fixtures/oracle-questlog-qi2.json +2417 -0
- package/src/domain/quests/interpreter/flags.ts +101 -0
- package/src/domain/quests/interpreter/index.ts +113 -0
- package/src/domain/quests/interpreter/live.ts +178 -0
- package/src/domain/quests/interpreter/types.ts +81 -0
- package/src/domain/quests/interpreter/updaters.ts +412 -0
- package/src/domain/quests/questCorpseUse.ts +155 -0
- package/src/domain/quests/questDialog.ts +43 -0
- package/src/domain/quests/questFight.ts +34 -0
- package/src/domain/quests/questObjectiveLabel.ts +39 -0
- package/src/domain/quests/questRewardChoice.ts +85 -0
- package/src/domain/quests/questScriptCredit.ts +26 -0
- package/src/domain/quests/questScriptItemUse.ts +74 -0
- package/src/domain/quests/questTameRods.ts +79 -0
- package/src/domain/quests/questlog.ts +1545 -0
- package/src/domain/quests/talkDeliver.ts +170 -0
- package/src/domain/raid/globalActionBar.ts +195 -0
- package/src/domain/raid/instanceRoster.ts +194 -0
- package/src/domain/raid/partySummon.ts +198 -0
- package/src/domain/raid/partyVault.ts +320 -0
- package/src/domain/raid/raidBossAbilities.ts +268 -0
- package/src/domain/raid/raidComp.ts +335 -0
- package/src/domain/raid/raidCustomRows.ts +453 -0
- package/src/domain/raid/raidEncounterGear.ts +298 -0
- package/src/domain/raid/raidEncounterTactics.ts +412 -0
- package/src/domain/raid/raidPlan.ts +509 -0
- package/src/domain/raid/raidPlanEdits.ts +98 -0
- package/src/domain/raid/raidPlanPacks.ts +480 -0
- package/src/domain/raid/raidRecovery.ts +183 -0
- package/src/domain/raid/raidRosterTemplate.ts +138 -0
- package/src/domain/rotation/auraSide.ts +68 -0
- package/src/domain/rotation/build.ts +3031 -0
- package/src/domain/rotation/classes/druid.ts +58 -0
- package/src/domain/rotation/classes/hooks.ts +59 -0
- package/src/domain/rotation/classes/hunter.ts +156 -0
- package/src/domain/rotation/classes/index.ts +104 -0
- package/src/domain/rotation/classes/mage.ts +99 -0
- package/src/domain/rotation/classes/paladin.ts +296 -0
- package/src/domain/rotation/classes/priest.ts +94 -0
- package/src/domain/rotation/classes/pullOpener.ts +92 -0
- package/src/domain/rotation/classes/rogue.ts +69 -0
- package/src/domain/rotation/classes/shaman.ts +29 -0
- package/src/domain/rotation/classes/supportSpec.ts +94 -0
- package/src/domain/rotation/classes/warlock.ts +81 -0
- package/src/domain/rotation/classes/warrior.ts +402 -0
- package/src/domain/rotation/classify.ts +1381 -0
- package/src/domain/rotation/condMetricPicker.ts +379 -0
- package/src/domain/rotation/consumables.ts +242 -0
- package/src/domain/rotation/evaluate.ts +1115 -0
- package/src/domain/rotation/finisherLadder.ts +145 -0
- package/src/domain/rotation/invent.ts +938 -0
- package/src/domain/rotation/killOrder.ts +30 -0
- package/src/domain/rotation/openerForm.ts +22 -0
- package/src/domain/rotation/paladinBuffs.ts +568 -0
- package/src/domain/rotation/prebuff.ts +283 -0
- package/src/domain/rotation/raidRowTuning.ts +34 -0
- package/src/domain/rotation/rotation.ts +11 -0
- package/src/domain/rotation/rotationProfiles.ts +180 -0
- package/src/domain/rotation/rotationSuggestions.ts +851 -0
- package/src/domain/rotation/ruleAfterChain.ts +49 -0
- package/src/domain/rotation/spec.ts +70 -0
- package/src/domain/rotation/spellPicker.ts +140 -0
- package/src/domain/rotation/stand.ts +143 -0
- package/src/domain/rotation/types.ts +718 -0
- package/src/domain/run/battlegroundRun.ts +111 -0
- package/src/domain/run/canEnterContext.ts +32 -0
- package/src/domain/run/death.ts +561 -0
- package/src/domain/run/deathRecovery.ts +289 -0
- package/src/domain/run/duel.ts +141 -0
- package/src/domain/run/dungeonRun.ts +344 -0
- package/src/domain/run/floorRest.ts +159 -0
- package/src/domain/run/honor.ts +557 -0
- package/src/domain/run/honorWeek.ts +180 -0
- package/src/domain/run/whenMap.ts +977 -0
- package/src/domain/scriptdev/arathiHighlands.ts +445 -0
- package/src/domain/scriptdev/ashenvale.ts +635 -0
- package/src/domain/scriptdev/ashenvaleHelpers.ts +1215 -0
- package/src/domain/scriptdev/azshara.ts +892 -0
- package/src/domain/scriptdev/blackfathomDeeps.ts +212 -0
- package/src/domain/scriptdev/blackrockDepths.ts +1434 -0
- package/src/domain/scriptdev/blackrockSpire.ts +730 -0
- package/src/domain/scriptdev/blackwingLair.ts +521 -0
- package/src/domain/scriptdev/burningSteppes.ts +62 -0
- package/src/domain/scriptdev/classFilter.ts +43 -0
- package/src/domain/scriptdev/darkshore.ts +817 -0
- package/src/domain/scriptdev/darkshoreHelpers.ts +853 -0
- package/src/domain/scriptdev/deadmines.ts +331 -0
- package/src/domain/scriptdev/desolace.ts +1239 -0
- package/src/domain/scriptdev/direMaul.ts +859 -0
- package/src/domain/scriptdev/durotar.ts +532 -0
- package/src/domain/scriptdev/dustwallowMarsh.ts +758 -0
- package/src/domain/scriptdev/dustwallowMarshHelpers.ts +994 -0
- package/src/domain/scriptdev/easternPlaguelands.ts +21 -0
- package/src/domain/scriptdev/felwood.ts +1055 -0
- package/src/domain/scriptdev/feralas.ts +571 -0
- package/src/domain/scriptdev/feralasHelpers.ts +838 -0
- package/src/domain/scriptdev/gnomeregan.ts +466 -0
- package/src/domain/scriptdev/guards.ts +616 -0
- package/src/domain/scriptdev/hinterlands.ts +693 -0
- package/src/domain/scriptdev/host.ts +1134 -0
- package/src/domain/scriptdev/index.ts +123 -0
- package/src/domain/scriptdev/instanceRaids.ts +136 -0
- package/src/domain/scriptdev/itemScripts.ts +37 -0
- package/src/domain/scriptdev/lochModan.ts +283 -0
- package/src/domain/scriptdev/maraudon.ts +232 -0
- package/src/domain/scriptdev/moltenCore.ts +831 -0
- package/src/domain/scriptdev/moonglade.ts +659 -0
- package/src/domain/scriptdev/moongladeHelpers.ts +933 -0
- package/src/domain/scriptdev/naxxramas.ts +577 -0
- package/src/domain/scriptdev/naxxramasConstruct.ts +364 -0
- package/src/domain/scriptdev/naxxramasFrostwyrm.ts +378 -0
- package/src/domain/scriptdev/naxxramasMilitary.ts +276 -0
- package/src/domain/scriptdev/npcProfessions.ts +1025 -0
- package/src/domain/scriptdev/npcsSpecial.ts +1379 -0
- package/src/domain/scriptdev/onyxiasLair.ts +313 -0
- package/src/domain/scriptdev/orgrimmar.ts +147 -0
- package/src/domain/scriptdev/professionSpecConsumer.ts +481 -0
- package/src/domain/scriptdev/razorfenDowns.ts +468 -0
- package/src/domain/scriptdev/razorfenKraul.ts +410 -0
- package/src/domain/scriptdev/redridgeMountains.ts +163 -0
- package/src/domain/scriptdev/registry.ts +945 -0
- package/src/domain/scriptdev/ruinsOfAhnqiraj.ts +356 -0
- package/src/domain/scriptdev/scarletMonastery.ts +713 -0
- package/src/domain/scriptdev/scholomance.ts +682 -0
- package/src/domain/scriptdev/searingGorge.ts +295 -0
- package/src/domain/scriptdev/shadowfangKeep.ts +181 -0
- package/src/domain/scriptdev/silithus.ts +71 -0
- package/src/domain/scriptdev/silverpineForest.ts +662 -0
- package/src/domain/scriptdev/stonetalonMountains.ts +493 -0
- package/src/domain/scriptdev/stormwindCity.ts +1192 -0
- package/src/domain/scriptdev/stranglethornVale.ts +402 -0
- package/src/domain/scriptdev/stratholme.ts +650 -0
- package/src/domain/scriptdev/stratholmeScripts.ts +505 -0
- package/src/domain/scriptdev/sunkenTemple.ts +736 -0
- package/src/domain/scriptdev/swampOfSorrows.ts +22 -0
- package/src/domain/scriptdev/tanaris.ts +391 -0
- package/src/domain/scriptdev/tanarisHelpers.ts +661 -0
- package/src/domain/scriptdev/teldrassil.ts +143 -0
- package/src/domain/scriptdev/templeOfAhnqiraj.ts +1013 -0
- package/src/domain/scriptdev/theBarrens.ts +709 -0
- package/src/domain/scriptdev/theBarrensHelpers.ts +1026 -0
- package/src/domain/scriptdev/thousandNeedles.ts +426 -0
- package/src/domain/scriptdev/thousandNeedlesHelpers.ts +566 -0
- package/src/domain/scriptdev/tirisfalGlades.ts +533 -0
- package/src/domain/scriptdev/types.ts +1026 -0
- package/src/domain/scriptdev/uldaman.ts +437 -0
- package/src/domain/scriptdev/ungoroCrater.ts +549 -0
- package/src/domain/scriptdev/ungoroCraterHelpers.ts +1006 -0
- package/src/domain/scriptdev/wailingCaverns.ts +700 -0
- package/src/domain/scriptdev/warEffort.ts +371 -0
- package/src/domain/scriptdev/westernPlaguelands.ts +952 -0
- package/src/domain/scriptdev/westfall.ts +767 -0
- package/src/domain/scriptdev/wetlands.ts +660 -0
- package/src/domain/scriptdev/winterspring.ts +482 -0
- package/src/domain/scriptdev/winterspringHelpers.ts +801 -0
- package/src/domain/scriptdev/worldBosses.ts +605 -0
- package/src/domain/scriptdev/zulfarrak.ts +478 -0
- package/src/domain/scriptdev/zulgurub.ts +1152 -0
- package/src/domain/sim/dummySim.ts +408 -0
- package/src/domain/sim/targetDummy.ts +29 -0
- package/src/domain/social/guild.ts +148 -0
- package/src/domain/spells/areaAuraMembership.ts +93 -0
- package/src/domain/spells/breakableStunCc.ts +166 -0
- package/src/domain/spells/cancelAura.ts +80 -0
- package/src/domain/spells/diminishing.ts +167 -0
- package/src/domain/spells/dispel.ts +149 -0
- package/src/domain/spells/dispelMechanic.ts +105 -0
- package/src/domain/spells/equipSpells.ts +247 -0
- package/src/domain/spells/learnOnTargets.ts +260 -0
- package/src/domain/spells/mergeSchoolImmunityFromRaw.ts +66 -0
- package/src/domain/spells/resurrectSpell.ts +124 -0
- package/src/domain/spells/spellPushback.ts +39 -0
- package/src/domain/spells/spellReflect.ts +43 -0
- package/src/domain/spells/stealth.ts +183 -0
- package/src/domain/spells/totem.ts +394 -0
- package/src/domain/spells/worldCast.ts +187 -0
- package/src/domain/world/areaTriggerLocks.ts +177 -0
- package/src/domain/world/dungeonMusic.ts +108 -0
- package/src/domain/world/gameEvents.ts +119 -0
- package/src/domain/world/gameObjects.ts +1030 -0
- package/src/domain/world/goEventFire.ts +234 -0
- package/src/domain/world/goMoTransport.ts +74 -0
- package/src/domain/world/goTransport.ts +81 -0
- package/src/domain/world/hazards.ts +459 -0
- package/src/domain/world/openLock.ts +398 -0
- package/src/domain/world/pools.ts +470 -0
- package/src/domain/world/restArea.ts +37 -0
- package/src/domain/world/weather.ts +201 -0
- package/src/domain/zoneAmbience.mjs +87 -0
- package/src/domain/zoneMusic.mjs +62 -0
- package/src/domain/zoneMusicQueue.mjs +7 -0
- package/src/engine/combat/activeSeal.ts +39 -0
- package/src/engine/combat/aiTargetResolve.ts +64 -0
- package/src/engine/combat/aoeCast.ts +289 -0
- package/src/engine/combat/applyConsumeAura.ts +74 -0
- package/src/engine/combat/applyDemonicSacrifice.ts +70 -0
- package/src/engine/combat/applyDispelMechanic.ts +30 -0
- package/src/engine/combat/applyHealEffect.ts +301 -0
- package/src/engine/combat/applySchoolAbsorb.ts +114 -0
- package/src/engine/combat/applySpell.ts +3169 -0
- package/src/engine/combat/applySpellLate.ts +2026 -0
- package/src/engine/combat/applySpellLateBag.ts +144 -0
- package/src/engine/combat/areaAura.ts +283 -0
- package/src/engine/combat/attackTable.ts +508 -0
- package/src/engine/combat/battleRez.ts +87 -0
- package/src/engine/combat/castAction.ts +36 -0
- package/src/engine/combat/castTargetGates.ts +75 -0
- package/src/engine/combat/castableKit.ts +68 -0
- package/src/engine/combat/ccMirrorUpgrade.ts +39 -0
- package/src/engine/combat/chainFan.ts +210 -0
- package/src/engine/combat/channel.ts +423 -0
- package/src/engine/combat/chaseCloseness.ts +95 -0
- package/src/engine/combat/cmangosCastGates.ts +129 -0
- package/src/engine/combat/combatFlag.ts +77 -0
- package/src/engine/combat/combatGeometry.ts +1329 -0
- package/src/engine/combat/combustion.ts +68 -0
- package/src/engine/combat/context.ts +730 -0
- package/src/engine/combat/createSession.ts +1165 -0
- package/src/engine/combat/creatureLeash.ts +38 -0
- package/src/engine/combat/crowdControl.ts +1259 -0
- package/src/engine/combat/dispelDuty.ts +54 -0
- package/src/engine/combat/dispelReflect.ts +609 -0
- package/src/engine/combat/displaceFloor.ts +113 -0
- package/src/engine/combat/duelBounds.ts +47 -0
- package/src/engine/combat/equippedItemGate.ts +297 -0
- package/src/engine/combat/evadeLifecycle.ts +63 -0
- package/src/engine/combat/eventAi.ts +2220 -0
- package/src/engine/combat/facing.ts +74 -0
- package/src/engine/combat/fearFlee.ts +137 -0
- package/src/engine/combat/fighterFactory.ts +1144 -0
- package/src/engine/combat/forceDespawnAction.ts +51 -0
- package/src/engine/combat/formulas.ts +807 -0
- package/src/engine/combat/friendlyFan.ts +55 -0
- package/src/engine/combat/groundPoolTick.ts +210 -0
- package/src/engine/combat/hazards.ts +114 -0
- package/src/engine/combat/healTargets.ts +226 -0
- package/src/engine/combat/heartbeatResist.ts +49 -0
- package/src/engine/combat/hostileItemUse.ts +75 -0
- package/src/engine/combat/interruptAuras.ts +66 -0
- package/src/engine/combat/itemRescue.ts +49 -0
- package/src/engine/combat/itemUse.ts +260 -0
- package/src/engine/combat/kitById.ts +23 -0
- package/src/engine/combat/knockBack.ts +201 -0
- package/src/engine/combat/leap.ts +89 -0
- package/src/engine/combat/mitigation.ts +587 -0
- package/src/engine/combat/modTaunt.ts +43 -0
- package/src/engine/combat/motionClaim.ts +268 -0
- package/src/engine/combat/motionGate.ts +150 -0
- package/src/engine/combat/motionGenerator.ts +27 -0
- package/src/engine/combat/navSight.ts +138 -0
- package/src/engine/combat/perfSink.ts +28 -0
- package/src/engine/combat/periodicDue.ts +55 -0
- package/src/engine/combat/petAuras.ts +86 -0
- package/src/engine/combat/pickAction.ts +3449 -0
- package/src/engine/combat/powerForm.ts +735 -0
- package/src/engine/combat/powerPools.ts +49 -0
- package/src/engine/combat/predicates.ts +538 -0
- package/src/engine/combat/procsDamage.ts +1823 -0
- package/src/engine/combat/reactiveGates.ts +135 -0
- package/src/engine/combat/roles.ts +389 -0
- package/src/engine/combat/rotationSubject.ts +78 -0
- package/src/engine/combat/ruleAfter.ts +106 -0
- package/src/engine/combat/seedDisplayAuras.ts +203 -0
- package/src/engine/combat/seedOocEffects.ts +317 -0
- package/src/engine/combat/seedSchoolImmunities.ts +45 -0
- package/src/engine/combat/session-types.ts +458 -0
- package/src/engine/combat/sessionApi.ts +1785 -0
- package/src/engine/combat/sessionCharge.ts +124 -0
- package/src/engine/combat/sessionChase.ts +869 -0
- package/src/engine/combat/sessionDeaths.ts +469 -0
- package/src/engine/combat/sessionFocus.ts +506 -0
- package/src/engine/combat/sessionFollow.ts +167 -0
- package/src/engine/combat/sessionGeometry.ts +188 -0
- package/src/engine/combat/sessionHome.ts +75 -0
- package/src/engine/combat/sessionInject.ts +719 -0
- package/src/engine/combat/sessionMechanicMove.ts +555 -0
- package/src/engine/combat/sessionMotion.ts +1553 -0
- package/src/engine/combat/sessionMotionKit.ts +247 -0
- package/src/engine/combat/sessionMoveIntent.ts +49 -0
- package/src/engine/combat/sessionMoveOrder.ts +200 -0
- package/src/engine/combat/sessionPathTo.ts +657 -0
- package/src/engine/combat/sessionPools.ts +688 -0
- package/src/engine/combat/sessionSteer.ts +144 -0
- package/src/engine/combat/sessionStepBack.ts +151 -0
- package/src/engine/combat/sessionStepWrap.ts +116 -0
- package/src/engine/combat/sessionView.ts +442 -0
- package/src/engine/combat/simEffects.ts +32 -0
- package/src/engine/combat/simFacade.ts +620 -0
- package/src/engine/combat/simPump.ts +264 -0
- package/src/engine/combat/simSnapshot.ts +165 -0
- package/src/engine/combat/simThreadPool.ts +35 -0
- package/src/engine/combat/skeramImages.ts +57 -0
- package/src/engine/combat/slowUpdate.ts +178 -0
- package/src/engine/combat/snapToFloor.ts +42 -0
- package/src/engine/combat/speedAura.ts +29 -0
- package/src/engine/combat/spellReagents.ts +71 -0
- package/src/engine/combat/stealthDetect.ts +93 -0
- package/src/engine/combat/stepBackMotion.ts +171 -0
- package/src/engine/combat/stepSession.ts +3102 -0
- package/src/engine/combat/summonCast.ts +243 -0
- package/src/engine/combat/thaddiusPolarity.ts +97 -0
- package/src/engine/combat/threat.ts +388 -0
- package/src/engine/combat/threatOps.ts +272 -0
- package/src/engine/combat/tickWorkerBody.ts +211 -0
- package/src/engine/combat/totemAuras.ts +122 -0
- package/src/engine/combat/triggerPayload.ts +42 -0
- package/src/engine/combat/triggeredCompanion.ts +63 -0
- package/src/engine/combat/types.ts +1897 -0
- package/src/engine/combat/useObjectTrap.ts +140 -0
- package/src/engine/combat/walkSurface.ts +54 -0
- package/src/engine/combat/weapon.ts +1248 -0
- package/src/engine/combat/worldFrame.ts +43 -0
- package/src/engine/combat.solo-golden.json +2242 -0
- package/src/engine/combat.ts +84 -0
- package/src/engine/core/detMath.ts +289 -0
- package/src/engine/core/events.ts +357 -0
- package/src/engine/core/rng.ts +36 -0
- package/src/engine/game/actionBars.ts +124 -0
- package/src/engine/game/activateObjectFromSpell.ts +320 -0
- package/src/engine/game/applyActiveObjectFromDbScript.ts +98 -0
- package/src/engine/game/applyDeathAndSpellScripts.ts +268 -0
- package/src/engine/game/applyDoorFromDbScript.ts +388 -0
- package/src/engine/game/applyEscortFromDbScript.ts +290 -0
- package/src/engine/game/applyGoSpellcasterCast.ts +40 -0
- package/src/engine/game/applyHoverFromDbScript.ts +110 -0
- package/src/engine/game/applyOocResurrect.ts +71 -0
- package/src/engine/game/applySpellDurability.ts +115 -0
- package/src/engine/game/applyTailFromDbScript.ts +478 -0
- package/src/engine/game/applyTerminateFromDbScript.ts +95 -0
- package/src/engine/game/auctionMail.ts +269 -0
- package/src/engine/game/bagOverflow.ts +130 -0
- package/src/engine/game/bank.ts +110 -0
- package/src/engine/game/barAutofill.ts +26 -0
- package/src/engine/game/battleground.ts +369 -0
- package/src/engine/game/combatEnemySpec.ts +216 -0
- package/src/engine/game/combatObjectTraps.ts +167 -0
- package/src/engine/game/combatSeed.ts +320 -0
- package/src/engine/game/combatSession.ts +3241 -0
- package/src/engine/game/combatSessionClock.ts +62 -0
- package/src/engine/game/companionQuestActions.ts +242 -0
- package/src/engine/game/companionTraining.ts +40 -0
- package/src/engine/game/consumables.ts +624 -0
- package/src/engine/game/context.ts +53 -0
- package/src/engine/game/createHealthstone.ts +45 -0
- package/src/engine/game/deathFlow.ts +127 -0
- package/src/engine/game/devGrant.ts +117 -0
- package/src/engine/game/dynamicObject.ts +365 -0
- package/src/engine/game/enchantItem.ts +177 -0
- package/src/engine/game/equipmentHelper.ts +806 -0
- package/src/engine/game/factionState.ts +108 -0
- package/src/engine/game/garmentsOoc.ts +69 -0
- package/src/engine/game/globalActionBar.ts +179 -0
- package/src/engine/game/goInteractQuestCredit.ts +51 -0
- package/src/engine/game/goMoTransportUse.ts +61 -0
- package/src/engine/game/goTransportUse.ts +60 -0
- package/src/engine/game/healthstoneUpkeep.ts +59 -0
- package/src/engine/game/killSkin.ts +124 -0
- package/src/engine/game/learnFromItem.ts +118 -0
- package/src/engine/game/loadReconcile.ts +551 -0
- package/src/engine/game/loot.ts +294 -0
- package/src/engine/game/macros.ts +125 -0
- package/src/engine/game/mail.ts +293 -0
- package/src/engine/game/meetingStoneQueue.ts +75 -0
- package/src/engine/game/meetingStoneUse.ts +54 -0
- package/src/engine/game/newGameState.ts +363 -0
- package/src/engine/game/oocAuraInterrupt.ts +92 -0
- package/src/engine/game/oocCast.ts +241 -0
- package/src/engine/game/oocCastEffects.ts +803 -0
- package/src/engine/game/oocCastMethods.ts +1077 -0
- package/src/engine/game/openLockFromSpell.ts +174 -0
- package/src/engine/game/outdoorPvp.ts +187 -0
- package/src/engine/game/outfitter.ts +644 -0
- package/src/engine/game/paladinBoard.ts +393 -0
- package/src/engine/game/partyAuras.ts +121 -0
- package/src/engine/game/persistCombatAuras.ts +91 -0
- package/src/engine/game/pets.ts +459 -0
- package/src/engine/game/prePullHealthstones.ts +154 -0
- package/src/engine/game/professions.ts +2227 -0
- package/src/engine/game/pruneStaleRuntime.ts +39 -0
- package/src/engine/game/questFightCredit.ts +36 -0
- package/src/engine/game/questItemOnCorpse.ts +166 -0
- package/src/engine/game/questItemOnCreature.ts +103 -0
- package/src/engine/game/questLog.ts +879 -0
- package/src/engine/game/questSpellComplete.ts +21 -0
- package/src/engine/game/questTurnIn.ts +598 -0
- package/src/engine/game/raidParty.ts +32 -0
- package/src/engine/game/reagentBuyIn.ts +39 -0
- package/src/engine/game/regenRest.ts +186 -0
- package/src/engine/game/rotation.ts +425 -0
- package/src/engine/game/runFullDungeon.ts +611 -0
- package/src/engine/game/runSupply.ts +80 -0
- package/src/engine/game/sanitizeDriftedItems.ts +135 -0
- package/src/engine/game/scriptDevGoUse.ts +397 -0
- package/src/engine/game/scriptDevInnkeeper.ts +150 -0
- package/src/engine/game/scriptDevProfessionSpec.ts +99 -0
- package/src/engine/game/sendEventFromSpell.ts +87 -0
- package/src/engine/game/sortContainers.ts +85 -0
- package/src/engine/game/spellReputation.ts +69 -0
- package/src/engine/game/squad/squadDungeonSeating.ts +197 -0
- package/src/engine/game/squadDungeon.ts +4222 -0
- package/src/engine/game/squadQuestShare.ts +260 -0
- package/src/engine/game/summonObjectSlot.ts +69 -0
- package/src/engine/game/summonObjectWild.ts +73 -0
- package/src/engine/game/summoningRitualUse.ts +141 -0
- package/src/engine/game/talents.ts +356 -0
- package/src/engine/game/tamingRodUse.ts +66 -0
- package/src/engine/game/tempSpawnCreature.ts +136 -0
- package/src/engine/game/tickDungeonPatrolScripts.ts +205 -0
- package/src/engine/game/tickTimedQuests.ts +28 -0
- package/src/engine/game/trade.ts +586 -0
- package/src/engine/game/travel.ts +1155 -0
- package/src/engine/game/types.ts +1855 -0
- package/src/engine/game/vendorOffers.ts +244 -0
- package/src/engine/game/weaponImbueItem.ts +281 -0
- package/src/engine/game/worldSession.ts +183 -0
- package/src/engine/game/worldState.ts +166 -0
- package/src/engine/game.ts +968 -0
- package/src/engine/spells/GAP.md +67 -0
- package/src/engine/spells/absorbs.ts +376 -0
- package/src/engine/spells/addTargetTrigger.ts +53 -0
- package/src/engine/spells/auraPerSecondCost.ts +50 -0
- package/src/engine/spells/auraStacking.ts +497 -0
- package/src/engine/spells/cmangos-special-cases-combustion.ts +13 -0
- package/src/engine/spells/cmangos-special-cases-s18.ts +445 -0
- package/src/engine/spells/cmangos-special-cases-s19.ts +324 -0
- package/src/engine/spells/cmangos-special-cases-s20.ts +385 -0
- package/src/engine/spells/cmangos-special-cases-s21.ts +116 -0
- package/src/engine/spells/cmangos-special-cases-s22.ts +24 -0
- package/src/engine/spells/cmangos-special-cases-s23.ts +52 -0
- package/src/engine/spells/cmangos-special-cases.ts +1570 -0
- package/src/engine/spells/conditions.ts +718 -0
- package/src/engine/spells/effects.ts +203 -0
- package/src/engine/spells/fixtures/oracle-effects-lock9a.json +1 -0
- package/src/engine/spells/immunity.ts +942 -0
- package/src/engine/spells/intentEffects.ts +351 -0
- package/src/engine/spells/interpreter/GAPS.md +21 -0
- package/src/engine/spells/interpreter/amount.ts +109 -0
- package/src/engine/spells/interpreter/class-scripts-kit.ts +478 -0
- package/src/engine/spells/interpreter/diff.ts +246 -0
- package/src/engine/spells/interpreter/duration.ts +90 -0
- package/src/engine/spells/interpreter/form-attrs.json +1 -0
- package/src/engine/spells/interpreter/form-attrs.ts +95 -0
- package/src/engine/spells/interpreter/handlers/aura-remove.ts +113 -0
- package/src/engine/spells/interpreter/handlers/auras-control.ts +1547 -0
- package/src/engine/spells/interpreter/handlers/auras-core.ts +1583 -0
- package/src/engine/spells/interpreter/handlers/auras-misc.ts +1774 -0
- package/src/engine/spells/interpreter/handlers/effect-disenchant.ts +17 -0
- package/src/engine/spells/interpreter/handlers/effect-skinning.ts +20 -0
- package/src/engine/spells/interpreter/handlers/effects-action-utility.ts +1455 -0
- package/src/engine/spells/interpreter/handlers/effects-core.ts +432 -0
- package/src/engine/spells/interpreter/handlers/effects-remainder.ts +105 -0
- package/src/engine/spells/interpreter/handlers/effects-summon-item.ts +687 -0
- package/src/engine/spells/interpreter/handlers/mod-increase-energy-percent.ts +36 -0
- package/src/engine/spells/interpreter/handlers/mod-increase-energy.ts +36 -0
- package/src/engine/spells/interpreter/handlers/periodic-trigger-zero.ts +327 -0
- package/src/engine/spells/interpreter/handlers/procs.ts +131 -0
- package/src/engine/spells/interpreter/handlers/registry.ts +975 -0
- package/src/engine/spells/interpreter/handlers/shared.ts +667 -0
- package/src/engine/spells/interpreter/handlers.ts +9 -0
- package/src/engine/spells/interpreter/hunter-kit.ts +202 -0
- package/src/engine/spells/interpreter/index.ts +525 -0
- package/src/engine/spells/interpreter/item-triggers.ts +1121 -0
- package/src/engine/spells/interpreter/lift-consume-energy.ts +37 -0
- package/src/engine/spells/interpreter/mage-portals.ts +37 -0
- package/src/engine/spells/interpreter/materialize-charm.ts +88 -0
- package/src/engine/spells/interpreter/materialize-dispel-mechanic.ts +25 -0
- package/src/engine/spells/interpreter/materialize-rating-buffs.ts +81 -0
- package/src/engine/spells/interpreter/materialize-speed.ts +109 -0
- package/src/engine/spells/interpreter/materialize-spell-magnet.ts +22 -0
- package/src/engine/spells/interpreter/opcodes.ts +789 -0
- package/src/engine/spells/interpreter/paladin-kit.ts +95 -0
- package/src/engine/spells/interpreter/paladin-seals.ts +499 -0
- package/src/engine/spells/interpreter/priest-warlock-kit.ts +135 -0
- package/src/engine/spells/interpreter/proc.ts +443 -0
- package/src/engine/spells/interpreter/registry/helpers.ts +16 -0
- package/src/engine/spells/interpreter/registry/index.ts +216 -0
- package/src/engine/spells/interpreter/registry/packs/class-abilities.ts +304 -0
- package/src/engine/spells/interpreter/registry/packs/dire-maul.ts +44 -0
- package/src/engine/spells/interpreter/registry/packs/dummy-script-tail.ts +1065 -0
- package/src/engine/spells/interpreter/registry/packs/ex-wave-m.ts +70 -0
- package/src/engine/spells/interpreter/registry/packs/life-tap.ts +107 -0
- package/src/engine/spells/interpreter/registry/packs/paladin.ts +246 -0
- package/src/engine/spells/interpreter/registry/packs/raid-script-summons.ts +26 -0
- package/src/engine/spells/interpreter/registry/packs/raid-threat-wipe.ts +22 -0
- package/src/engine/spells/interpreter/registry/packs/shaman.ts +214 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-a.ts +269 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-b.ts +370 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-c.ts +172 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-hunter.ts +60 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-warrior.ts +31 -0
- package/src/engine/spells/interpreter/registry/packs/si4-script-generic-a.ts +114 -0
- package/src/engine/spells/interpreter/registry/packs/si4-script-generic-b.ts +249 -0
- package/src/engine/spells/interpreter/registry/packs/si4-script-generic-c.ts +233 -0
- package/src/engine/spells/interpreter/registry/types.ts +48 -0
- package/src/engine/spells/interpreter/talent-scripted.ts +329 -0
- package/src/engine/spells/interpreter/targeting.ts +90 -0
- package/src/engine/spells/interpreter/types-intent.ts +1501 -0
- package/src/engine/spells/interpreter/types.ts +419 -0
- package/src/engine/spells/interpreter/warrior-druid-kit.ts +314 -0
- package/src/engine/spells/interpreter/weapon-imbue-kit.ts +384 -0
- package/src/engine/spells/interrupt.ts +217 -0
- package/src/engine/spells/meleeAttackReset.ts +60 -0
- package/src/engine/spells/oocTriggerSpell.ts +49 -0
- package/src/engine/spells/overrideClassScripts.ts +319 -0
- package/src/engine/spells/parryHaste.ts +20 -0
- package/src/engine/spells/procs.ts +300 -0
- package/src/engine/spells/recovery.ts +203 -0
- package/src/engine/spells/scriptTargetDest.ts +76 -0
- package/src/engine/spells/scriptTargets.ts +85 -0
- package/src/engine/spells/singleTargetAura.ts +82 -0
- package/src/engine/spells/specialCasePayloads.ts +57 -0
- package/src/engine/spells/spellAttributes.ts +249 -0
- package/src/engine/spells/spellBonus.ts +143 -0
- package/src/engine/spells/spellModIntents.ts +419 -0
- package/src/engine/spells/spellMods.ts +283 -0
- package/src/engine/spells/spellRadius.ts +15 -0
- package/src/engine/spells/trapObjects.ts +26 -0
- package/src/json.d.ts +7 -0
- package/src/persistence/memoryRepository.ts +23 -0
- package/src/persistence/saveBackups.ts +162 -0
- package/src/persistence/saveFile.ts +68 -0
- package/src/persistence/saveRepository.ts +11 -0
- package/src/persistence/saveShape.ts +668 -0
- package/src/progression/leveling.ts +89 -0
- package/src/progression/rest.ts +1434 -0
- package/src/progression/training.ts +226 -0
- package/src/progression/xpGain.ts +369 -0
- package/src/quests/quests.ts +1580 -0
- package/src/world/aoePull.ts +198 -0
- package/src/world/areaTableDbc.ts +39 -0
- package/src/world/areaTableMapIdPairs.ts +141 -0
- package/src/world/bakeSlug.ts +43 -0
- package/src/world/continuousDungeon.ts +1250 -0
- package/src/world/dungeonBakeProjection.ts +190 -0
- package/src/world/dungeonMapDensity.ts +342 -0
- package/src/world/dungeonMapProjection.ts +135 -0
- package/src/world/dungeonNav.ts +904 -0
- package/src/world/dungeonNpcSim.ts +147 -0
- package/src/world/dungeonObjectives.ts +234 -0
- package/src/world/dungeonPreset.ts +35 -0
- package/src/world/dungeonRoute.ts +311 -0
- package/src/world/dungeonRouteGeometry.ts +732 -0
- package/src/world/dungeonRouteOrder.ts +1037 -0
- package/src/world/dungeonWingRooms.ts +426 -0
- package/src/world/escort.ts +296 -0
- package/src/world/goPoolRoll.ts +75 -0
- package/src/world/instanceObjects.ts +785 -0
- package/src/world/instances.ts +1572 -0
- package/src/world/meetingStones.ts +56 -0
- package/src/world/navmeshFloors.ts +145 -0
- package/src/world/nearGrid.ts +167 -0
- package/src/world/packBudget.ts +613 -0
- package/src/world/rareRoll.ts +335 -0
- package/src/world/routeAuthoring.ts +132 -0
- package/src/world/routeEdit.ts +209 -0
- package/src/world/world.ts +812 -0
- package/src/world/zoneHazards.ts +47 -0
|
@@ -0,0 +1,3449 @@
|
|
|
1
|
+
// Peeled from createSession (Wave 4 hub: pickAction). Pattern (D).
|
|
2
|
+
// Extracted modules must not import combat.ts.
|
|
3
|
+
import { castableKitIds } from './castableKit.ts';
|
|
4
|
+
import { triggeredIdsOf } from './triggeredCompanion.ts';
|
|
5
|
+
import { dispelClaimedByOtherSeat } from './dispelDuty.ts';
|
|
6
|
+
// NOTE: imports may need expanding when first run against seam tests.
|
|
7
|
+
|
|
8
|
+
import type { CombatContext } from './context.ts';
|
|
9
|
+
import type { Fighter, AllyActionProvider, CombatantView, ProviderNeeds } from './types.ts';
|
|
10
|
+
import { fighterCombatTimeMs, fighterIsInCombat } from './combatFlag.ts';
|
|
11
|
+
import type { SpellDef, TotemSlot, SpellRole } from '../../data/schema.ts';
|
|
12
|
+
import type { CcEffect } from './crowdControl.ts';
|
|
13
|
+
import { spellThreatFromDef } from './threat.ts';
|
|
14
|
+
import type { Side, EnemySide } from '../core/events.ts';
|
|
15
|
+
import { isEnemySide, isAllySide, isPlayerCharacterSide, isTotemSide } from '../core/events.ts';
|
|
16
|
+
import { TUNING, drFactor } from './formulas.ts';
|
|
17
|
+
import { schoolMaskFromSchool } from '../spells/procs.ts';
|
|
18
|
+
import { roleOf, spellHeals } from './roles.ts';
|
|
19
|
+
import { SPELL_ATTR, SPELL_ATTR_EX2, SPELL_ATTR_EX4, hasAttr, hasAttrEx2, hasAttrEx4, spellPiercesSchoolImmunity } from '../spells/spellAttributes.ts';
|
|
20
|
+
import {
|
|
21
|
+
isHostileSpellDef,
|
|
22
|
+
spellTargetTypeAllowed,
|
|
23
|
+
isHostileBuffEffect,
|
|
24
|
+
spellNeedsRangeGate,
|
|
25
|
+
spellGateRangeMaxYd,
|
|
26
|
+
spellGateRangeMinYd,
|
|
27
|
+
spellFoeDirectedOpaque,
|
|
28
|
+
spellFoeDirectedGeometry,
|
|
29
|
+
spellNeedsFriendlyLosGate,
|
|
30
|
+
spellNeedsLosGate,
|
|
31
|
+
isSelfCenteredAoe,
|
|
32
|
+
isFoeDirectedOffense,
|
|
33
|
+
focusImmunityBlocksSpell,
|
|
34
|
+
nextSwingFanCap,
|
|
35
|
+
sideIdSortKey,
|
|
36
|
+
isNonCastableTalentProc,
|
|
37
|
+
isRangedAutoRepeatToggle,
|
|
38
|
+
} from './predicates.ts';
|
|
39
|
+
import { reactiveCastGate } from './reactiveGates.ts';
|
|
40
|
+
import { isPlayerCharacterTarget, targetCastGate } from './castTargetGates.ts';
|
|
41
|
+
import { refuseCmangosCheckCast, RECENTLY_BANDAGED_SPELL_ID } from './cmangosCastGates.ts';
|
|
42
|
+
import { kitById } from './kitById.ts';
|
|
43
|
+
import { auraHolderExcludesCaster, isPartyFanBody, selectedOrSelfFriendly } from './healTargets.ts';
|
|
44
|
+
// INTERACTION_DISTANCE 5.0f. One definition for the hand click and the rotation click alike.
|
|
45
|
+
// Cite: ObjectDefines.h:25; world/instanceObjects.ts GO_INTERACT_YD.
|
|
46
|
+
import { GO_INTERACT_YD } from '../../world/instanceObjects.ts';
|
|
47
|
+
import { detHypot } from '../core/detMath.ts';
|
|
48
|
+
import {
|
|
49
|
+
isImmuneToMechanic,
|
|
50
|
+
isImmuneToMechanicId,
|
|
51
|
+
isImmuneToHarmfulAura,
|
|
52
|
+
isAbilityIneffectiveOnTarget,
|
|
53
|
+
isTotemCreatureType,
|
|
54
|
+
spellAppliesHostileAura,
|
|
55
|
+
schoolMaskFromSchoolName,
|
|
56
|
+
} from '../spells/immunity.ts';
|
|
57
|
+
import { captureSpiritAllowsEntry } from '../spells/cmangos-special-cases.ts';
|
|
58
|
+
import { CHROMAGGUS_BREATH_SELECTION_SPELL_ID } from '../spells/cmangos-special-cases-s23.ts';
|
|
59
|
+
import {
|
|
60
|
+
CORRUPTED_MIND_PRIMER_SPELL_ID,
|
|
61
|
+
refuseOverrideClassScriptCheckCast,
|
|
62
|
+
} from '../spells/overrideClassScripts.ts';
|
|
63
|
+
import { spellAllowedAtLocation } from '../spells/spellAttributes.ts';
|
|
64
|
+
import { categoryCooldownMs, getGcdMs, spellIncursGcd } from '../spells/recovery.ts';
|
|
65
|
+
import { DISPEL_TYPE, getDispelMask } from '../../domain/spells/dispel.ts';
|
|
66
|
+
import {
|
|
67
|
+
isDiminishingReturnsGroupDurationDiminished, DIMINISHING_GROUP_ORDER,
|
|
68
|
+
} from '../../domain/spells/diminishing.ts';
|
|
69
|
+
import {
|
|
70
|
+
resolveFormCast,
|
|
71
|
+
isFormSpell,
|
|
72
|
+
formIdFromSpell,
|
|
73
|
+
isMobilityFormSpellId,
|
|
74
|
+
isMobilityTravelForm,
|
|
75
|
+
} from '../../domain/character/forms.ts';
|
|
76
|
+
import {
|
|
77
|
+
isInSpellRange,
|
|
78
|
+
combatDistanceYd,
|
|
79
|
+
planarRangeHonestYd,
|
|
80
|
+
coneArcsOfKit,
|
|
81
|
+
coneCovers,
|
|
82
|
+
meleeContactYd,
|
|
83
|
+
} from './combatGeometry.ts';
|
|
84
|
+
import { layCombatObjectTrap } from './useObjectTrap.ts';
|
|
85
|
+
import { isHunterTrapArmProc } from '../spells/interpreter/hunter-kit.ts';
|
|
86
|
+
import { keepUpAlreadyPresent } from '../spells/auraStacking.ts';
|
|
87
|
+
import { hazardMatches, hostileHazardsThisTick } from './hazards.ts';
|
|
88
|
+
import type { RuleMove } from '../../domain/rotation/rotation.ts';
|
|
89
|
+
import { navSightClear } from './navSight.ts';
|
|
90
|
+
import { spellFromKit } from './kitById.ts';
|
|
91
|
+
import {
|
|
92
|
+
isSpellSchoolLocked,
|
|
93
|
+
interruptEffectMechanicId,
|
|
94
|
+
schoolIndexFromSchool,
|
|
95
|
+
spellHasInterruptCast,
|
|
96
|
+
pruneInterruptClaims,
|
|
97
|
+
unitIsCastingOrChanneling,
|
|
98
|
+
unitInterruptibleCast,
|
|
99
|
+
} from '../spells/interrupt.ts';
|
|
100
|
+
import { combatConditionMeets, type ConditionUnit } from '../../domain/creature/combatCondition.ts';
|
|
101
|
+
import type { CombatantId } from './threat.ts';
|
|
102
|
+
import type { AllyFilter, RotationSnapshot, RuleAfterStep, SubjectState } from '../../domain/rotation/rotation.ts';
|
|
103
|
+
import { killOrderEntryMatches, killOrderTakesCharmed } from '../../domain/rotation/killOrder.ts';
|
|
104
|
+
import {
|
|
105
|
+
allyFilterIsAny,
|
|
106
|
+
haltDpsCoversEntry,
|
|
107
|
+
haltDpsRefusesSchool,
|
|
108
|
+
isGroundTargetedMulti,
|
|
109
|
+
isNovaKiteSpell,
|
|
110
|
+
isPiercingHowlSpell,
|
|
111
|
+
piercingHowlRefused,
|
|
112
|
+
multiTargetNeedsPackDensity,
|
|
113
|
+
isPackSuppressedStFiller,
|
|
114
|
+
multiTargetGateYards,
|
|
115
|
+
novaDiscOccupied,
|
|
116
|
+
isDispelSpell,
|
|
117
|
+
isBattleRezSpell,
|
|
118
|
+
isPureInterruptSpell,
|
|
119
|
+
} from '../../domain/rotation/rotation.ts';
|
|
120
|
+
import {
|
|
121
|
+
assignedHealSubgroupOf,
|
|
122
|
+
buildRaidPlan,
|
|
123
|
+
campOfSeat,
|
|
124
|
+
isMainTankSeat,
|
|
125
|
+
isOffTankSeat,
|
|
126
|
+
normalizeRaidPlan,
|
|
127
|
+
overlayEncounterRaidPlan,
|
|
128
|
+
raidCampCode,
|
|
129
|
+
RAID_JOB_CODE,
|
|
130
|
+
seatPlanOf,
|
|
131
|
+
tankBossIndex,
|
|
132
|
+
tankFocusSplitsBosses,
|
|
133
|
+
type RaidPlan,
|
|
134
|
+
type RaidPlanSeatInput,
|
|
135
|
+
} from '../../domain/raid/raidPlan.ts';
|
|
136
|
+
import { passesConditionGate, type ConditionSnapshot } from '../spells/conditions.ts';
|
|
137
|
+
import { getVariable, isConditionFulfilled } from '../game/worldState.ts';
|
|
138
|
+
import { SPELL_EFFECT_CHARGE } from '../spells/interpreter/opcodes.ts';
|
|
139
|
+
import {
|
|
140
|
+
CAST_FLAG,
|
|
141
|
+
EVENT_T,
|
|
142
|
+
pickCreatureCast,
|
|
143
|
+
resolveRandomActionCastSlot,
|
|
144
|
+
resolveClassCallCastSlot,
|
|
145
|
+
rollCreatureCastChance,
|
|
146
|
+
markCreatureCastRowUsed,
|
|
147
|
+
countCreatureCastFire,
|
|
148
|
+
MIN_AGGRO_DISTANCE,
|
|
149
|
+
isMechanicImmune,
|
|
150
|
+
SPELL_LIST_POLL_MS,
|
|
151
|
+
} from '../../domain/creature/creatureAi.ts';
|
|
152
|
+
import { TARGET_T } from '../../domain/creature/aiTargeting.ts';
|
|
153
|
+
import { getAuraScript, dataGatePasses } from '../../domain/scriptdev/index.ts';
|
|
154
|
+
import { isNefarianClassCallSpell } from '../../domain/scriptdev/classFilter.ts';
|
|
155
|
+
import {
|
|
156
|
+
WIDOWS_EMBRACE_CD_SPELL_ID,
|
|
157
|
+
widowsEmbraceRefusesCast,
|
|
158
|
+
} from '../spells/cmangos-special-cases-s22.ts';
|
|
159
|
+
import { ALLY_HEAL_THRESHOLD } from './types.ts';
|
|
160
|
+
import { SLOW_IDLE_RATE } from './slowUpdate.ts';
|
|
161
|
+
import { drGroupOf } from './crowdControl.ts';
|
|
162
|
+
import { playerMissingRequiredEquippedItem } from './equippedItemGate.ts';
|
|
163
|
+
import { canPaySpellReagents } from './spellReagents.ts';
|
|
164
|
+
import { armRuleAfter, consumeFollowUp, liveFollowUpChain } from './ruleAfter.ts';
|
|
165
|
+
import { channelInterruptCtx, maybeCancelChannelOnMove } from './channel.ts';
|
|
166
|
+
import {
|
|
167
|
+
flagAuraId, inMeleeBand, isRezzableCorpse, rezzableDeadCount, unitClassId, unitIsCaster,
|
|
168
|
+
unitIsHunter,
|
|
169
|
+
} from './rotationSubject.ts';
|
|
170
|
+
import { pickBattleRezTarget, pruneRezClaims } from './battleRez.ts';
|
|
171
|
+
import { emitSimEffect } from './simEffects.ts';
|
|
172
|
+
import { maxPowerOfType } from './powerPools.ts';
|
|
173
|
+
|
|
174
|
+
export type installPickActionDeps = {
|
|
175
|
+
dead: (...args: any[]) => any;
|
|
176
|
+
isControlled: (...args: any[]) => any;
|
|
177
|
+
isControlledHard: (...args: any[]) => any;
|
|
178
|
+
isWeaponAction: (...args: any[]) => any;
|
|
179
|
+
isBreaker: (...args: any[]) => any;
|
|
180
|
+
ccImmune: (...args: any[]) => any;
|
|
181
|
+
spellTemplateImmune: (...args: any[]) => any;
|
|
182
|
+
canAffordSpell: (...args: any[]) => any;
|
|
183
|
+
spellCost: (...args: any[]) => any;
|
|
184
|
+
comboOn: (...args: any[]) => any;
|
|
185
|
+
foeOf: (...args: any[]) => any;
|
|
186
|
+
ensurePlayerFocus: (...args: any[]) => any;
|
|
187
|
+
lowestHpFriendly: (...args: any[]) => any;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// A pure function of a frozen bake row, asked per candidate spell per unit per tick — 4.6% of a tick
|
|
191
|
+
// profile. Module scope, not per session: spell defs are interned, so the map is shared and self-evicting.
|
|
192
|
+
const chargeRoleCache = new WeakMap<SpellDef, boolean>();
|
|
193
|
+
const memoSpellBool = (
|
|
194
|
+
map: WeakMap<SpellDef, boolean>,
|
|
195
|
+
fn: (s: SpellDef) => boolean,
|
|
196
|
+
s: SpellDef,
|
|
197
|
+
): boolean => {
|
|
198
|
+
let v = map.get(s);
|
|
199
|
+
if (v === undefined) { v = fn(s); map.set(s, v); }
|
|
200
|
+
return v;
|
|
201
|
+
};
|
|
202
|
+
const pureInterruptCache = new WeakMap<SpellDef, boolean>();
|
|
203
|
+
const interruptCastCache = new WeakMap<SpellDef, boolean>();
|
|
204
|
+
const packDensityCache = new WeakMap<SpellDef, boolean>();
|
|
205
|
+
const groundMultiFlagCache = new WeakMap<SpellDef, boolean>();
|
|
206
|
+
const novaKiteFlagCache = new WeakMap<SpellDef, boolean>();
|
|
207
|
+
const selfCenteredCache = new WeakMap<SpellDef, boolean>();
|
|
208
|
+
const dotPayloadCache = new WeakMap<SpellDef, boolean>();
|
|
209
|
+
const pureInterruptOf = (s: SpellDef): boolean => memoSpellBool(pureInterruptCache, isPureInterruptSpell, s);
|
|
210
|
+
const interruptCastOf = (s: SpellDef): boolean => memoSpellBool(interruptCastCache, spellHasInterruptCast, s);
|
|
211
|
+
const packDensityOf = (s: SpellDef): boolean => memoSpellBool(packDensityCache, multiTargetNeedsPackDensity, s);
|
|
212
|
+
const groundMultiOf = (s: SpellDef): boolean => memoSpellBool(groundMultiFlagCache, isGroundTargetedMulti, s);
|
|
213
|
+
const novaKiteOf = (s: SpellDef): boolean => memoSpellBool(novaKiteFlagCache, isNovaKiteSpell, s);
|
|
214
|
+
const selfCenteredOf = (s: SpellDef): boolean => memoSpellBool(selfCenteredCache, isSelfCenteredAoe, s);
|
|
215
|
+
|
|
216
|
+
// Per-kit Cleave/Swipe flags the rotation asks for every candidate spell. Keyed on `spec.spells` so a
|
|
217
|
+
// shapeshift kit swap misses naturally (same as kitById).
|
|
218
|
+
const packFillerCache = new WeakMap<readonly SpellDef[], { hasCleave: boolean; hasSwipe: boolean }>();
|
|
219
|
+
const packFillersOf = (spells: readonly SpellDef[]): { hasCleave: boolean; hasSwipe: boolean } => {
|
|
220
|
+
let v = packFillerCache.get(spells);
|
|
221
|
+
if (!v) {
|
|
222
|
+
let hasCleave = false;
|
|
223
|
+
let hasSwipe = false;
|
|
224
|
+
for (const sp of spells) {
|
|
225
|
+
const n = (sp.name ?? '').toLowerCase();
|
|
226
|
+
if (n === 'cleave') hasCleave = true;
|
|
227
|
+
if (n === 'swipe') hasSwipe = true;
|
|
228
|
+
}
|
|
229
|
+
v = { hasCleave, hasSwipe };
|
|
230
|
+
packFillerCache.set(spells, v);
|
|
231
|
+
}
|
|
232
|
+
return v;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// Self-centred hard-control AoEs in a kit — Frost Nova and its ranks. Empty for every class but mage, so
|
|
236
|
+
// the holdability scan costs 39 of 40 raid seats nothing.
|
|
237
|
+
const novaKiteKitCache = new WeakMap<readonly SpellDef[], readonly SpellDef[]>();
|
|
238
|
+
const novaKiteKitOf = (spells: readonly SpellDef[]): readonly SpellDef[] => {
|
|
239
|
+
let v = novaKiteKitCache.get(spells);
|
|
240
|
+
if (!v) {
|
|
241
|
+
v = spells.filter((sp) => novaKiteOf(sp));
|
|
242
|
+
novaKiteKitCache.set(spells, v);
|
|
243
|
+
}
|
|
244
|
+
return v;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const kitHasGroundMultiCache = new WeakMap<readonly SpellDef[], boolean>();
|
|
248
|
+
const kitHasGroundMulti = (spells: readonly SpellDef[]): boolean => {
|
|
249
|
+
let v = kitHasGroundMultiCache.get(spells);
|
|
250
|
+
if (v === undefined) {
|
|
251
|
+
v = false;
|
|
252
|
+
for (const s of spells) if (groundMultiOf(s)) { v = true; break; }
|
|
253
|
+
kitHasGroundMultiCache.set(spells, v);
|
|
254
|
+
}
|
|
255
|
+
return v;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const kitHasFanStrikeCache = new WeakMap<readonly SpellDef[], boolean>();
|
|
259
|
+
const kitHasFanStrike = (spells: readonly SpellDef[]): boolean => {
|
|
260
|
+
let v = kitHasFanStrikeCache.get(spells);
|
|
261
|
+
if (v === undefined) {
|
|
262
|
+
v = false;
|
|
263
|
+
for (const s of spells) {
|
|
264
|
+
if (roleOf(s) === 'strike' && (s.aoe || nextSwingFanCap(s) != null)) { v = true; break; }
|
|
265
|
+
}
|
|
266
|
+
kitHasFanStrikeCache.set(spells, v);
|
|
267
|
+
}
|
|
268
|
+
return v;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const kitNeedsSelfPackDistsCache = new WeakMap<readonly SpellDef[], boolean>();
|
|
272
|
+
const kitNeedsSelfPackDists = (spells: readonly SpellDef[]): boolean => {
|
|
273
|
+
let v = kitNeedsSelfPackDistsCache.get(spells);
|
|
274
|
+
if (v === undefined) {
|
|
275
|
+
const fillers = packFillersOf(spells);
|
|
276
|
+
v = fillers.hasCleave || fillers.hasSwipe || novaKiteKitOf(spells).length > 0;
|
|
277
|
+
if (!v) {
|
|
278
|
+
for (const s of spells) {
|
|
279
|
+
if (packDensityOf(s) && !groundMultiOf(s)) { v = true; break; }
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
kitNeedsSelfPackDistsCache.set(spells, v);
|
|
283
|
+
}
|
|
284
|
+
return v;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
export function installPickAction(ctx: CombatContext, deps: installPickActionDeps): void {
|
|
288
|
+
const resolveDispelTarget = (...args: any[]) => (ctx.resolveDispelTarget as Function)(...args);
|
|
289
|
+
const dispellableAurasOn = (...args: any[]) => (ctx.dispellableAurasOn as Function)(...args);
|
|
290
|
+
const push = (e: any) => ctx.push(e);
|
|
291
|
+
const enemyCastBySide = ctx.enemyCastBySide;
|
|
292
|
+
const player = ctx.player;
|
|
293
|
+
const pet = ctx.pet;
|
|
294
|
+
const totems = ctx.totems;
|
|
295
|
+
const effects = ctx.effects;
|
|
296
|
+
const unitItems = ctx.unitItems;
|
|
297
|
+
const unitItemCd = ctx.unitItemCd;
|
|
298
|
+
const rng = ctx.rng;
|
|
299
|
+
const cfg = ctx.cfg;
|
|
300
|
+
const combatObjectBag = [...(cfg.combatObjects ?? [])];
|
|
301
|
+
// A button re-arms instead of despawning, so its slot is never spent.
|
|
302
|
+
// Cite: GameObject.cpp:1458 UseDoorOrButton; types.ts combatObjectsReusable.
|
|
303
|
+
const reusableObjects = new Set((cfg.combatObjectsReusable ?? []).map((e) => e | 0));
|
|
304
|
+
// Worth clicking THIS tick, off the per-tick input the game layer supplies (S6b mechanism 2). No
|
|
305
|
+
// input at all means the fight has no instance rule, and every bag entry is actionable.
|
|
306
|
+
const objectActionable = (goEntry: number): boolean =>
|
|
307
|
+
ctx.tickInputs?.actionableObjects?.has(goEntry | 0) ?? true;
|
|
308
|
+
|
|
309
|
+
// The two companion knobs, stamped onto the snapshot from the tick input. The provider reads them
|
|
310
|
+
// off a LIVE companion when nothing supplies them, which a worker cannot hold (S6b).
|
|
311
|
+
const stampCompanionKnobs = (side: string, snap: RotationSnapshot): void => {
|
|
312
|
+
const k = ctx.tickInputs?.companionKnobs?.get(side);
|
|
313
|
+
if (!k) return;
|
|
314
|
+
snap.aoeMode = k.aoeMode;
|
|
315
|
+
snap.offHealAllowed = k.offHeal ? 1 : 0;
|
|
316
|
+
};
|
|
317
|
+
/** Where a bag object stands, in combat yards, for the walk to it. */
|
|
318
|
+
const objectSites = new Map(
|
|
319
|
+
(cfg.combatObjectSites ?? []).map((sp) => [sp.goEntry | 0, sp.origin] as const),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const distanceBetween = ctx.distanceBetween;
|
|
323
|
+
const threatOf = ctx.threatOf;
|
|
324
|
+
const livingHostilesFor = ctx.livingHostilesFor;
|
|
325
|
+
const livingEnemies = ctx.livingEnemies;
|
|
326
|
+
/** Endless: multi AI counts combat hostiles only (not the passive stream). */
|
|
327
|
+
const combatEnemyCount = (): number =>
|
|
328
|
+
(typeof ctx.livingCombatEnemies === 'function' ? ctx.livingCombatEnemies() : livingEnemies()).length;
|
|
329
|
+
/** EVENT_T_SPELLHIT pulse still live: the spell landed on `f` this tick or the last one. */
|
|
330
|
+
const freshSpellHit = (f: Fighter): boolean =>
|
|
331
|
+
f.spellHitAt != null && ctx.t - f.spellHitAt <= ctx.tickMs * SLOW_IDLE_RATE;
|
|
332
|
+
/** Break-on-damage CC (poly/sap/gouge/shackle/blind) — our blast must not count it as pack. */
|
|
333
|
+
const ccProtected = (u: Fighter): boolean => ctx.ccProtected?.(u) === true;
|
|
334
|
+
/** combatEnemyCount minus the mobs an AoE would only free. Positionless fallback. */
|
|
335
|
+
const blastEnemyCount = (): number => {
|
|
336
|
+
const pool = typeof ctx.livingCombatEnemies === 'function'
|
|
337
|
+
? ctx.livingCombatEnemies()
|
|
338
|
+
: livingEnemies();
|
|
339
|
+
let n = 0;
|
|
340
|
+
for (const e of pool) if (!ccProtected(e)) n++;
|
|
341
|
+
return n;
|
|
342
|
+
};
|
|
343
|
+
// Hoisted at install scope on purpose: two readers ~500 lines apart both need these, and a const
|
|
344
|
+
// arrow would sit in its temporal dead zone for one of them. Interrupt immunity is 144 rows.
|
|
345
|
+
function interruptCanLand(tgt: Fighter, s: SpellDef): boolean {
|
|
346
|
+
return !isImmuneToMechanicId(tgt.immunity, interruptEffectMechanicId(s), {
|
|
347
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
348
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** Soonest interruptible bar this unit can actually land a kick on. */
|
|
352
|
+
function pickInterruptTarget(f: Fighter, focus: Fighter, spell: SpellDef): Fighter | null {
|
|
353
|
+
const remainingMs = (u: Fighter): number => {
|
|
354
|
+
if (u.castingSpell) return Math.max(0, u.castFinishAt - ctx.t);
|
|
355
|
+
if (u.channeling) return Math.max(0, u.channeling.endsAt - ctx.t);
|
|
356
|
+
return Number.POSITIVE_INFINITY;
|
|
357
|
+
};
|
|
358
|
+
const ok = (u: Fighter): boolean =>
|
|
359
|
+
!dead(u)
|
|
360
|
+
&& u !== f
|
|
361
|
+
&& unitInterruptibleCast(u, ctx.t) != null
|
|
362
|
+
&& interruptCanLand(u, spell)
|
|
363
|
+
&& !interruptClaimedByOther(u.side, f.side)
|
|
364
|
+
&& canCastSpell(f, u, spell);
|
|
365
|
+
let best: Fighter | null = null;
|
|
366
|
+
let bestRem = Number.POSITIVE_INFINITY;
|
|
367
|
+
const consider = (u: Fighter): void => {
|
|
368
|
+
if (!ok(u)) return;
|
|
369
|
+
const rem = remainingMs(u);
|
|
370
|
+
if (best == null || rem < bestRem) {
|
|
371
|
+
best = u;
|
|
372
|
+
bestRem = rem;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
consider(focus);
|
|
376
|
+
for (const h of hostilesOf(f)) {
|
|
377
|
+
if (h !== focus) consider(h);
|
|
378
|
+
}
|
|
379
|
+
return best;
|
|
380
|
+
}
|
|
381
|
+
// A CC whose whole hold ends on the first hit — Shackle Undead, Polymorph, Banish, Hibernate, Freezing
|
|
382
|
+
// Trap. Exactly the rows breakBreakableCcOnDamage shatters.
|
|
383
|
+
const isBreakOnDamageCc = (s: SpellDef): boolean =>
|
|
384
|
+
s.effects.some((e) => e.kind === 'cc' && e.breakOnDamage === true);
|
|
385
|
+
|
|
386
|
+
function hardControlCanLand(src: Fighter, tgt: Fighter, s: SpellDef): boolean {
|
|
387
|
+
if (isHostileSpellDef(s) && !spellTargetTypeAllowed(s, tgt.creatureType)) return false;
|
|
388
|
+
if (spellTemplateImmune(tgt, s)) return false;
|
|
389
|
+
if (
|
|
390
|
+
isHostileSpellDef(s)
|
|
391
|
+
&& isImmuneToHarmfulAura(
|
|
392
|
+
tgt.immunity,
|
|
393
|
+
{
|
|
394
|
+
schoolMask: schoolMaskFromSchoolName(s.school) || schoolMaskFromSchool(s.school),
|
|
395
|
+
positive: false,
|
|
396
|
+
noSchoolImmunities: spellPiercesSchoolImmunity(s),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
400
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
) {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
// The caster's tracked seat is already on another live body: casting here would only MOVE it,
|
|
407
|
+
// freeing that mob, so no rule offers a second sheep. Cite: PlayerbotAI.cpp:4523 CanReceive.
|
|
408
|
+
if ((ctx.trackedAuraSeats?.(src, s) ?? []).some((seat) => seat.side !== tgt.side)) return false;
|
|
409
|
+
const ccEffs = s.effects.filter((e): e is CcEffect => e.kind === 'cc');
|
|
410
|
+
const hasRoot = s.effects.some((e) => e.kind === 'root');
|
|
411
|
+
// Taunt / threat / pure utility under role control — no hard-CC gate.
|
|
412
|
+
if (ccEffs.length === 0 && !hasRoot) return true;
|
|
413
|
+
|
|
414
|
+
const srcIsPc =
|
|
415
|
+
src.spec.isPlayerOrPet ?? (src.side === 'player' || src.side === 'pet' || isAllySide(src.side));
|
|
416
|
+
const tgtIsPc =
|
|
417
|
+
tgt.spec.isPlayerOrPet ?? (tgt.side === 'player' || tgt.side === 'pet' || isAllySide(tgt.side));
|
|
418
|
+
const isPvp = !!(srcIsPc && tgtIsPc);
|
|
419
|
+
|
|
420
|
+
for (const eff of ccEffs) {
|
|
421
|
+
if (ccImmune(tgt, eff.mechanic, eff.onlyTypes)) continue;
|
|
422
|
+
if (
|
|
423
|
+
isImmuneToMechanic(tgt.immunity, eff.mechanic, {
|
|
424
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
425
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
426
|
+
})
|
|
427
|
+
) {
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
// Peek DR without advancing (applyDr mutates). Duration-dim groups go immune at step 3.
|
|
431
|
+
const key = drGroupOf(eff.mechanic);
|
|
432
|
+
const rec = tgt.dr.get(key);
|
|
433
|
+
const step = rec && rec.resetAt > ctx.t ? rec.step : 0;
|
|
434
|
+
if (isDiminishingReturnsGroupDurationDiminished(key, isPvp) && drFactor(step) <= 0) continue;
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (hasRoot) {
|
|
438
|
+
if (isMechanicImmune(tgt.spec.mechanicImmuneMask, 'root')) return false;
|
|
439
|
+
if (
|
|
440
|
+
isImmuneToMechanic(tgt.immunity, 'root', {
|
|
441
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
442
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
443
|
+
})
|
|
444
|
+
) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
const rootKey = drGroupOf('root');
|
|
448
|
+
const rec = tgt.dr.get(rootKey);
|
|
449
|
+
const step = rec && rec.resetAt > ctx.t ? rec.step : 0;
|
|
450
|
+
if (isDiminishingReturnsGroupDurationDiminished(rootKey, isPvp) && drFactor(step) <= 0) return false;
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A living hostile that is NOT the focus and is NOT already under hard control — the mob a CC rule
|
|
457
|
+
// should land on. Deterministic: nearest, then lowest side, and no rng.
|
|
458
|
+
function pickHostileByKillOrder(
|
|
459
|
+
f: Fighter,
|
|
460
|
+
hostiles: readonly Fighter[],
|
|
461
|
+
order: readonly number[],
|
|
462
|
+
): Fighter | null {
|
|
463
|
+
// A charmed body of the picker's OWN side is already in `hostiles`, because charm flips a
|
|
464
|
+
// unit's faction (sessionPools.ts:170-173). Damaging it is what frees it.
|
|
465
|
+
const mine = isEnemySide(f.side);
|
|
466
|
+
for (const entry of order) {
|
|
467
|
+
for (const h of hostiles) {
|
|
468
|
+
if (dead(h)) continue;
|
|
469
|
+
if (killOrderTakesCharmed(entry | 0, isCharmed(h), isEnemySide(h.side) === mine)) return h;
|
|
470
|
+
if (killOrderEntryMatches(entry | 0, h.spec.creatureEntry ?? 0)) return h;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function pickCcTarget(f: Fighter, focus: Fighter | null, spell?: SpellDef): Fighter | null {
|
|
477
|
+
// Hard control only: stun/fear/incap/charm. Root is not in IsNeutralized
|
|
478
|
+
// (PlayerbotAI.cpp:5458-5485) and isControlledHard (crowdControl.ts) omits it.
|
|
479
|
+
const cands = hostilesOf(f).filter((e) => (
|
|
480
|
+
!dead(e)
|
|
481
|
+
&& e !== focus
|
|
482
|
+
&& !(e.stunnedUntil > ctx.t || e.fearedUntil > ctx.t || e.incapUntil > ctx.t
|
|
483
|
+
|| e.charmedUntil > ctx.t)
|
|
484
|
+
// Our own damage already shattered this caster's CC on this body once. The timer test only sees
|
|
485
|
+
// a LIVE control, so the rule re-fired every GCD. An expired CC may be refreshed; a broken one not.
|
|
486
|
+
&& e.ccDamageBrokenBy?.has(f.side) !== true
|
|
487
|
+
// The CC has to be able to LAND on this body. Without it the rule only asked "is there another
|
|
488
|
+
// mob", so a Polymorph fired every GCD at a mechanic-immune elite and got immune back forever.
|
|
489
|
+
&& (spell == null || hardControlCanLand(f, e, spell))
|
|
490
|
+
));
|
|
491
|
+
if (!cands.length) return null;
|
|
492
|
+
// Blind peel: FindAttacker then Blind that body (PlayerbotRogueAI.cpp:208-219). Party form —
|
|
493
|
+
// prefer the hostile whose GetVictim is a living ally, not the off-healer Poly still wants.
|
|
494
|
+
// Mechanic `disoriented` is Blind (2094); Polymorph is `polymorph` and keeps healer preference.
|
|
495
|
+
const isBlind = spell != null && spell.effects.some(
|
|
496
|
+
(e) => e.kind === 'cc' && e.breakOnDamage === true && e.mechanic === 'disoriented',
|
|
497
|
+
);
|
|
498
|
+
const peelTrainers = isBlind && cfg.enemiesAreATeam === true
|
|
499
|
+
? cands.filter((e) => {
|
|
500
|
+
const v = foeOf(e);
|
|
501
|
+
return v !== e && !dead(v) && friendlyPoolOf(f).includes(v);
|
|
502
|
+
})
|
|
503
|
+
: [];
|
|
504
|
+
// Arena: off-target CC prefers the healer that is not the kill focus.
|
|
505
|
+
// Cite: PlayerbotClassAI.cpp:659 GetBotJob; sessionPools preferFocusHostile.
|
|
506
|
+
const arenaHealers = cfg.enemiesAreATeam === true
|
|
507
|
+
? cands.filter((e) => e.spec.groupRole === 'healer')
|
|
508
|
+
: [];
|
|
509
|
+
// Break-on-damage CC (Blind/Poly) on a melee in the pile dies to the next swing/cleave.
|
|
510
|
+
// With no off-healer, prefer a caster so the hold lasts. Cite: spells.json 2094 breakOnDamage;
|
|
511
|
+
// PlayerbotAI.cpp:5458-5485 IsNeutralized (do not attack the CC'd body).
|
|
512
|
+
let pool = peelTrainers.length > 0
|
|
513
|
+
? peelTrainers
|
|
514
|
+
: (arenaHealers.length > 0 ? arenaHealers : cands);
|
|
515
|
+
if (
|
|
516
|
+
peelTrainers.length === 0
|
|
517
|
+
&& arenaHealers.length === 0
|
|
518
|
+
&& cfg.enemiesAreATeam === true
|
|
519
|
+
&& spell != null
|
|
520
|
+
&& isBreakOnDamageCc(spell)
|
|
521
|
+
) {
|
|
522
|
+
const casters = cands.filter((e) => unitIsCaster(e, ctx.spellOf));
|
|
523
|
+
if (casters.length > 0) pool = casters;
|
|
524
|
+
}
|
|
525
|
+
if (!ctx.unitPositionsMutable) return pool[0]!;
|
|
526
|
+
// Prefer a body the spell can actually reach. Hard-skipping out-of-range candidates broke
|
|
527
|
+
// the same-comp mirror (repro-duel-offtarget-range.mjs); sorting still picks someone so the
|
|
528
|
+
// row fires, but Blind 10 yd no longer prefers a 20 yd peeler over one in the disc.
|
|
529
|
+
// Cite: Spell.cpp:6217-6244 CheckRange.
|
|
530
|
+
const inSpellRange = (e: Fighter): boolean => {
|
|
531
|
+
if (spell == null) return true;
|
|
532
|
+
const dist = distanceBetween(f, e);
|
|
533
|
+
if (dist == null) return true;
|
|
534
|
+
return isInSpellRange(
|
|
535
|
+
dist, spellGateRangeMaxYd(spell), spellGateRangeMinYd(spell),
|
|
536
|
+
ctx.isMoving(f.side) && ctx.isMoving(e.side),
|
|
537
|
+
f.spec.combatReachYd, e.spec.combatReachYd,
|
|
538
|
+
);
|
|
539
|
+
};
|
|
540
|
+
return pool.slice().sort((a, b) => {
|
|
541
|
+
const aIn = inSpellRange(a);
|
|
542
|
+
const bIn = inSpellRange(b);
|
|
543
|
+
if (aIn !== bIn) return aIn ? -1 : 1;
|
|
544
|
+
const da = distanceBetween(f, a);
|
|
545
|
+
const db = distanceBetween(f, b);
|
|
546
|
+
if (da == null && db == null) return String(a.side).localeCompare(String(b.side));
|
|
547
|
+
if (da == null) return 1;
|
|
548
|
+
if (db == null) return -1;
|
|
549
|
+
const d = da - db;
|
|
550
|
+
return d !== 0 ? d : String(a.side).localeCompare(String(b.side));
|
|
551
|
+
})[0]!;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Hostiles relative to `f` — BG enemy providers must not see "livingEnemies" (their own team). */
|
|
555
|
+
const hostilesOf = (f: Fighter): Fighter[] => {
|
|
556
|
+
if (isEnemySide(f.side)) return livingHostilesFor(f);
|
|
557
|
+
return (typeof ctx.livingCombatEnemies === 'function' ? ctx.livingCombatEnemies() : livingEnemies());
|
|
558
|
+
};
|
|
559
|
+
const friendlyPoolOf = ctx.friendlyPoolOf;
|
|
560
|
+
const foePoolOf = ctx.foePoolOf;
|
|
561
|
+
const keepUpLookup = {
|
|
562
|
+
spell: (id: number) => ctx.spellOf?.(id),
|
|
563
|
+
chainOf: ctx.spellChainOf,
|
|
564
|
+
};
|
|
565
|
+
const auraCovered = (ids: ReadonlySet<number>, ref: number): boolean =>
|
|
566
|
+
keepUpAlreadyPresent(ids, ref, keepUpLookup);
|
|
567
|
+
// False when `aura` is a periodic energize and this body has no such pool at all.
|
|
568
|
+
const canHoldEnergizeAura = (u: Fighter, aura: number): boolean => {
|
|
569
|
+
const sp = ctx.spellOf?.(aura);
|
|
570
|
+
if (!sp) return true;
|
|
571
|
+
for (const e of sp.effects ?? []) {
|
|
572
|
+
if (e.kind !== 'restore_power' || (e.amountPerTick ?? 0) < 0) continue;
|
|
573
|
+
const pool = e.powerType != null
|
|
574
|
+
? e.powerType | 0
|
|
575
|
+
: (sp.rawEffects?.[(sp.effects ?? []).indexOf(e)]?.miscValue ?? 0) | 0;
|
|
576
|
+
if (maxPowerOfType(u, pool) <= 0) return false;
|
|
577
|
+
}
|
|
578
|
+
return true;
|
|
579
|
+
};
|
|
580
|
+
// Living friendlies OTHER than `f` that do not hold `aura` (or a same-or-stronger rank/family).
|
|
581
|
+
// ONE list, so a party keep-up's gate and its cast target cannot disagree.
|
|
582
|
+
const alliesMissingAura = (f: Fighter, aura: number, among?: AllyFilter): Fighter[] => {
|
|
583
|
+
const id = aura | 0;
|
|
584
|
+
const out: Fighter[] = [];
|
|
585
|
+
if (id <= 0) return out;
|
|
586
|
+
const want = allyFilterIsAny(among) ? undefined : among;
|
|
587
|
+
for (const u of friendlyPoolOf(f)) {
|
|
588
|
+
if (u === f || dead(u) || auraCovered(u.buffActive, id)) continue;
|
|
589
|
+
// The row's own filter. A pet carries no player class, so `classId: 0` is the board's `pet`
|
|
590
|
+
// column — the same split blessingCellKey writes. Cite: paladinBuffs.ts:38.
|
|
591
|
+
if (want) {
|
|
592
|
+
if (want.unit && u.side !== want.unit) continue;
|
|
593
|
+
if (want.role != null && u.spec.groupRole !== want.role) continue;
|
|
594
|
+
if (want.notRole != null && u.spec.groupRole === want.notRole) continue;
|
|
595
|
+
if (want.classId != null && unitClassId(u) !== want.classId) continue;
|
|
596
|
+
}
|
|
597
|
+
// The cast can only reach what the fan enumerates: a totem or a guardian never registers the
|
|
598
|
+
// aura, so leaving it here spends the buffer's mana on it every window.
|
|
599
|
+
if (!isPartyFanBody(u)) continue;
|
|
600
|
+
// Nor can a body with no pool hold an energize aura, so the row would re-fire forever on it.
|
|
601
|
+
// Cite: SpellAuras.cpp:4838 GetMaxPower(power) == 0 → the aura never lands.
|
|
602
|
+
if (!canHoldEnergizeAura(u, id)) continue;
|
|
603
|
+
out.push(u);
|
|
604
|
+
}
|
|
605
|
+
return out;
|
|
606
|
+
};
|
|
607
|
+
// Nearest of those in planar yards. Without geometry (headless / fixtures) the first in pool order
|
|
608
|
+
// — a session that cannot measure the room still buffs the party.
|
|
609
|
+
const nearestAllyMissingAura = (f: Fighter, aura: number, among?: AllyFilter): Fighter | null => {
|
|
610
|
+
const pool = alliesMissingAura(f, aura, among);
|
|
611
|
+
if (!pool.length) return null;
|
|
612
|
+
const here = ctx.posOf?.(f.side);
|
|
613
|
+
if (!here) return pool[0]!;
|
|
614
|
+
let best: Fighter | null = null;
|
|
615
|
+
let bestD = Number.POSITIVE_INFINITY;
|
|
616
|
+
for (const u of pool) {
|
|
617
|
+
const p = ctx.posOf?.(u.side);
|
|
618
|
+
if (!p) continue;
|
|
619
|
+
const d = combatDistanceYd(here, p);
|
|
620
|
+
if (d != null && d < bestD) { bestD = d; best = u; }
|
|
621
|
+
}
|
|
622
|
+
return best ?? pool[0]!;
|
|
623
|
+
};
|
|
624
|
+
// The ally a friendly cast would land on, once per tick per caster: canCastSpell asks it for every
|
|
625
|
+
// heal in the kit and the resolver walks the pool. Cite: applyHealEffect.ts:104 same resolver.
|
|
626
|
+
const healTargetBySide = new Map<string, Fighter | null>();
|
|
627
|
+
let healTargetTick = -1;
|
|
628
|
+
const healTargetOf = (f: Fighter): Fighter | null => {
|
|
629
|
+
if (ctx.t !== healTargetTick) {
|
|
630
|
+
healTargetBySide.clear();
|
|
631
|
+
healTargetTick = ctx.t;
|
|
632
|
+
}
|
|
633
|
+
const hit = healTargetBySide.get(f.side);
|
|
634
|
+
if (hit !== undefined) return hit;
|
|
635
|
+
const tgt = lowestHpFriendlyOf(f);
|
|
636
|
+
healTargetBySide.set(f.side, tgt);
|
|
637
|
+
return tgt;
|
|
638
|
+
};
|
|
639
|
+
// A cast refused for SIGHT waits on the unit's own feet, so it must re-decide while it walks or it
|
|
640
|
+
// sleeps through its own arrival. Cite: slowUpdate.ts:133-135, the same rule a relocating unit gets.
|
|
641
|
+
const blockedBySight = (f: Fighter): false => {
|
|
642
|
+
f.decideDirty = true;
|
|
643
|
+
return false;
|
|
644
|
+
};
|
|
645
|
+
// CheckCast refuses a heal without sight, and one started anyway holds the caster still for its whole
|
|
646
|
+
// cast time. Cite: Spell.cpp:4742; sessionMotion.ts:1366 `casting` gates motion.
|
|
647
|
+
const friendlyCastReaches = (f: Fighter, ally: Fighter): boolean => {
|
|
648
|
+
if (!cfg.navLos || ally === f) return true;
|
|
649
|
+
const ps = ctx.unitPositionsMutable;
|
|
650
|
+
if (navSightClear(cfg, ps?.[f.side], ps?.[ally.side])) return true;
|
|
651
|
+
blockedBySight(f);
|
|
652
|
+
return false;
|
|
653
|
+
};
|
|
654
|
+
const isCharmed = ctx.isCharmed;
|
|
655
|
+
const applyDr = (...args: any[]) => (ctx.applyDr as Function)(...args);
|
|
656
|
+
const applySpell = (...args: any[]) => (ctx.applySpell as Function)(...args);
|
|
657
|
+
const startOrCast = (...args: any[]) => (ctx.startOrCast as Function)(...args);
|
|
658
|
+
const step = (...args: any[]) => (ctx.step as Function)(...args);
|
|
659
|
+
// Wave 4 fix-up: createSession closures the peel left as bare identifiers.
|
|
660
|
+
const behindOk = (...args: any[]) => (ctx.behindOk as Function)(...args);
|
|
661
|
+
const view = (f: Fighter): CombatantView => (ctx.view as (f: Fighter) => CombatantView)(f);
|
|
662
|
+
const useItem = (...args: any[]) => (ctx.useItem as Function)(...args);
|
|
663
|
+
const controlActive = (...args: any[]) => (ctx.controlActive as Function)(...args);
|
|
664
|
+
const dead = deps.dead;
|
|
665
|
+
const isControlled = deps.isControlled;
|
|
666
|
+
const isControlledHard = deps.isControlledHard;
|
|
667
|
+
const isWeaponAction = deps.isWeaponAction;
|
|
668
|
+
const isBreaker = deps.isBreaker;
|
|
669
|
+
|
|
670
|
+
// The plan is a property of the ROSTER, not of the asking fighter, and every seat rebuilt it: a
|
|
671
|
+
// 40-man raid built a 40-seat plan 40 times a tick. Roles are fixed, so one build per tick is exact.
|
|
672
|
+
let raidPlanAt = -1;
|
|
673
|
+
let raidPlanSeats: readonly Fighter[] | null = null;
|
|
674
|
+
let raidPlanCached: RaidPlan | null = null;
|
|
675
|
+
const liveRaidPlan = (): RaidPlan => {
|
|
676
|
+
const now = ctx.t;
|
|
677
|
+
// Keyed on the BLOCK's identity, not its length: the same array object comes back until the roster
|
|
678
|
+
// gen moves, and a length compare cannot see a seat RECYCLED onto a different occupant.
|
|
679
|
+
const seats = ctx.allyBlock();
|
|
680
|
+
if (raidPlanCached && raidPlanAt === now && raidPlanSeats === seats) return raidPlanCached;
|
|
681
|
+
const plan = buildLiveRaidPlan();
|
|
682
|
+
raidPlanAt = now;
|
|
683
|
+
raidPlanSeats = seats;
|
|
684
|
+
raidPlanCached = plan;
|
|
685
|
+
return plan;
|
|
686
|
+
};
|
|
687
|
+
const buildLiveRaidPlan = (): RaidPlan => {
|
|
688
|
+
const saved = normalizeRaidPlan(Reflect.get(cfg, 'raidPlan'));
|
|
689
|
+
const inputs: RaidPlanSeatInput[] = [];
|
|
690
|
+
const pushSeat = (f: Fighter): void => {
|
|
691
|
+
const role = f.spec.groupRole;
|
|
692
|
+
if (!role) return;
|
|
693
|
+
inputs.push({
|
|
694
|
+
seatId: f.side,
|
|
695
|
+
role: role === 'damage' ? 'dps' : role,
|
|
696
|
+
subgroup: f.spec.subgroup ?? 0,
|
|
697
|
+
...(f.spec.classId != null ? { cls: f.spec.classId } : {}),
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
pushSeat(player);
|
|
701
|
+
for (const al of ctx.allyBlock()) pushSeat(al);
|
|
702
|
+
if (!inputs.length) {
|
|
703
|
+
return saved ?? buildRaidPlan([{ seatId: player.side, role: 'dps', subgroup: 0 }]);
|
|
704
|
+
}
|
|
705
|
+
const base = !saved
|
|
706
|
+
? buildRaidPlan(inputs)
|
|
707
|
+
: buildRaidPlan(inputs, {
|
|
708
|
+
mainTank: saved.mainTank,
|
|
709
|
+
offTanks: saved.offTanks,
|
|
710
|
+
camps: saved.camps,
|
|
711
|
+
healAssignments: saved.healAssignments,
|
|
712
|
+
});
|
|
713
|
+
const entries: number[] = [];
|
|
714
|
+
for (const e of livingEnemies()) {
|
|
715
|
+
const id = e.spec.creatureEntry;
|
|
716
|
+
if (id != null) entries.push(id);
|
|
717
|
+
}
|
|
718
|
+
return overlayEncounterRaidPlan(base, inputs, entries);
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const raidSnapFields = (f: Fighter, plan: RaidPlan): Pick<
|
|
722
|
+
RotationSnapshot,
|
|
723
|
+
'isMainTank' | 'isOffTank' | 'myJob' | 'myCamp' | 'assignedHealSubgroup'
|
|
724
|
+
> => {
|
|
725
|
+
const sp = seatPlanOf(plan, f.side);
|
|
726
|
+
const job = sp?.job ?? 'dps';
|
|
727
|
+
const healSub = assignedHealSubgroupOf(plan, f.side);
|
|
728
|
+
return {
|
|
729
|
+
isMainTank: isMainTankSeat(plan, f.side),
|
|
730
|
+
isOffTank: isOffTankSeat(plan, f.side),
|
|
731
|
+
myJob: RAID_JOB_CODE[job],
|
|
732
|
+
myCamp: raidCampCode(campOfSeat(plan, f.side)),
|
|
733
|
+
...(healSub != null ? { assignedHealSubgroup: healSub } : {}),
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
// The main tank takes the first in-combat hostile and the off-tank the second when there is one,
|
|
738
|
+
// never an unpulled stream inject. On a one-boss pull the second is an add, so the off-tank peels.
|
|
739
|
+
const tankPlanFocus = (f: Fighter, foe: Fighter, plan: RaidPlan): Fighter => {
|
|
740
|
+
const idx = tankBossIndex(plan, f.side);
|
|
741
|
+
if (idx == null) return foe;
|
|
742
|
+
const pool = typeof ctx.freeFireHostilesFor === 'function'
|
|
743
|
+
? ctx.freeFireHostilesFor(f)
|
|
744
|
+
: livingHostilesOf(f);
|
|
745
|
+
const hostiles = pool.filter((h) => !dead(h) && isEnemySide(h.side)
|
|
746
|
+
&& (typeof ctx.enemyHasThreat !== 'function' || ctx.enemyHasThreat(h)));
|
|
747
|
+
if (hostiles.length <= 1) return foe;
|
|
748
|
+
if (idx > 0 && !tankFocusSplitsBosses(hostiles.map((h) => (h.spec.creatureEntry ?? 0) | 0))) {
|
|
749
|
+
return foe;
|
|
750
|
+
}
|
|
751
|
+
const sorted = hostiles.slice().sort((a, b) => {
|
|
752
|
+
const ka = sideIdSortKey(a.side);
|
|
753
|
+
const kb = sideIdSortKey(b.side);
|
|
754
|
+
return ka < kb ? -1 : ka > kb ? 1 : String(a.side).localeCompare(String(b.side));
|
|
755
|
+
});
|
|
756
|
+
return sorted[Math.min(idx, sorted.length - 1)] ?? foe;
|
|
757
|
+
};
|
|
758
|
+
const ccImmune = deps.ccImmune;
|
|
759
|
+
const spellTemplateImmune = deps.spellTemplateImmune;
|
|
760
|
+
const canAffordSpell = deps.canAffordSpell;
|
|
761
|
+
const spellCost = deps.spellCost;
|
|
762
|
+
const comboOn = deps.comboOn;
|
|
763
|
+
const foeOf = deps.foeOf;
|
|
764
|
+
const ensurePlayerFocus = deps.ensurePlayerFocus;
|
|
765
|
+
const lowestHpFriendly = deps.lowestHpFriendly;
|
|
766
|
+
// Faction-relative triage: the resolver applyHealEffect lands on. Cite: applyHealEffect.ts:104.
|
|
767
|
+
const lowestHpFriendlyOf = (f: Fighter): Fighter | null => ctx.lowestHpFriendlyOf?.(f) ?? lowestHpFriendly();
|
|
768
|
+
|
|
769
|
+
const onCooldown = (f: Fighter, spell: SpellDef): boolean => {
|
|
770
|
+
const t = ctx.t;
|
|
771
|
+
if ((f.cooldowns.get(spell.id) ?? 0) > t) return true;
|
|
772
|
+
const c = spell.category;
|
|
773
|
+
if (c != null && (f.categoryCooldowns.get(c) ?? 0) > t) return true;
|
|
774
|
+
return false;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
// StartRecoveryTime is the whole rule. The role fallback that lived here synthesized a GCD onto rows
|
|
778
|
+
// the DBC leaves off it, and it was a second copy of recovery.ts that drifted.
|
|
779
|
+
const canAfford = (f: Fighter, spell: SpellDef): boolean => canAffordSpell(f, spell);
|
|
780
|
+
|
|
781
|
+
/** Stealth / positional cast gates (Spell.cpp ONLY_STEALTHED / behind / front). */
|
|
782
|
+
const stealthPosError = (f: Fighter, foe: Fighter, spell: SpellDef): string | null => {
|
|
783
|
+
// Stealth enter and Charge refuse while in combat; Vanish is not flagged and may re-stealth. The
|
|
784
|
+
// combat flag is the real 5 s timer, so leaving combat lets the next engagement Charge again.
|
|
785
|
+
if (spell.notInCombatOnly && fighterIsInCombat(f, ctx.t)) return 'not in combat only';
|
|
786
|
+
if (spell.requiresStealth && !f.stealthed) return 'must be stealthed';
|
|
787
|
+
if (spell.requiresBehind && !behindOk(f, foe)) return 'not behind';
|
|
788
|
+
// The in-front facing gate is PLAYERS ONLY — the C++ test is `m_trueCaster->IsPlayer()`, so a
|
|
789
|
+
// creature never refuses on it and neither does a pet. Player-ness, not seat: a duel seats a
|
|
790
|
+
// player body on `enemy` too, and gating on the seat alone made facing one-sided (KI-1076).
|
|
791
|
+
// Cite: Spell.cpp:6249 SPELL_FAILED_UNIT_NOT_INFRONT; types.ts:143 classId is the player marker.
|
|
792
|
+
if (
|
|
793
|
+
spell.requiresFacing
|
|
794
|
+
&& (f.side === 'player' || isAllySide(f.side) || f.spec.classId != null)
|
|
795
|
+
&& ctx.isInFrontArc
|
|
796
|
+
&& !ctx.isInFrontArc(f, foe)
|
|
797
|
+
) return 'must be facing target';
|
|
798
|
+
// requiresFront: the eight Gouge ranks, with a real arc test now that geometry exists. BOTH units
|
|
799
|
+
// must have each other in front, and no geometry still allows.
|
|
800
|
+
if (spell.requiresFront && ctx.facingTargetsFront?.(f, foe) === false) return 'not in front';
|
|
801
|
+
return null;
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
/** Snapshot for optional condition gates (fights without conditions skip entirely). */
|
|
805
|
+
const conditionSnapshotFor = (f: Fighter): ConditionSnapshot => {
|
|
806
|
+
const cctx = f.spec.conditionCtx;
|
|
807
|
+
return {
|
|
808
|
+
level: f.spec.level,
|
|
809
|
+
auras: f.buffActive,
|
|
810
|
+
knownSpells: f.spec.spells.map((s) => s.id),
|
|
811
|
+
inCombat: fighterIsInCombat(f, ctx.t),
|
|
812
|
+
// Player-state suppliers captured at fight start (game.ts). Absent on mobs/tests →
|
|
813
|
+
// REPUTATION_RANK/SKILL/GAME_EVENT/TEAM/PVP_RANK conditions stay unsupported (fail closed).
|
|
814
|
+
...(cctx?.reputationRanks
|
|
815
|
+
? { reputationRank: (fid: number): number => cctx.reputationRanks![fid] ?? 0 }
|
|
816
|
+
: {}),
|
|
817
|
+
...(cctx?.skillValues
|
|
818
|
+
? { skillValue: (sid: number): number | undefined => cctx.skillValues![sid] }
|
|
819
|
+
: {}),
|
|
820
|
+
...(cctx?.activeEvents ? { activeEvents: cctx.activeEvents } : {}),
|
|
821
|
+
...(cctx?.activeHolidays ? { activeHolidays: cctx.activeHolidays } : {}),
|
|
822
|
+
...(cctx?.team != null ? { team: cctx.team } : {}),
|
|
823
|
+
...(cctx?.pvpRank != null ? { pvpRank: cctx.pvpRank } : {}),
|
|
824
|
+
...(cctx?.worldStates
|
|
825
|
+
? { worldState: (id: number): number => getVariable(cctx.worldStates, id) }
|
|
826
|
+
: {}),
|
|
827
|
+
worldScript: isConditionFulfilled,
|
|
828
|
+
};
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// School lockout gate (Object.cpp IsSpellReady CheckLockout branch). Only PreventionType == SILENCE
|
|
832
|
+
// spells are blocked; prefer baked field.
|
|
833
|
+
const schoolLocked = (f: Fighter, spell: SpellDef): boolean =>
|
|
834
|
+
isSpellSchoolLocked(spell, f.schoolLockoutUntil, ctx.t);
|
|
835
|
+
|
|
836
|
+
// Living pet for pet_heal / applyTo:'pet' target rules (FS3). Player → session pet; ally → the seat
|
|
837
|
+
// its own spec names (E1.1), no seat index.
|
|
838
|
+
const livingPetOf = (f: Fighter): Fighter | null => {
|
|
839
|
+
if (f.side === 'player') return pet && !dead(pet) ? pet : null;
|
|
840
|
+
if (isAllySide(f.side)) {
|
|
841
|
+
const ps = f.spec.petSide;
|
|
842
|
+
if (!ps) return null;
|
|
843
|
+
const mapped = ctx.bySide(ps);
|
|
844
|
+
return mapped && mapped.side === ps && !dead(mapped) ? mapped : null;
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
/** Spell needs a living pet target (Mend Pet role, or all buff effects are applyTo pet). */
|
|
850
|
+
const requiresLivingPetTarget = (spell: SpellDef): boolean => {
|
|
851
|
+
if (roleOf(spell) === 'pet_heal') return true;
|
|
852
|
+
const buffs = spell.effects.filter((e) => e.kind === 'buff' && !isHostileBuffEffect(e, e.amount));
|
|
853
|
+
return buffs.length > 0 && buffs.every((e) => e.kind === 'buff' && e.applyTo === 'pet');
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
// A unit is "engaged" for Charge when it is in the melee band of a living hostile, and Charge cannot
|
|
857
|
+
// re-fire while engaged. Without geometry nothing is engaged by distance, so the used flag alone gates.
|
|
858
|
+
const isInMeleeOf = (a: Fighter, b: Fighter): boolean => {
|
|
859
|
+
const d = distanceBetween(a, b);
|
|
860
|
+
if (d == null) return false; // no geometry → do not invent engagement
|
|
861
|
+
return d <= MIN_AGGRO_DISTANCE;
|
|
862
|
+
};
|
|
863
|
+
const livingHostilesOf = (f: Fighter): Fighter[] => livingHostilesFor(f);
|
|
864
|
+
const isEngagedMelee = (f: Fighter): boolean => {
|
|
865
|
+
if (!ctx.unitPositionsMutable) return false;
|
|
866
|
+
for (const h of livingHostilesOf(f)) {
|
|
867
|
+
if (isInMeleeOf(f, h)) return true;
|
|
868
|
+
}
|
|
869
|
+
return false;
|
|
870
|
+
};
|
|
871
|
+
// The Charge and Intercept detector, symmetric across sides. Prefer the raw charge opcode over the
|
|
872
|
+
// baked role: the real spells bake as control or strike, while fixtures use the role.
|
|
873
|
+
const isChargeRole = (spell: SpellDef): boolean => {
|
|
874
|
+
const hit = chargeRoleCache.get(spell);
|
|
875
|
+
if (hit !== undefined) return hit;
|
|
876
|
+
const v = roleOf(spell) === 'charge'
|
|
877
|
+
|| (spell.rawEffects?.some((r) => (r.effect | 0) === SPELL_EFFECT_CHARGE) ?? false)
|
|
878
|
+
// Free rage dump opener (Charge ranks) even when bake role is control/utility.
|
|
879
|
+
|| ((spell.rageGenerated ?? 0) > 0 && (spell.cost ?? 0) === 0
|
|
880
|
+
&& (spell.rangeMax ?? 0) > MIN_AGGRO_DISTANCE);
|
|
881
|
+
chargeRoleCache.set(spell, v);
|
|
882
|
+
return v;
|
|
883
|
+
};
|
|
884
|
+
// The Charge band. CheckRange uses SpellRange min and max; melee is SPELL_FAILED_TOO_CLOSE.
|
|
885
|
+
// Cite: Spell.cpp:6242-6246. No geometry still allows (legacy 1v1).
|
|
886
|
+
const chargeCastOk = (f: Fighter, foe: Fighter, spell: SpellDef): boolean => {
|
|
887
|
+
if (!isChargeRole(spell)) return true;
|
|
888
|
+
if (f.pendingCharge) return false;
|
|
889
|
+
// 'hold' bans motion and a charge IS motion — advanceChargeMove cancels the leap, so
|
|
890
|
+
// picking one here would spend the rage/CD on a step the unit may not take.
|
|
891
|
+
if (ctx.unitStance?.get(f.side) === 'hold') return false;
|
|
892
|
+
const dist = distanceBetween(f, foe);
|
|
893
|
+
if (dist == null) return true;
|
|
894
|
+
// Other storey: planar 8–25 is a ledge Charge. Walk the ramp instead.
|
|
895
|
+
if (!planarRangeHonestYd(ctx.posOf?.(f.side), ctx.posOf?.(foe.side))) return false;
|
|
896
|
+
return isInSpellRange(
|
|
897
|
+
dist, spellGateRangeMaxYd(spell), spellGateRangeMinYd(spell),
|
|
898
|
+
ctx.isMoving(f.side) && ctx.isMoving(foe.side),
|
|
899
|
+
f.spec.combatReachYd, foe.spec.combatReachYd,
|
|
900
|
+
);
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
// A local-combat backstop: an offensive spell with a MISSING baked range skips the range gate and used
|
|
904
|
+
// to fire cross-map. Melee-shaped picks cap at reach and the rest at the acquire radius.
|
|
905
|
+
const beyondAcquireRange = (f: Fighter, foe: Fighter, spell: SpellDef): boolean => {
|
|
906
|
+
const acq = cfg.acquireRangeYd;
|
|
907
|
+
if (acq == null) return false;
|
|
908
|
+
if (selfCenteredOf(spell)) return false;
|
|
909
|
+
const meleeShaped = !!spell.strikeBonus || !!spell.onNextSwing || roleOf(spell) === 'strike';
|
|
910
|
+
if (!meleeShaped && !isHostileSpellDef(spell) && roleOf(spell) !== 'shoot'
|
|
911
|
+
&& !spellFoeDirectedOpaque(spell)) return false;
|
|
912
|
+
const d = distanceBetween(f, foe);
|
|
913
|
+
const cap = Math.max(
|
|
914
|
+
meleeShaped && !((spell.rangeMax ?? 0) > 0) ? MIN_AGGRO_DISTANCE : acq,
|
|
915
|
+
spell.rangeMax ?? 0,
|
|
916
|
+
);
|
|
917
|
+
return d != null && d > cap;
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
/** Combat roles that require a living hostile (not self/stand-down sentinel). */
|
|
921
|
+
const needsLiveHostile = (spell: SpellDef): boolean => {
|
|
922
|
+
if (selfCenteredOf(spell)) return false;
|
|
923
|
+
if (isHostileSpellDef(spell)) return true;
|
|
924
|
+
const r = roleOf(spell);
|
|
925
|
+
return r === 'nuke' || r === 'instant_nuke' || r === 'dot' || r === 'shoot'
|
|
926
|
+
|| r === 'strike' || r === 'charge' || r === 'judgement';
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
/** Judgement / Holy Shock geometry gate. Cite: predicates.spellFoeDirectedGeometry. */
|
|
930
|
+
const foeDirectedOpaqueCast = spellFoeDirectedGeometry;
|
|
931
|
+
|
|
932
|
+
// When each unit's current mechanic run-out started, so one that cannot succeed stops eating the GCD.
|
|
933
|
+
// In a 40-man stacked on a boss the row's own end condition is never true — 4 of 5 windows, 0 casts.
|
|
934
|
+
const runoutSince = new Map<Side, { kind: string; at: number }>();
|
|
935
|
+
/** Live halt window per side, so `halt_dps` is pushed once per window and not once per tick. */
|
|
936
|
+
const haltSince = new Map<Side, string>();
|
|
937
|
+
const RUNOUT_YIELD_AFTER_MS = 4_000;
|
|
938
|
+
|
|
939
|
+
// Does this move still own the unit past the give-up? The MECHANIC answers, not a name list: a bomb is
|
|
940
|
+
// clear after 4 s, while a stance row is its whole phase and an avoid lives as long as its hazard.
|
|
941
|
+
const moveOwnsPastDeadline = (f: Fighter, move: RuleMove): boolean => {
|
|
942
|
+
if (move.kind === 'keep_range' || move.kind === 'spread') return true;
|
|
943
|
+
// A walk to a bag object owns the unit while that object is still worth pressing, for the same reason
|
|
944
|
+
// an avoid does. A gnomeface button is ~40 yd off, so the flat give-up abandoned every press.
|
|
945
|
+
if (move.kind === 'move_to') {
|
|
946
|
+
const go = f.pendingObjectClick;
|
|
947
|
+
return go != null && combatObjectBag.includes(go | 0)
|
|
948
|
+
&& objectActionable(go);
|
|
949
|
+
}
|
|
950
|
+
if (move.kind !== 'avoid_zone') return false;
|
|
951
|
+
return hostileHazardsThisTick(ctx, dead).some((h) => hazardMatches(h, move.id));
|
|
952
|
+
};
|
|
953
|
+
|
|
954
|
+
const canCastSpell = (
|
|
955
|
+
f: Fighter, foe: Fighter, spell: SpellDef,
|
|
956
|
+
// Skip ONLY the shapeshift gate, so a caller can ask "ready but for the stance". Every other
|
|
957
|
+
// refusal stands. Cite: stancePrereq.ts:22 stanceIntentForPress, the press-side twin of this.
|
|
958
|
+
opts?: { ignoreForm?: boolean },
|
|
959
|
+
): boolean => {
|
|
960
|
+
const t = ctx.t;
|
|
961
|
+
if (isNonCastableTalentProc(spell)) return false;
|
|
962
|
+
// A live channel OCCUPIES the caster until it ends. Beginning one sets no cast bar, so the GCD
|
|
963
|
+
// released the caster halfway through and the next pick cancelled it. An instant kick still clips.
|
|
964
|
+
if (f.channeling && t < f.channeling.endsAt && !interruptCastOf(spell) && !hasAttrEx4(spell, SPELL_ATTR_EX4.ALLOW_CAST_WHILE_CASTING)) return false;
|
|
965
|
+
if (!canAfford(f, spell) || onCooldown(f, spell)) return false;
|
|
966
|
+
if (schoolLocked(f, spell)) return false;
|
|
967
|
+
// Spell.cpp:4604-4612 CheckCast SPELL_FAILED_ONLY_OUTDOORS. `outdoors` is absent on every
|
|
968
|
+
// surface but a measured-indoors dungeon pull, and absent is outdoors.
|
|
969
|
+
if (!spellAllowedAtLocation(spell, cfg.outdoors !== false, cfg.battlegroundMap)) return false;
|
|
970
|
+
// Player EquippedItem gate — Backstab with mace was canCast true then startOrCast silent
|
|
971
|
+
// no-op every GCD (KI-604). Cite: Spell.cpp:6539–6543; equippedItemGate.ts; stepSession.
|
|
972
|
+
if (isPlayerCharacterSide(f.side) && playerMissingRequiredEquippedItem(spell, f.spec)) return false;
|
|
973
|
+
// Spell.cpp CheckItems — Soul Shard reagents etc. (KI-804 Shadowburn).
|
|
974
|
+
if (!canPaySpellReagents(f, spell)) return false;
|
|
975
|
+
// Spell.cpp CheckTargetCreatureType — don't pick a hostile spell this target can't receive.
|
|
976
|
+
if (isHostileSpellDef(spell) && !selfCenteredOf(spell) && !spellTargetTypeAllowed(spell, foe.creatureType)) return false;
|
|
977
|
+
// Skip abilities the focus will be immune to, weapon strikes included — a Heroic Strike into a
|
|
978
|
+
// Blessing of Protection is refused here rather than fizzling. Cite: Unit.cpp IsImmuneToSpell.
|
|
979
|
+
if (focusImmunityBlocksSpell(
|
|
980
|
+
spell,
|
|
981
|
+
foe,
|
|
982
|
+
selfCenteredOf(spell) ? blastEnemyCount() : 1,
|
|
983
|
+
)) return false;
|
|
984
|
+
// The foe lookup hands the caster ITSELF back as the stand-down sentinel when no hostile lives, so a
|
|
985
|
+
// hostile cast needs a live non-self target. A self-centred AoE still fans with no focus.
|
|
986
|
+
if (needsLiveHostile(spell) && (foe === f || (dead(foe) && !hasAttrEx2(spell, SPELL_ATTR_EX2.ALLOW_DEAD_TARGET)))) return false;
|
|
987
|
+
// FS3: pet-only heals/buffs refuse without a living pet (no silent self-apply).
|
|
988
|
+
if (requiresLivingPetTarget(spell) && !livingPetOf(f)) return false;
|
|
989
|
+
if (spell.requiresComboPoints && comboOn(f, foe) <= 0) return false;
|
|
990
|
+
// Form-only abilities blocked; NOT_SHAPESHIFT leaves form then casts (resolveFormCast).
|
|
991
|
+
if (opts?.ignoreForm !== true && !resolveFormCast(spell, f.form).ok) return false;
|
|
992
|
+
if (stealthPosError(f, foe, spell)) return false;
|
|
993
|
+
// Howl is dest-at-caster (rangeMax 0), so the yard gate below never sees it. Melee already
|
|
994
|
+
// has Hamstring; a live snare is the keep-up. Cite: classify.ts piercingHowlRefused.
|
|
995
|
+
if (isPiercingHowlSpell(spell) && foe !== f && !dead(foe)) {
|
|
996
|
+
const howlDist = distanceBetween(f, foe);
|
|
997
|
+
if (piercingHowlRefused({
|
|
998
|
+
inMelee: inMeleeBand(howlDist ?? null, !!ctx.unitPositionsMutable),
|
|
999
|
+
snared: controlActive(foe, 'snare') === true,
|
|
1000
|
+
snareImmune: isImmuneToMechanic(foe.immunity, 'snare', {
|
|
1001
|
+
mechanicImmuneMask: foe.spec.mechanicImmuneMask,
|
|
1002
|
+
}),
|
|
1003
|
+
distanceYd: howlDist ?? null,
|
|
1004
|
+
radiusYd: spell.radiusYd,
|
|
1005
|
+
})) return false;
|
|
1006
|
+
}
|
|
1007
|
+
// Spell range plus the charge engagement, symmetric across sides. A non-positive rangeMax is no yard
|
|
1008
|
+
// limit, and an undefined distance allows. Cite: combatGeometry.isInSpellRange.
|
|
1009
|
+
if (isChargeRole(spell)) {
|
|
1010
|
+
// Warrior Charge is NOT_IN_COMBAT_ONLY; pet Charge / Intercept recast on CD.
|
|
1011
|
+
// Cite: Spell.cpp:4598 IsNonCombatSpell; spell 7371 attributes lack 0x10000000.
|
|
1012
|
+
if (f.chargeUsed && spell.notInCombatOnly) return false;
|
|
1013
|
+
if (!chargeCastOk(f, foe, spell)) return false;
|
|
1014
|
+
} else if ((spellNeedsRangeGate(spell) || foeDirectedOpaqueCast(spell))
|
|
1015
|
+
&& (!planarRangeHonestYd(ctx.posOf?.(f.side), ctx.posOf?.(foe.side))
|
|
1016
|
+
|| !isInSpellRange(
|
|
1017
|
+
distanceBetween(f, foe), spellGateRangeMaxYd(spell), spellGateRangeMinYd(spell),
|
|
1018
|
+
))) {
|
|
1019
|
+
return false;
|
|
1020
|
+
} else if (beyondAcquireRange(f, foe, spell)) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
// Hostile casts through walls fail when navLos is on (the free-walk floor turns it on).
|
|
1024
|
+
// Cite: Spell.cpp:4742 CheckCast; UnitAI IsWithinLOSInMap.
|
|
1025
|
+
if (cfg.navLos && spellNeedsLosGate(spell)) {
|
|
1026
|
+
const positions = ctx.unitPositionsMutable;
|
|
1027
|
+
if (!navSightClear(cfg, positions?.[f.side], positions?.[foe.side])) return blockedBySight(f);
|
|
1028
|
+
}
|
|
1029
|
+
// The same gate for a cast aimed at an ALLY. Off-mesh ends and a map with no collision bake still
|
|
1030
|
+
// fail open, so this refuses only where real geometry says rock. Cite: Spell.cpp:4742.
|
|
1031
|
+
if (cfg.navLos && spellNeedsFriendlyLosGate(spell)) {
|
|
1032
|
+
const ally = healTargetOf(f);
|
|
1033
|
+
if (ally && ally !== f) {
|
|
1034
|
+
const positions = ctx.unitPositionsMutable;
|
|
1035
|
+
if (!navSightClear(cfg, positions?.[f.side], positions?.[ally.side])) return blockedBySight(f);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
// Conditions: only when spell carries condition/conditionId (no data → always pass).
|
|
1039
|
+
if (spell.condition || (spell.conditionId != null && spell.conditionId !== 0)) {
|
|
1040
|
+
if (!passesConditionGate(spell, conditionSnapshotFor(f), cfg.conditionTable).ok) return false;
|
|
1041
|
+
}
|
|
1042
|
+
// Reactive CasterAuraState / Overpower windows (Mongoose Bite after self dodge, …).
|
|
1043
|
+
// Cite: Player.cpp IsSpellReady CasterAuraState; UnitAuraProcHandler ProcSkillsAndReactives.
|
|
1044
|
+
if (reactiveCastGate(f, foe, spell, t) != null) return false;
|
|
1045
|
+
// MaxTargetLevel / ONLY_PEACEFUL_TARGETS / EX3_ONLY_ON_PLAYER. Cite: castTargetGates.ts.
|
|
1046
|
+
if (targetCastGate(f, foe, spell, t) != null) return false;
|
|
1047
|
+
// Capture Spirit 7914 entry allowlist. Cite: Spell.cpp:7914–7920; captureSpiritAllowsEntry.
|
|
1048
|
+
if ((spell.id | 0) === 7914 && !captureSpiritAllowsEntry((foe.spec.creatureEntry ?? 0) | 0)) {
|
|
1049
|
+
return false;
|
|
1050
|
+
}
|
|
1051
|
+
// Cite: Spell.cpp:4655–4686 HasOverrideScript 3654/3655/4327.
|
|
1052
|
+
if (refuseOverrideClassScriptCheckCast(spell, f.buffActive)) return false;
|
|
1053
|
+
// Inspect the cast gates applySpell already refuses — Drain Mana, the class calls, the AQ mounts —
|
|
1054
|
+
// without stamping any flag here. A friendly cast checks the CASTER when the focus is hostile.
|
|
1055
|
+
const ccSid = spell.id | 0;
|
|
1056
|
+
const bandageLock = spell.mechanic === 'bandage' && ccSid !== RECENTLY_BANDAGED_SPELL_ID;
|
|
1057
|
+
const checkTgt = (ccSid === 10060 || ccSid === 19752 || ccSid === 19754) ? f
|
|
1058
|
+
: bandageLock
|
|
1059
|
+
? selectedOrSelfFriendly(
|
|
1060
|
+
f, foe, f.friendlyPin ? ctx.bySide(f.friendlyPin) : null, dead, isEnemySide,
|
|
1061
|
+
)
|
|
1062
|
+
: foe;
|
|
1063
|
+
if (refuseCmangosCheckCast(spell, {
|
|
1064
|
+
flags: checkTgt.spec.flags,
|
|
1065
|
+
creatureEntry: checkTgt.spec.creatureEntry,
|
|
1066
|
+
powerType: checkTgt.spec.powerType,
|
|
1067
|
+
classId: checkTgt.spec.classId,
|
|
1068
|
+
auraSpellIds: checkTgt.buffActive,
|
|
1069
|
+
isPlayer: isPlayerCharacterTarget(checkTgt),
|
|
1070
|
+
}, {
|
|
1071
|
+
mapId: f.spec.mapId,
|
|
1072
|
+
hasActivePet: !!livingPetOf(f) || isCharmed(f),
|
|
1073
|
+
}, false)) return false;
|
|
1074
|
+
|
|
1075
|
+
// Cite: Spell.cpp:5001-5007 if player caster has mount and !triggered && !passive && !HasAttribute(ALLOW_WHILE_MOUNTED) -> SPELL_FAILED_NOT_MOUNTED
|
|
1076
|
+
if (f.side === 'player' && f.spec.mount != null && !isTriggeredCompanion(f, spell) && !hasAttr(spell, SPELL_ATTR.PASSIVE) && !hasAttr(spell, SPELL_ATTR.ALLOW_WHILE_MOUNTED)) {
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
return true;
|
|
1081
|
+
};
|
|
1082
|
+
// Spells appended only so a trigger, talent or gear proc can RESOLVE them; parents fire them, so they
|
|
1083
|
+
// must never be auto-selected. The kit-derived half is memoized — it was 4.6% of a 46-body profile.
|
|
1084
|
+
const isTriggeredCompanion = (f: Fighter, s: SpellDef): boolean =>
|
|
1085
|
+
triggeredIdsOf(f.spec.spells).has(s.id)
|
|
1086
|
+
|| f.procs.some((p) => p.triggerSpellId === s.id);
|
|
1087
|
+
|
|
1088
|
+
const find = (f: Fighter, role: SpellRole): SpellDef | undefined =>
|
|
1089
|
+
f.spec.spells.find((s) => roleOf(s) === role && !isTriggeredCompanion(f, s));
|
|
1090
|
+
|
|
1091
|
+
// ids and stacks in ONE pass. The pair used to be a Set built from a map plus a second walk, so three
|
|
1092
|
+
// iterations and a throwaway array per aura list, twice per subject, for every unit every tick.
|
|
1093
|
+
const auraIdsAndStacks = (
|
|
1094
|
+
auras: ReadonlyArray<{ spellId: number; remainingMs?: number; stacks?: number }>,
|
|
1095
|
+
): { ids: Set<number>; stacks: Map<number, number>; remaining: Map<number, number> | null } => {
|
|
1096
|
+
const ids = new Set<number>();
|
|
1097
|
+
const stacks = new Map<number, number>();
|
|
1098
|
+
// Allocated on the first FINITE remainingMs, so a bar of permanents costs nothing extra and a
|
|
1099
|
+
// missing entry keeps its evaluate.ts meaning: permanent.
|
|
1100
|
+
let remaining: Map<number, number> | null = null;
|
|
1101
|
+
for (const a of auras) {
|
|
1102
|
+
ids.add(a.spellId);
|
|
1103
|
+
if ((a.stacks ?? 1) > 1) stacks.set(a.spellId, a.stacks!);
|
|
1104
|
+
const rem = a.remainingMs;
|
|
1105
|
+
if (rem != null && Number.isFinite(rem)) (remaining ??= new Map()).set(a.spellId, Math.max(0, rem));
|
|
1106
|
+
}
|
|
1107
|
+
return { ids, stacks, remaining };
|
|
1108
|
+
};
|
|
1109
|
+
// Spell ids in the caster's kit whose hostile aura cannot land on this unit. Only computed for a
|
|
1110
|
+
// hostile subject: a keep-up gated on the aura's absence is otherwise unsatisfiable and re-fires.
|
|
1111
|
+
const auraMechSlots = new WeakMap<SpellDef, ReadonlyArray<{ mechanic: number }>>();
|
|
1112
|
+
const mechanicAuraSlotsOf = (sp: SpellDef): ReadonlyArray<{ mechanic: number }> => {
|
|
1113
|
+
let hit = auraMechSlots.get(sp);
|
|
1114
|
+
if (!hit) {
|
|
1115
|
+
const slots = sp.rawEffects ?? [];
|
|
1116
|
+
hit = slots.filter((r) => (r.effect | 0) === 6 && (r.mechanic | 0) > 0);
|
|
1117
|
+
auraMechSlots.set(sp, hit);
|
|
1118
|
+
}
|
|
1119
|
+
return hit;
|
|
1120
|
+
};
|
|
1121
|
+
type ImmunityIds = { auras?: Set<number>; spells?: Set<number> };
|
|
1122
|
+
const NO_IMMUNITY: ImmunityIds = {};
|
|
1123
|
+
const computeTargetImmunityIds = (u: Fighter, caster: Fighter): ImmunityIds => {
|
|
1124
|
+
const mechMask = u.spec.mechanicImmuneMask ?? 0;
|
|
1125
|
+
const imm = u.immunity;
|
|
1126
|
+
const totem = isTotemCreatureType(u.creatureType) || isTotemCreatureType(u.spec.creatureType)
|
|
1127
|
+
|| isTotemSide(u.side);
|
|
1128
|
+
// No template mask and no live immunity → every hostile aura can land; skip the kit walk.
|
|
1129
|
+
// Totems carry empty masks but Totem.cpp still eats every negative aura.
|
|
1130
|
+
if (
|
|
1131
|
+
mechMask === 0
|
|
1132
|
+
&& !(u.spec.schoolImmuneMask)
|
|
1133
|
+
&& !imm.schoolImmuneMask
|
|
1134
|
+
&& !imm.damageImmuneMask
|
|
1135
|
+
&& !imm.mechanicImmuneMask
|
|
1136
|
+
&& !imm.dispelImmuneMask
|
|
1137
|
+
&& !totem
|
|
1138
|
+
) return NO_IMMUNITY;
|
|
1139
|
+
let auras: Set<number> | undefined;
|
|
1140
|
+
let spells: Set<number> | undefined;
|
|
1141
|
+
const template = {
|
|
1142
|
+
schoolImmuneMask: u.spec.schoolImmuneMask,
|
|
1143
|
+
mechanicImmuneMask: u.spec.mechanicImmuneMask,
|
|
1144
|
+
creatureType: u.spec.creatureType ?? u.creatureType,
|
|
1145
|
+
};
|
|
1146
|
+
for (const sp of caster.spec.spells) {
|
|
1147
|
+
const ineffective = isAbilityIneffectiveOnTarget(u.immunity, sp, template);
|
|
1148
|
+
let auraBlocked = ineffective;
|
|
1149
|
+
// Per-effect mechanic (live FAP mask OR template): Hamstring's hit still lands on a snare-immune
|
|
1150
|
+
// target, so the whole-spell test cannot be the keep-up skip.
|
|
1151
|
+
const auraMechMask = mechMask | (imm.mechanicImmuneMask ?? 0);
|
|
1152
|
+
if (!auraBlocked && auraMechMask !== 0) {
|
|
1153
|
+
const auraSlots = mechanicAuraSlotsOf(sp);
|
|
1154
|
+
auraBlocked = auraSlots.length > 0
|
|
1155
|
+
&& auraSlots.every((r) => (auraMechMask & (1 << ((r.mechanic | 0) - 1))) !== 0);
|
|
1156
|
+
}
|
|
1157
|
+
// Totem.cpp:282–287 immune to all negative auras (Wing Clip snare, Serpent Sting, Fear).
|
|
1158
|
+
if (!auraBlocked && totem && spellAppliesHostileAura(sp)) auraBlocked = true;
|
|
1159
|
+
if (auraBlocked) (auras ??= new Set()).add(sp.id);
|
|
1160
|
+
// Self-centered hostile AoE (Piercing Howl) is not foe-directed, so the old filter left
|
|
1161
|
+
// Howl out of immuneSpellIds and the snared==0 keep-up recast into Free Action forever.
|
|
1162
|
+
if (
|
|
1163
|
+
ineffective
|
|
1164
|
+
&& (isFoeDirectedOffense(sp) || (selfCenteredOf(sp) && isHostileSpellDef(sp)))
|
|
1165
|
+
) {
|
|
1166
|
+
(spells ??= new Set()).add(sp.id);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
return { auras, spells };
|
|
1170
|
+
};
|
|
1171
|
+
interface ImmunityIdsEntry {
|
|
1172
|
+
immunity: Fighter['immunity'];
|
|
1173
|
+
spells: Fighter['spec']['spells'];
|
|
1174
|
+
spec: Fighter['spec'];
|
|
1175
|
+
value: ImmunityIds;
|
|
1176
|
+
}
|
|
1177
|
+
// Pure in (caster kit, u.spec, u.immunity) and every .immunity write replaces the object, so the
|
|
1178
|
+
// ref is the version. 6.3% of all JS (Firefox 40-man MC 4x); result Sets are ReadonlySet.
|
|
1179
|
+
const immunityIdsCache = new WeakMap<Fighter, WeakMap<Fighter, ImmunityIdsEntry>>();
|
|
1180
|
+
const targetImmunityIdsFor = (u: Fighter, caster: Fighter | null): ImmunityIds => {
|
|
1181
|
+
if (!caster || caster === u) return NO_IMMUNITY;
|
|
1182
|
+
let byTarget = immunityIdsCache.get(caster);
|
|
1183
|
+
if (byTarget === undefined) {
|
|
1184
|
+
byTarget = new WeakMap<Fighter, ImmunityIdsEntry>();
|
|
1185
|
+
immunityIdsCache.set(caster, byTarget);
|
|
1186
|
+
}
|
|
1187
|
+
const hit = byTarget.get(u);
|
|
1188
|
+
if (
|
|
1189
|
+
hit !== undefined
|
|
1190
|
+
&& hit.immunity === u.immunity
|
|
1191
|
+
&& hit.spells === caster.spec.spells
|
|
1192
|
+
&& hit.spec === u.spec
|
|
1193
|
+
) return hit.value;
|
|
1194
|
+
const value = computeTargetImmunityIds(u, caster);
|
|
1195
|
+
byTarget.set(u, { immunity: u.immunity, spells: caster.spec.spells, spec: u.spec, value });
|
|
1196
|
+
return value;
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
const manaPctOf = (v: CombatantView): { manaPct?: number } => {
|
|
1200
|
+
const maxMana = v.maxMana ?? 0;
|
|
1201
|
+
if (maxMana > 0) return { manaPct: ((v.mana ?? 0) / maxMana) * 100 };
|
|
1202
|
+
if (v.powerType !== 'mana') return {};
|
|
1203
|
+
return { manaPct: v.maxPower > 0 ? (v.power / v.maxPower) * 100 : 0 };
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
// Kit ids this unit can pay for right now, off the same canAffordSpell the pick path spends
|
|
1207
|
+
// through — so a `canAfford` row and the cast it guards can never disagree.
|
|
1208
|
+
const affordableIdsOf = (u: Fighter): Set<number> => {
|
|
1209
|
+
const out = new Set<number>();
|
|
1210
|
+
for (const s of u.spec.spells ?? []) if (canAffordSpell(u, s)) out.add(s.id);
|
|
1211
|
+
return out;
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
const subjectFromView = (
|
|
1215
|
+
u: Fighter,
|
|
1216
|
+
caster?: Fighter | null,
|
|
1217
|
+
priceKit = false,
|
|
1218
|
+
): SubjectState => {
|
|
1219
|
+
const v = view(u);
|
|
1220
|
+
const buffs = auraIdsAndStacks(v.buffs);
|
|
1221
|
+
// The view hides a trap arm (a ground object, not a bar chip), so a `buffPresent(trap)` keep-up
|
|
1222
|
+
// re-laid it every cooldown at the hunter's feet. The gate reads the armed trap; the bar does not.
|
|
1223
|
+
if (unitIsHunter(u)) {
|
|
1224
|
+
for (const e of ctx.effectsOn(u.side)) {
|
|
1225
|
+
if (isHunterTrapArmProc(e) && e.expiresAt > ctx.t && (e.charges == null || e.charges > 0)) {
|
|
1226
|
+
buffs.ids.add(e.spellId);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
const debuffs = auraIdsAndStacks(v.debuffs);
|
|
1231
|
+
const flag = flagAuraId(buffs.ids);
|
|
1232
|
+
const immune = caster ? targetImmunityIdsFor(u, caster) : {};
|
|
1233
|
+
return {
|
|
1234
|
+
...(immune.auras ? { immuneAuraSpellIds: immune.auras } : {}),
|
|
1235
|
+
...(immune.spells ? { immuneSpellIds: immune.spells } : {}),
|
|
1236
|
+
alive: v.hp > 0,
|
|
1237
|
+
healthPct: v.maxHp > 0 ? (v.hp / v.maxHp) * 100 : 0,
|
|
1238
|
+
powerPct: v.maxPower > 0 ? (v.power / v.maxPower) * 100 : 0,
|
|
1239
|
+
// The mana bar even while the ACTIVE bar is energy or rage — the dual-bar druid. A unit whose
|
|
1240
|
+
// power already IS mana has no second bar, so powerPct is the same number.
|
|
1241
|
+
...manaPctOf(v),
|
|
1242
|
+
// Priced only for the acting unit, and only when a rule named the metric — see ProviderNeeds.
|
|
1243
|
+
...(priceKit ? { affordableSpellIds: affordableIdsOf(u) } : {}),
|
|
1244
|
+
level: u.spec.level,
|
|
1245
|
+
buffs: buffs.ids,
|
|
1246
|
+
debuffs: debuffs.ids,
|
|
1247
|
+
comboPoints: v.comboPoints ?? 0,
|
|
1248
|
+
// The live DR ladder this unit stands on, one step per group, using the same expiry test the apply
|
|
1249
|
+
// path does. Built ONLY when the unit has a record — an unconditional map cost 1.9 tick points.
|
|
1250
|
+
...(u.dr.size > 0
|
|
1251
|
+
? {
|
|
1252
|
+
drSteps: DIMINISHING_GROUP_ORDER.map((g) => {
|
|
1253
|
+
const rec = u.dr.get(g);
|
|
1254
|
+
return rec && rec.resetAt > ctx.t ? rec.step : 0;
|
|
1255
|
+
}),
|
|
1256
|
+
}
|
|
1257
|
+
: {}),
|
|
1258
|
+
formId: (v.form ?? 0) | 0,
|
|
1259
|
+
casting: v.casting
|
|
1260
|
+
? {
|
|
1261
|
+
school: schoolIndexFromSchool(v.casting.school),
|
|
1262
|
+
isHeal: v.casting.isHeal ?? false,
|
|
1263
|
+
spellId: (v.casting.spellId | 0) || undefined,
|
|
1264
|
+
}
|
|
1265
|
+
: null,
|
|
1266
|
+
buffStacks: buffs.stacks,
|
|
1267
|
+
debuffStacks: debuffs.stacks,
|
|
1268
|
+
...(buffs.remaining ? { buffRemainingMs: buffs.remaining } : {}),
|
|
1269
|
+
...(debuffs.remaining ? { debuffRemainingMs: debuffs.remaining } : {}),
|
|
1270
|
+
dodgedRecently: v.dodgedRecently ?? false,
|
|
1271
|
+
defenseReactRecently: v.defenseReactRecently ?? false,
|
|
1272
|
+
hunterParryRecently: v.hunterParryRecently ?? false,
|
|
1273
|
+
inMelee: false,
|
|
1274
|
+
creatureType: u.creatureType || v.creatureType,
|
|
1275
|
+
isCaster: unitIsCaster(u, ctx.spellOf),
|
|
1276
|
+
isHunter: unitIsHunter(u),
|
|
1277
|
+
classId: unitClassId(u),
|
|
1278
|
+
...(flag != null ? { carryingFlag: flag } : {}),
|
|
1279
|
+
charmed: v.controlled?.control === 'charm',
|
|
1280
|
+
groupRole: u.spec.groupRole,
|
|
1281
|
+
// W10 CC metrics — these gate a trinket / Free Action Potion rotation row; a rotation
|
|
1282
|
+
// cannot decide to break CC it cannot see. Declared since W10, filled never (until now).
|
|
1283
|
+
// silenced: Spell.cpp:6049-6050 UNIT_FLAG_SILENCED -> SPELL_FAILED_SILENCED.
|
|
1284
|
+
rooted: u.rootedUntil > ctx.t,
|
|
1285
|
+
stunned: u.stunnedUntil > ctx.t,
|
|
1286
|
+
feared: u.fearedUntil > ctx.t,
|
|
1287
|
+
snared: controlActive(u, 'snare') === true,
|
|
1288
|
+
silenced: u.silencedUntil > ctx.t,
|
|
1289
|
+
};
|
|
1290
|
+
};
|
|
1291
|
+
const prunePartyInterruptClaims = (): void => {
|
|
1292
|
+
pruneInterruptClaims(
|
|
1293
|
+
ctx.interruptClaim,
|
|
1294
|
+
(id) => {
|
|
1295
|
+
const u = ctx.bySide(id as Side);
|
|
1296
|
+
return !!u && !dead(u);
|
|
1297
|
+
},
|
|
1298
|
+
(id) => {
|
|
1299
|
+
const u = ctx.bySide(id as Side);
|
|
1300
|
+
return !!u && unitIsCastingOrChanneling(u, ctx.t);
|
|
1301
|
+
},
|
|
1302
|
+
);
|
|
1303
|
+
};
|
|
1304
|
+
/** Another party member already owns the kick on this casting target. */
|
|
1305
|
+
const interruptClaimedByOther = (targetId: CombatantId, actorId: CombatantId): boolean => {
|
|
1306
|
+
prunePartyInterruptClaims();
|
|
1307
|
+
const owner = ctx.interruptClaim.get(targetId);
|
|
1308
|
+
return owner != null && owner !== actorId;
|
|
1309
|
+
};
|
|
1310
|
+
/** Another party member already owns this (target, dispel category). See dispelDuty.ts. */
|
|
1311
|
+
const dispelCatClaimedByOther = (
|
|
1312
|
+
targetId: CombatantId,
|
|
1313
|
+
actorId: CombatantId,
|
|
1314
|
+
cat: number,
|
|
1315
|
+
): boolean => dispelClaimedByOtherSeat(ctx, targetId, actorId, cat, dead);
|
|
1316
|
+
const prunePartyRezClaims = (): void => {
|
|
1317
|
+
pruneRezClaims(
|
|
1318
|
+
ctx.rezClaim,
|
|
1319
|
+
(id) => {
|
|
1320
|
+
const u = ctx.bySide(id as Side);
|
|
1321
|
+
// Same predicate the pick uses, so a claim cannot outlive the body being a valid target.
|
|
1322
|
+
return !!u && isRezzableCorpse(u);
|
|
1323
|
+
},
|
|
1324
|
+
// The claim is taken in startOrCast, never at pick time, so requiring a live cast bar has
|
|
1325
|
+
// no gap to cover — and an interrupted Rebirth must free its body for the next druid.
|
|
1326
|
+
(id) => {
|
|
1327
|
+
const u = ctx.bySide(id as Side);
|
|
1328
|
+
return !!u && !dead(u) && unitIsCastingOrChanneling(u, ctx.t);
|
|
1329
|
+
},
|
|
1330
|
+
);
|
|
1331
|
+
};
|
|
1332
|
+
// Every corpse this caster could stand up is already claimed by another live member, so the row falls
|
|
1333
|
+
// through rather than spending a 30 min cooldown on a body that will be up before the cast lands.
|
|
1334
|
+
const battleRezClaimedByOther = (f: Fighter, s: SpellDef): boolean => {
|
|
1335
|
+
if (!isBattleRezSpell(s)) return false;
|
|
1336
|
+
// Party-side only, and the enemy arm this replaces was dead code: no built creature kit carries a
|
|
1337
|
+
// battle rez, and the resurrect effect refuses a non-player target anyway.
|
|
1338
|
+
if (isEnemySide(f.side)) return false;
|
|
1339
|
+
prunePartyRezClaims();
|
|
1340
|
+
return pickBattleRezTarget(ctx.partyUnits(), f, (c) => ctx.rezClaim.get(c.side)) == null;
|
|
1341
|
+
};
|
|
1342
|
+
const dispelSpellClaimedByOther = (f: Fighter, foe: Fighter, s: SpellDef): boolean => {
|
|
1343
|
+
if (!isDispelSpell(s)) return false;
|
|
1344
|
+
let any = false;
|
|
1345
|
+
let unclaimed = false;
|
|
1346
|
+
for (const e of s.effects) {
|
|
1347
|
+
if (e.kind !== 'dispel') continue;
|
|
1348
|
+
const res = resolveDispelTarget(f, foe, e.dispelType);
|
|
1349
|
+
if (!res) continue;
|
|
1350
|
+
any = true;
|
|
1351
|
+
if (!dispelCatClaimedByOther(res.unit.side, f.side, e.dispelType)) unclaimed = true;
|
|
1352
|
+
}
|
|
1353
|
+
return any && !unclaimed;
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
// Which dispel categories a companion could strip right now, so a cure rule only fires when it would
|
|
1357
|
+
// do something. Hoisted out of the per-ally body: the friendly answer is a property of the POOL.
|
|
1358
|
+
const EMPTY_CATS: ReadonlySet<number> = new Set<number>();
|
|
1359
|
+
const EMPTY_KEEP: ReadonlySet<number> = new Set<number>();
|
|
1360
|
+
const friendlyDispelCats = new Map<string, Set<number>>();
|
|
1361
|
+
const targetDispelCats = new Map<string, Set<number>>();
|
|
1362
|
+
let dispelCacheTick = -1;
|
|
1363
|
+
let keepAuraTick = -1;
|
|
1364
|
+
const refreshKeepAuras = (): void => {
|
|
1365
|
+
if (ctx.t === keepAuraTick) return;
|
|
1366
|
+
keepAuraTick = ctx.t;
|
|
1367
|
+
const out = new Set<number>();
|
|
1368
|
+
const add = (p?: AllyActionProvider | null): void => {
|
|
1369
|
+
if (!p?.keepAuraIds) return;
|
|
1370
|
+
for (const id of p.keepAuraIds) if (id > 0) out.add(id);
|
|
1371
|
+
};
|
|
1372
|
+
add(cfg.playerActionProvider);
|
|
1373
|
+
// ALLY seats only. The provider map also holds the BG enemy bots', and the keep-set is consulted for
|
|
1374
|
+
// every unit a dispel touches, so folding theirs in would protect party buffs from a party dispel.
|
|
1375
|
+
for (const [side, p] of ctx.unitActionProvider) if (isAllySide(side)) add(p);
|
|
1376
|
+
ctx.keepAuraSpellIds = out.size ? out : EMPTY_KEEP;
|
|
1377
|
+
};
|
|
1378
|
+
const resetDispelCaches = (): void => {
|
|
1379
|
+
if (ctx.t === dispelCacheTick) return;
|
|
1380
|
+
dispelCacheTick = ctx.t;
|
|
1381
|
+
friendlyDispelCats.clear();
|
|
1382
|
+
targetDispelCats.clear();
|
|
1383
|
+
refreshKeepAuras();
|
|
1384
|
+
};
|
|
1385
|
+
const dispellableOnFriendliesOf = (f: Fighter): ReadonlySet<number> => {
|
|
1386
|
+
if (!ctx.anyDispellableEffect?.()) return EMPTY_CATS;
|
|
1387
|
+
resetDispelCaches();
|
|
1388
|
+
const key = String(ctx.factionOf(f.side));
|
|
1389
|
+
const hit = friendlyDispelCats.get(key);
|
|
1390
|
+
if (hit) return hit;
|
|
1391
|
+
const out = new Set<number>();
|
|
1392
|
+
const pool = friendlyPoolOf(f);
|
|
1393
|
+
for (const cat of [DISPEL_TYPE.MAGIC, DISPEL_TYPE.CURSE, DISPEL_TYPE.DISEASE, DISPEL_TYPE.POISON]) {
|
|
1394
|
+
const mask = getDispelMask(cat);
|
|
1395
|
+
if (pool.some((u) => dispellableAurasOn(u, mask, true).length > 0)) out.add(cat);
|
|
1396
|
+
}
|
|
1397
|
+
friendlyDispelCats.set(key, out);
|
|
1398
|
+
return out;
|
|
1399
|
+
};
|
|
1400
|
+
// Enrage / stealth / invisibility read the WHOLE hostile pool, the question resolveDispelTarget
|
|
1401
|
+
// answers on the cast (dispelReflect.ts:371); MAGIC stays focus-only, an offensive Purge is a pick.
|
|
1402
|
+
const dispellableOnTargetOf = (f: Fighter, foe: Fighter): ReadonlySet<number> => {
|
|
1403
|
+
if (!isEnemySide(foe.side) || dead(foe)) return EMPTY_CATS;
|
|
1404
|
+
if (!ctx.anyDispellableEffect?.()) return EMPTY_CATS;
|
|
1405
|
+
resetDispelCaches();
|
|
1406
|
+
const key = `${f.side}|${foe.side}`;
|
|
1407
|
+
const hit = targetDispelCats.get(key);
|
|
1408
|
+
if (hit) return hit;
|
|
1409
|
+
const out = new Set<number>();
|
|
1410
|
+
if (dispellableAurasOn(foe, getDispelMask(DISPEL_TYPE.MAGIC), false).length > 0) {
|
|
1411
|
+
out.add(DISPEL_TYPE.MAGIC);
|
|
1412
|
+
}
|
|
1413
|
+
// The pool resolveDispelTarget picks from, acquire-range included — the gate cannot outrun the act.
|
|
1414
|
+
const pool = foePoolOf(f);
|
|
1415
|
+
for (const cat of [DISPEL_TYPE.ENRAGE, DISPEL_TYPE.STEALTH, DISPEL_TYPE.INVISIBILITY]) {
|
|
1416
|
+
const mask = getDispelMask(cat);
|
|
1417
|
+
if (pool.some((u) => dispellableAurasOn(u, mask, false).length > 0)) out.add(cat);
|
|
1418
|
+
}
|
|
1419
|
+
targetDispelCats.set(key, out);
|
|
1420
|
+
return out;
|
|
1421
|
+
};
|
|
1422
|
+
|
|
1423
|
+
// One friendly HP and distance snapshot per tick per side, with callers sharing the same rows — no
|
|
1424
|
+
// getters, which fired on spread and made it worse. Rebuilt on pool or position change.
|
|
1425
|
+
type DistHp = { hpPct: number; distYd: number | null };
|
|
1426
|
+
type PartySnap = {
|
|
1427
|
+
pool: Fighter[];
|
|
1428
|
+
hps: number[];
|
|
1429
|
+
modes: Array<Fighter['mode']>;
|
|
1430
|
+
hpPcts: number[];
|
|
1431
|
+
meleeAllyCount: number;
|
|
1432
|
+
posGen: number;
|
|
1433
|
+
distHpBySide: Map<string, DistHp[]>;
|
|
1434
|
+
};
|
|
1435
|
+
const partySnapByFac = new Map<string, PartySnap>();
|
|
1436
|
+
let partySnapTick = -1;
|
|
1437
|
+
const fillPartyDerived = (snap: PartySnap): void => {
|
|
1438
|
+
snap.meleeAllyCount = 0;
|
|
1439
|
+
for (let i = 0; i < snap.pool.length; i++) {
|
|
1440
|
+
const u = snap.pool[i]!;
|
|
1441
|
+
const hpPct = (u.hp / Math.max(1, u.spec.maxHealth)) * 100;
|
|
1442
|
+
snap.hps[i] = u.hp;
|
|
1443
|
+
snap.modes[i] = u.mode;
|
|
1444
|
+
snap.hpPcts[i] = hpPct;
|
|
1445
|
+
if (u.spec.groupRole !== 'healer' && u.mode !== 'ranged') snap.meleeAllyCount++;
|
|
1446
|
+
}
|
|
1447
|
+
for (const row of snap.distHpBySide.values()) {
|
|
1448
|
+
for (let i = 0; i < row.length; i++) row[i]!.hpPct = snap.hpPcts[i]!;
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
const partySnapOf = (f: Fighter): PartySnap => {
|
|
1452
|
+
if (ctx.t !== partySnapTick) {
|
|
1453
|
+
partySnapByFac.clear();
|
|
1454
|
+
partySnapTick = ctx.t;
|
|
1455
|
+
}
|
|
1456
|
+
// Endless enemy pool is per-unit (self ∪ threat), not faction-wide.
|
|
1457
|
+
const fac = cfg.endless && ctx.factionOf(f.side) === 'enemy'
|
|
1458
|
+
? `end:${f.side}`
|
|
1459
|
+
: String(ctx.factionOf(f.side));
|
|
1460
|
+
// Totems out of party triage: heal-immune and 5 HP, so one splash made a Searing Totem the
|
|
1461
|
+
// group's lowest-HP body. Cite: Entities/Totem.cpp:261-280 IsImmuneToSpellEffect.
|
|
1462
|
+
const rawPool = friendlyPoolOf(f);
|
|
1463
|
+
let pool = rawPool;
|
|
1464
|
+
for (const u of rawPool) {
|
|
1465
|
+
if (isTotemSide(u.side)) { pool = rawPool.filter((x) => !isTotemSide(x.side)); break; }
|
|
1466
|
+
}
|
|
1467
|
+
const posGen = ctx.posGen;
|
|
1468
|
+
let snap = partySnapByFac.get(fac);
|
|
1469
|
+
if (snap && snap.posGen === posGen && snap.pool.length === pool.length) {
|
|
1470
|
+
let same = true;
|
|
1471
|
+
let hpModeDirty = false;
|
|
1472
|
+
for (let i = 0; i < pool.length; i++) {
|
|
1473
|
+
const u = pool[i]!;
|
|
1474
|
+
if (u !== snap.pool[i]) { same = false; break; }
|
|
1475
|
+
if (u.hp !== snap.hps[i] || u.mode !== snap.modes[i]) hpModeDirty = true;
|
|
1476
|
+
}
|
|
1477
|
+
if (same) {
|
|
1478
|
+
if (hpModeDirty) fillPartyDerived(snap);
|
|
1479
|
+
return snap;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
snap = {
|
|
1483
|
+
pool,
|
|
1484
|
+
hps: new Array(pool.length),
|
|
1485
|
+
modes: new Array(pool.length),
|
|
1486
|
+
hpPcts: new Array(pool.length),
|
|
1487
|
+
meleeAllyCount: 0,
|
|
1488
|
+
posGen,
|
|
1489
|
+
distHpBySide: new Map(),
|
|
1490
|
+
};
|
|
1491
|
+
fillPartyDerived(snap);
|
|
1492
|
+
partySnapByFac.set(fac, snap);
|
|
1493
|
+
return snap;
|
|
1494
|
+
};
|
|
1495
|
+
const distHpRowOf = (snap: PartySnap, f: Fighter): DistHp[] => {
|
|
1496
|
+
let row = snap.distHpBySide.get(f.side);
|
|
1497
|
+
if (row) return row;
|
|
1498
|
+
row = [];
|
|
1499
|
+
const hasGeo = !!ctx.unitPositionsMutable;
|
|
1500
|
+
for (let i = 0; i < snap.pool.length; i++) {
|
|
1501
|
+
const u = snap.pool[i]!;
|
|
1502
|
+
const d = hasGeo ? distanceBetween(f, u) : undefined;
|
|
1503
|
+
// `healersWithinYd` reads isHealer off this row and only the BG snapshot ever set it, so the
|
|
1504
|
+
// metric answered 0 in every PvE fight (evaluate.ts healersWithinYd).
|
|
1505
|
+
row.push({
|
|
1506
|
+
hpPct: snap.hpPcts[i]!,
|
|
1507
|
+
distYd: d == null ? null : d,
|
|
1508
|
+
...(u.spec.groupRole === 'healer' ? { isHealer: true } : {}),
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
snap.distHpBySide.set(f.side, row);
|
|
1512
|
+
return row;
|
|
1513
|
+
};
|
|
1514
|
+
|
|
1515
|
+
// Mana of the healer SEATS on one side. Seats, not the living pool: a dead healer is the case that
|
|
1516
|
+
// matters and reads 0. Built once per tick and faction, or a 40-man asks it 40 times.
|
|
1517
|
+
type HealerSeats = { seats: Array<{ side: Side; pct: number }>; groupSeats: number };
|
|
1518
|
+
const healerSeatsByFaction = new Map<string, HealerSeats>();
|
|
1519
|
+
let healerSeatsTick = -1;
|
|
1520
|
+
const healerSeatsOf = (f: Fighter): HealerSeats => {
|
|
1521
|
+
if (ctx.t !== healerSeatsTick) {
|
|
1522
|
+
healerSeatsByFaction.clear();
|
|
1523
|
+
healerSeatsTick = ctx.t;
|
|
1524
|
+
}
|
|
1525
|
+
const fac = ctx.factionOfFighter(f);
|
|
1526
|
+
const hit = healerSeatsByFaction.get(fac);
|
|
1527
|
+
if (hit) return hit;
|
|
1528
|
+
const out: HealerSeats = { seats: [], groupSeats: 0 };
|
|
1529
|
+
for (const u of ctx.fightersOrder()) {
|
|
1530
|
+
// A PARTY seat. The pet is not one — it has no group role and cannot heal — and counting it made
|
|
1531
|
+
// the seat count read 2 for a solo hunter. A class id cannot see this: a pet carries one.
|
|
1532
|
+
if (isTotemSide(u.side) || u.side === 'pet' || u.spec.classId == null) continue;
|
|
1533
|
+
if (ctx.factionOfFighter(u) !== fac) continue;
|
|
1534
|
+
out.groupSeats++;
|
|
1535
|
+
if (u.spec.groupRole !== 'healer') continue;
|
|
1536
|
+
const maxPower = u.spec.maxPower;
|
|
1537
|
+
out.seats.push({
|
|
1538
|
+
side: u.side,
|
|
1539
|
+
pct: u.hp > 0 && maxPower > 0 ? (u.power / maxPower) * 100 : 0,
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
healerSeatsByFaction.set(fac, out);
|
|
1543
|
+
return out;
|
|
1544
|
+
};
|
|
1545
|
+
// Interruptible bar on any combat hostile, not only the kill focus. Kick/CS `target.castingPresent`
|
|
1546
|
+
// reads this; one walk per faction per tick, and only when a rule named that metric.
|
|
1547
|
+
let kickableTick = -1;
|
|
1548
|
+
const kickableByFac = new Map<string, boolean>();
|
|
1549
|
+
const kickableCastPresentOf = (f: Fighter, hostiles: readonly Fighter[]): boolean => {
|
|
1550
|
+
if (ctx.t !== kickableTick) {
|
|
1551
|
+
kickableByFac.clear();
|
|
1552
|
+
kickableTick = ctx.t;
|
|
1553
|
+
}
|
|
1554
|
+
// Key on what hostilesOf branches on: an enemy seat's list is per unit under acquireRangeYd, and
|
|
1555
|
+
// a charmed mob keeps its enemy side, so the party's answer must not come off its radius.
|
|
1556
|
+
const key = isEnemySide(f.side)
|
|
1557
|
+
? (cfg.acquireRangeYd != null ? `side:${String(f.side)}` : 'enemy-side')
|
|
1558
|
+
: 'party-side';
|
|
1559
|
+
const hit = kickableByFac.get(key);
|
|
1560
|
+
if (hit !== undefined) return hit;
|
|
1561
|
+
let v = false;
|
|
1562
|
+
for (const h of hostiles) {
|
|
1563
|
+
if (!dead(h) && unitInterruptibleCast(h, ctx.t) != null) { v = true; break; }
|
|
1564
|
+
}
|
|
1565
|
+
kickableByFac.set(key, v);
|
|
1566
|
+
return v;
|
|
1567
|
+
};
|
|
1568
|
+
let totemOwnerTick = -1;
|
|
1569
|
+
const totemOwnerSides = new Set<string>();
|
|
1570
|
+
const totemOwnerSidesNow = (): Set<string> => {
|
|
1571
|
+
if (ctx.t === totemOwnerTick) return totemOwnerSides;
|
|
1572
|
+
totemOwnerSides.clear();
|
|
1573
|
+
totemOwnerTick = ctx.t;
|
|
1574
|
+
for (const tot of totems.values()) {
|
|
1575
|
+
if (dead(tot) || !tot.totemSlot) continue;
|
|
1576
|
+
totemOwnerSides.add(tot.totemOwnerSide ?? 'player');
|
|
1577
|
+
}
|
|
1578
|
+
return totemOwnerSides;
|
|
1579
|
+
};
|
|
1580
|
+
let deadAllyTick = -1;
|
|
1581
|
+
let deadAllyN = 0;
|
|
1582
|
+
const deadAllyCountNow = (): number => {
|
|
1583
|
+
if (ctx.t === deadAllyTick) return deadAllyN;
|
|
1584
|
+
deadAllyTick = ctx.t;
|
|
1585
|
+
deadAllyN = rezzableDeadCount(ctx.allyBlock()) + (isRezzableCorpse(player) ? 1 : 0);
|
|
1586
|
+
return deadAllyN;
|
|
1587
|
+
};
|
|
1588
|
+
// Tank-swap chips: every thinker with the same threat victim was rebuilding the same set.
|
|
1589
|
+
let aggroHolderTick = -1;
|
|
1590
|
+
const aggroHolderBySide = new Map<string, { ids: Set<number>; stacks: Map<number, number> }>();
|
|
1591
|
+
let hurtTick = -1;
|
|
1592
|
+
const hurtByFac = new Map<string, Fighter | null>();
|
|
1593
|
+
const sharedHurtOf = (f: Fighter): Fighter | null => {
|
|
1594
|
+
if (f.friendlyPin || cfg.acquireRangeYd != null || cfg.navLos) {
|
|
1595
|
+
return ctx.lowestHpFriendlyOf?.(f) ?? lowestHpFriendly();
|
|
1596
|
+
}
|
|
1597
|
+
if (ctx.t !== hurtTick) {
|
|
1598
|
+
hurtByFac.clear();
|
|
1599
|
+
hurtTick = ctx.t;
|
|
1600
|
+
}
|
|
1601
|
+
const fac = String(ctx.factionOfFighter(f));
|
|
1602
|
+
if (hurtByFac.has(fac)) return hurtByFac.get(fac) ?? null;
|
|
1603
|
+
const v = ctx.lowestHpFriendlyOf?.(f) ?? lowestHpFriendly();
|
|
1604
|
+
hurtByFac.set(fac, v);
|
|
1605
|
+
return v;
|
|
1606
|
+
};
|
|
1607
|
+
// The most-hurt living friendly matching every field of the filter. An empty filter returns
|
|
1608
|
+
// EXACTLY sharedHurtOf, so no stored row changes body. Totems are out, as in partySnapOf.
|
|
1609
|
+
const aimedAllyOf = (f: Fighter, filter?: AllyFilter): Fighter | null => {
|
|
1610
|
+
if (allyFilterIsAny(filter)) return sharedHurtOf(f);
|
|
1611
|
+
const want = filter as AllyFilter;
|
|
1612
|
+
let best: Fighter | null = null;
|
|
1613
|
+
let bestPct = Number.POSITIVE_INFINITY;
|
|
1614
|
+
for (const u of friendlyPoolOf(f)) {
|
|
1615
|
+
if (dead(u) || isTotemSide(u.side)) continue;
|
|
1616
|
+
if (want.unit && u.side !== want.unit) continue;
|
|
1617
|
+
if (want.role != null && u.spec.groupRole !== want.role) continue;
|
|
1618
|
+
if (want.notRole != null && u.spec.groupRole === want.notRole) continue;
|
|
1619
|
+
if (want.classId != null && unitClassId(u) !== want.classId) continue;
|
|
1620
|
+
const pct = (u.hp / Math.max(1, u.spec.maxHealth)) * 100;
|
|
1621
|
+
if (pct < bestPct) { bestPct = pct; best = u; }
|
|
1622
|
+
}
|
|
1623
|
+
return best;
|
|
1624
|
+
};
|
|
1625
|
+
// One SubjectState per (actor, filter) per tick: building one walks the body's aura chips, and a
|
|
1626
|
+
// 40-man asks the same question once per rule.
|
|
1627
|
+
let allySubjTick = -1;
|
|
1628
|
+
const allySubjCache = new Map<string, SubjectState | null>();
|
|
1629
|
+
const allyFilterKey = (fl?: AllyFilter): string => (allyFilterIsAny(fl)
|
|
1630
|
+
? ''
|
|
1631
|
+
: `${fl?.role ?? ''}|${fl?.notRole ?? ''}|${fl?.classId ?? ''}|${fl?.unit ?? ''}`);
|
|
1632
|
+
const allySubjectOf = (f: Fighter, filter?: AllyFilter): SubjectState | null => {
|
|
1633
|
+
if (ctx.t !== allySubjTick) { allySubjCache.clear(); allySubjTick = ctx.t; }
|
|
1634
|
+
const key = `${f.side}#${allyFilterKey(filter)}`;
|
|
1635
|
+
const hit = allySubjCache.get(key);
|
|
1636
|
+
if (hit !== undefined) return hit;
|
|
1637
|
+
const body = aimedAllyOf(f, filter);
|
|
1638
|
+
const out = body ? subjectFromView(body, f) : null;
|
|
1639
|
+
allySubjCache.set(key, out);
|
|
1640
|
+
return out;
|
|
1641
|
+
};
|
|
1642
|
+
const aggroHolderChipsOf = (victim: Fighter): { ids: Set<number>; stacks: Map<number, number> } => {
|
|
1643
|
+
if (ctx.t !== aggroHolderTick) {
|
|
1644
|
+
aggroHolderBySide.clear();
|
|
1645
|
+
aggroHolderTick = ctx.t;
|
|
1646
|
+
}
|
|
1647
|
+
const hit = aggroHolderBySide.get(victim.side);
|
|
1648
|
+
if (hit) return hit;
|
|
1649
|
+
const chips = ctx.debuffChipsOf?.(victim) ?? view(victim).debuffs;
|
|
1650
|
+
const parsed = auraIdsAndStacks(chips);
|
|
1651
|
+
const row = { ids: parsed.ids, stacks: parsed.stacks };
|
|
1652
|
+
aggroHolderBySide.set(victim.side, row);
|
|
1653
|
+
return row;
|
|
1654
|
+
};
|
|
1655
|
+
// What this unit sees: the OTHER seats' mana. A group with no healer among its members reads zero,
|
|
1656
|
+
// the same emergency as a dead one. Solo, and a healer asking about itself, are absent.
|
|
1657
|
+
const healerPowerPctsOf = (f: Fighter): readonly number[] | undefined => {
|
|
1658
|
+
const { seats, groupSeats } = healerSeatsOf(f);
|
|
1659
|
+
const mine = seats.some((s) => s.side === f.side);
|
|
1660
|
+
const others = seats.length - (mine ? 1 : 0);
|
|
1661
|
+
if (others === 0) return groupSeats > 1 && !mine ? NO_HEALER_MANA : undefined;
|
|
1662
|
+
if (!mine) return seats.map((s) => s.pct);
|
|
1663
|
+
return seats.filter((s) => s.side !== f.side).map((s) => s.pct);
|
|
1664
|
+
};
|
|
1665
|
+
|
|
1666
|
+
// Is this unit's side fighting? Own combat flag or any party member's — a back-line healer never swings
|
|
1667
|
+
// or gets hit, so its own flag alone reads OOC all pull. Cite: combatFlag.ts.
|
|
1668
|
+
let facEngagedTick = -1;
|
|
1669
|
+
const facEngagedByFac = new Map<string, boolean>();
|
|
1670
|
+
const factionEngaged = (f: Fighter, now: number): boolean => {
|
|
1671
|
+
if (fighterIsInCombat(f, now)) return true;
|
|
1672
|
+
if (ctx.t !== facEngagedTick) {
|
|
1673
|
+
facEngagedByFac.clear();
|
|
1674
|
+
facEngagedTick = ctx.t;
|
|
1675
|
+
}
|
|
1676
|
+
const fac = String(ctx.factionOfFighter(f));
|
|
1677
|
+
const hit = facEngagedByFac.get(fac);
|
|
1678
|
+
if (hit !== undefined) return hit;
|
|
1679
|
+
let v = false;
|
|
1680
|
+
for (const u of partySnapOf(f).pool) {
|
|
1681
|
+
if (fighterIsInCombat(u, now)) { v = true; break; }
|
|
1682
|
+
}
|
|
1683
|
+
facEngagedByFac.set(fac, v);
|
|
1684
|
+
return v;
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
/** Everything on, for pets / fixtures / providers that never advertised what they read. */
|
|
1688
|
+
const NEEDS_ALL: ProviderNeeds = {
|
|
1689
|
+
allyDistHp: true,
|
|
1690
|
+
allyTriageBuffs: true,
|
|
1691
|
+
charmedHostileCount: true,
|
|
1692
|
+
enemyDistsFromTarget: true,
|
|
1693
|
+
enemyDists: true,
|
|
1694
|
+
enemyHealerDists: true,
|
|
1695
|
+
hasAggro: true,
|
|
1696
|
+
enemyCastingOnAlly: true,
|
|
1697
|
+
enemyCastingOnSelf: true,
|
|
1698
|
+
items: true,
|
|
1699
|
+
groundPools: true,
|
|
1700
|
+
targetCone: true,
|
|
1701
|
+
hostileCreatureEntries: true,
|
|
1702
|
+
spellCosts: true,
|
|
1703
|
+
dispellablePresent: true,
|
|
1704
|
+
kickableCastPresent: true,
|
|
1705
|
+
aggroHolder: true,
|
|
1706
|
+
allySubject: true,
|
|
1707
|
+
};
|
|
1708
|
+
// Shared empties. A snapshot is built for every unit every tick and these four are empty in nearly
|
|
1709
|
+
// all of them, which was four allocations to say "nothing". The evaluator only reads them.
|
|
1710
|
+
const NO_SLOTS: Set<TotemSlot> = new Set();
|
|
1711
|
+
/** A group with no healer seat at all: nobody is healing, same emergency as a dead one. */
|
|
1712
|
+
const NO_HEALER_MANA: readonly number[] = [0];
|
|
1713
|
+
const NO_COUNTS: Map<number, number> = new Map();
|
|
1714
|
+
const NO_HANDS_SET: Set<'mainhand' | 'offhand'> = new Set();
|
|
1715
|
+
const imbueSetOf = (f: Fighter, now: number): Set<'mainhand' | 'offhand'> => {
|
|
1716
|
+
const mh = f.weaponImbues.mainhand != null && f.weaponImbues.mainhand.expiresAt > now;
|
|
1717
|
+
const oh = f.weaponImbues.offhand != null && f.weaponImbues.offhand.expiresAt > now;
|
|
1718
|
+
if (!mh && !oh) return NO_HANDS_SET;
|
|
1719
|
+
const out = new Set<'mainhand' | 'offhand'>();
|
|
1720
|
+
if (mh) out.add('mainhand');
|
|
1721
|
+
if (oh) out.add('offhand');
|
|
1722
|
+
return out;
|
|
1723
|
+
};
|
|
1724
|
+
/** Dual-wield open hands: weapon profiles only (shield → no spec.offhand). */
|
|
1725
|
+
const openHandsOf = (f: Fighter, now: number): Set<'mainhand' | 'offhand'> => {
|
|
1726
|
+
const mhOk = !(f.weaponImbues.mainhand && f.weaponImbues.mainhand.expiresAt > now);
|
|
1727
|
+
const ohOk = !!f.spec.offhand && !(f.weaponImbues.offhand && f.weaponImbues.offhand.expiresAt > now);
|
|
1728
|
+
if (!mhOk && !ohOk) return NO_HANDS_SET;
|
|
1729
|
+
const open = new Set<'mainhand' | 'offhand'>();
|
|
1730
|
+
if (mhOk) open.add('mainhand');
|
|
1731
|
+
if (ohOk) open.add('offhand');
|
|
1732
|
+
return open;
|
|
1733
|
+
};
|
|
1734
|
+
/**
|
|
1735
|
+
* Dev capture of the snapshot a seat's rotation conditions are evaluated against, keyed by side.
|
|
1736
|
+
* `session.abilities()` reports for the `player` seat only (sessionApi.ts:385), so a NON-LEAD or
|
|
1737
|
+
* enemy-side seat's condition values were unreadable — which is what left plan §W11 link 1 stuck
|
|
1738
|
+
* ("side B's warrior never casts Hamstring, gate `target.debuffPresent==0 & target.drLevel<2`,
|
|
1739
|
+
* and nothing can say which clause is false"). Written only when a caller asks, so a normal
|
|
1740
|
+
* fight pays nothing: `allySnapshot` runs per unit per tick.
|
|
1741
|
+
*/
|
|
1742
|
+
const allySnapshotOf = (f: Fighter, foe: Fighter, needs: ProviderNeeds = NEEDS_ALL): RotationSnapshot => {
|
|
1743
|
+
const t = ctx.t;
|
|
1744
|
+
refreshKeepAuras();
|
|
1745
|
+
// Totems are an engagement action, not a follow behaviour (radius gate + usableSpell below).
|
|
1746
|
+
const engaged = factionEngaged(f, t);
|
|
1747
|
+
// Off-heal emergency gate (`healerPowerPct`): the party healer's mana, 0 when it is dead.
|
|
1748
|
+
const healerSeatPowerPcts = healerPowerPctsOf(f);
|
|
1749
|
+
// Seats on this side, self included — the `partySeats` gate a healer's damage rows read. Seats,
|
|
1750
|
+
// not living bodies, so a party mid-wipe does not read as solo. Memoized with the healer seats.
|
|
1751
|
+
const partySeats = healerSeatsOf(f).groupSeats;
|
|
1752
|
+
let occupied: Set<TotemSlot> = NO_SLOTS;
|
|
1753
|
+
if (totems.size > 0 && totemOwnerSidesNow().has(f.side)) {
|
|
1754
|
+
for (const tot of totems.values()) {
|
|
1755
|
+
if (dead(tot)) continue;
|
|
1756
|
+
// This unit's OWN four seats. Reading the party's made two shamans out of each other's radius
|
|
1757
|
+
// each see the slot as empty, and every recast stole the other's. Cite: Unit.h:2625.
|
|
1758
|
+
if ((tot.totemOwnerSide ?? 'player') !== f.side) continue;
|
|
1759
|
+
const slot = tot.totemSlot;
|
|
1760
|
+
if (!slot) continue;
|
|
1761
|
+
// Out of the totem's baked aura radius the slot reads empty so the rotation recasts, but only
|
|
1762
|
+
// while fighting, or walking leaves the set behind and re-drops it. A missing radius fails open.
|
|
1763
|
+
if (ctx.unitPositionsMutable && engaged) {
|
|
1764
|
+
const r = tot.totemAuraRadiusYd ?? 0;
|
|
1765
|
+
if (r > 0) {
|
|
1766
|
+
const d = distanceBetween(f, tot);
|
|
1767
|
+
if (d != null && d > r) continue;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
if (occupied === NO_SLOTS) occupied = new Set<TotemSlot>();
|
|
1771
|
+
occupied.add(slot);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
// Faction-relative triage — BG enemy healers must not look at the player's party.
|
|
1775
|
+
const hurt = sharedHurtOf(f);
|
|
1776
|
+
// Which dispel categories this companion could actually strip right now, so a cure rule fires only
|
|
1777
|
+
// when it would do something. Friendlies give curable debuffs; the enemy target strippable buffs.
|
|
1778
|
+
const dispellableOnFriendlies = needs.dispellablePresent ? dispellableOnFriendliesOf(f) : EMPTY_CATS;
|
|
1779
|
+
const dispellableOnTarget = needs.dispellablePresent ? dispellableOnTargetOf(f, foe) : EMPTY_CATS;
|
|
1780
|
+
// The bag is keyed by side, so no roster index is looked up to find it (E1.1).
|
|
1781
|
+
const bag = f.side === 'player'
|
|
1782
|
+
? (ctx.playerItems ?? [])
|
|
1783
|
+
: (unitItems.get(f.side) ?? []);
|
|
1784
|
+
const cdMap = f.side === 'player'
|
|
1785
|
+
? (ctx.itemCd ?? new Map<number, number>())
|
|
1786
|
+
: (unitItemCd.get(f.side) ?? new Map<number, number>());
|
|
1787
|
+
let itemCounts: Map<number, number> = NO_COUNTS;
|
|
1788
|
+
if (needs.items) {
|
|
1789
|
+
for (const it of bag) {
|
|
1790
|
+
if (itemCounts === NO_COUNTS) itemCounts = new Map<number, number>();
|
|
1791
|
+
itemCounts.set(it.entry, it.count);
|
|
1792
|
+
}
|
|
1793
|
+
// Reagent bag snapshot (Soul Shard etc.) so rotation itemCount gates see live counts.
|
|
1794
|
+
// Cite: spellReagents.ts; KI-804; domain/rotation itemCount metric.
|
|
1795
|
+
if (f.spec.bagItemCounts) {
|
|
1796
|
+
for (const [k, v] of Object.entries(f.spec.bagItemCounts)) {
|
|
1797
|
+
const entry = Number(k);
|
|
1798
|
+
if (!Number.isFinite(entry)) continue;
|
|
1799
|
+
if (itemCounts === NO_COUNTS) itemCounts = new Map<number, number>();
|
|
1800
|
+
itemCounts.set(entry, v | 0);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
// Player pet is ctx.pet; a companion names its own pet seat (spec.petSide). Allies-only
|
|
1805
|
+
// lookup left snap.pet null for the player, so Health Funnel / Mend Pet never fired.
|
|
1806
|
+
const mappedPet = livingPetOf(f);
|
|
1807
|
+
const petSubj = mappedPet ? subjectFromView(mappedPet) : null;
|
|
1808
|
+
const selfSubj = subjectFromView(f, null, needs.spellCosts);
|
|
1809
|
+
// foeOf self-sentinel is NOT a target (types.ts: null = no target): as target-self, every
|
|
1810
|
+
// `target.debuffPresent == 0` keep-up read the unit's own clean list and re-fired at nothing.
|
|
1811
|
+
const targetSubj = dead(foe) || foe === f ? null : subjectFromView(foe, f);
|
|
1812
|
+
const hasGeo = !!ctx.unitPositionsMutable;
|
|
1813
|
+
selfSubj.inMelee = hasGeo
|
|
1814
|
+
? livingHostilesOf(f).some((h) => inMeleeBand(distanceBetween(f, h) ?? null, true))
|
|
1815
|
+
: true;
|
|
1816
|
+
if (targetSubj) targetSubj.inMelee = inMeleeBand(distanceBetween(f, foe) ?? null, hasGeo);
|
|
1817
|
+
if (petSubj && mappedPet && !dead(foe) && foe !== f) {
|
|
1818
|
+
petSubj.inMelee = inMeleeBand(distanceBetween(mappedPet, foe) ?? null, hasGeo);
|
|
1819
|
+
}
|
|
1820
|
+
// Charmed hostiles from this companion's faction view (possessed party members).
|
|
1821
|
+
const charmedHostileCount = needs.charmedHostileCount
|
|
1822
|
+
? livingHostilesFor(f).filter((h) => isCharmed(h)).length
|
|
1823
|
+
: 0;
|
|
1824
|
+
// Shared party HP/melee; per-actor distances for AOE heal gates (PoH / Chain Heal).
|
|
1825
|
+
const party = partySnapOf(f);
|
|
1826
|
+
const allyHpPcts = party.hpPcts;
|
|
1827
|
+
// Shared row (same object for this actor this tick). Built once; not a getter.
|
|
1828
|
+
const allyDistHp = needs.allyDistHp ? distHpRowOf(party, f) : undefined;
|
|
1829
|
+
// One resolver for "who is the triage body": `hurt` is the same lowestHpFriendlyOf the
|
|
1830
|
+
// `{ lowestHp }` aim resolves below, so the gate reads the bar the HoT actually lands on.
|
|
1831
|
+
const triageBuffs = needs.allyTriageBuffs && hurt ? new Set(hurt.buffActive.keys()) : undefined;
|
|
1832
|
+
const meleeAllyCount = party.meleeAllyCount;
|
|
1833
|
+
// Enemy distances for the multi and AoE gates, geometry only — missing degrades to a fight-wide
|
|
1834
|
+
// count. Faction-relative, so a Horde bot's enemies are Alliance rather than its own team.
|
|
1835
|
+
const hostiles = hostilesOf(f);
|
|
1836
|
+
// Ally skips vid === f.side (enemyCastingOnAlly.mjs). Self is that case: Grounding on the healer.
|
|
1837
|
+
// Cite: Unit.cpp:6894-6916 SelectMagnetTarget.
|
|
1838
|
+
let enemyCastingOnAlly = false;
|
|
1839
|
+
let enemyCastingOnSelf = false;
|
|
1840
|
+
if (needs.enemyCastingOnAlly || needs.enemyCastingOnSelf) {
|
|
1841
|
+
const myFaction = ctx.factionOf(f.side);
|
|
1842
|
+
for (const e of hostiles) {
|
|
1843
|
+
if (dead(e) || !unitIsCastingOrChanneling(e, t)) continue;
|
|
1844
|
+
const vid = threatOf(e)?.getCurrentVictimId();
|
|
1845
|
+
if (vid == null || isTotemSide(vid as Side)) continue;
|
|
1846
|
+
if (ctx.factionOf(vid as Side) !== myFaction) continue;
|
|
1847
|
+
const victim = ctx.bySide(vid as Side);
|
|
1848
|
+
if (!victim || dead(victim)) continue;
|
|
1849
|
+
if (vid === f.side) enemyCastingOnSelf = true;
|
|
1850
|
+
else enemyCastingOnAlly = true;
|
|
1851
|
+
if (enemyCastingOnAlly && enemyCastingOnSelf) break;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
// A free variable until now: the three uses below read it off the context where crowd control
|
|
1855
|
+
// installs it. Unbound it threw the moment a rotation named the metric. Absent protects nothing.
|
|
1856
|
+
const ccProtected = ctx.ccProtected ?? (() => false);
|
|
1857
|
+
let enemyDists: number[] | undefined;
|
|
1858
|
+
let enemyDistsFromTarget: number[] | undefined;
|
|
1859
|
+
let enemyHealerDists: number[] | undefined;
|
|
1860
|
+
// The blast-legal twin of the enemy distances: the same pack MINUS anything holding CC our own AoE
|
|
1861
|
+
// would shatter. The honest pack count stays for the rotation; this is the is-it-worth-it decision.
|
|
1862
|
+
let blastDists: number[] | undefined;
|
|
1863
|
+
let blastDistsFromTarget: number[] | undefined;
|
|
1864
|
+
if (ctx.unitPositionsMutable) {
|
|
1865
|
+
const needSelfDists = kitNeedsSelfPackDists(f.spec.spells)
|
|
1866
|
+
|| needs.enemyDists
|
|
1867
|
+
|| needs.enemyHealerDists;
|
|
1868
|
+
if (needSelfDists) {
|
|
1869
|
+
enemyDists = [];
|
|
1870
|
+
blastDists = [];
|
|
1871
|
+
for (const e of hostiles) {
|
|
1872
|
+
if (dead(e)) continue;
|
|
1873
|
+
const d = distanceBetween(f, e);
|
|
1874
|
+
if (d == null) continue;
|
|
1875
|
+
enemyDists.push(d);
|
|
1876
|
+
if (!ccProtected(e)) blastDists.push(d);
|
|
1877
|
+
if (needs.enemyHealerDists && e.spec.groupRole === 'healer') {
|
|
1878
|
+
(enemyHealerDists ??= []).push(d);
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
// usableSpell reads blastDistsFromTarget for ground-rain density; the snapshot field is
|
|
1883
|
+
// enemiesWithinYd subject target. Skip the walk when this kit and rotation name neither.
|
|
1884
|
+
const fromTargetForUsable = kitHasGroundMulti(f.spec.spells);
|
|
1885
|
+
const fromTargetForSnap = needs.enemyDistsFromTarget;
|
|
1886
|
+
if (!dead(foe) && foe !== f && (fromTargetForUsable || fromTargetForSnap)) {
|
|
1887
|
+
if (fromTargetForSnap) enemyDistsFromTarget = [];
|
|
1888
|
+
blastDistsFromTarget = [];
|
|
1889
|
+
for (const e of hostiles) {
|
|
1890
|
+
if (dead(e)) continue;
|
|
1891
|
+
let d = 0;
|
|
1892
|
+
if (e !== foe) {
|
|
1893
|
+
const dist = distanceBetween(foe, e);
|
|
1894
|
+
if (dist == null) continue;
|
|
1895
|
+
d = dist;
|
|
1896
|
+
}
|
|
1897
|
+
if (enemyDistsFromTarget) enemyDistsFromTarget.push(d);
|
|
1898
|
+
if (!ccProtected(e)) blastDistsFromTarget.push(d);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
// Self-centred hard control whose disc holds no body the control can hold. Counting bodies alone
|
|
1903
|
+
// let a root-immune boss eat the GCD and the authored step-back on every cooldown.
|
|
1904
|
+
let unholdableAoeCc: Set<number> | undefined;
|
|
1905
|
+
for (const nova of novaKiteKitOf(f.spec.spells)) {
|
|
1906
|
+
const discDists = blastDists ?? enemyDists;
|
|
1907
|
+
// Empty disc is the older gate's answer and needs no per-body test. Skipping it keeps
|
|
1908
|
+
// the scan below off the endless floor's passive stream, which `hostiles` includes.
|
|
1909
|
+
if (discDists && !novaDiscOccupied(discDists, nova)) continue;
|
|
1910
|
+
const yards = multiTargetGateYards(nova);
|
|
1911
|
+
let holdable = false;
|
|
1912
|
+
for (const e of hostiles) {
|
|
1913
|
+
if (dead(e) || ccProtected(e)) continue;
|
|
1914
|
+
// No geometry (hop fights): the whole pack is "in the disc", same as novaDiscOccupied.
|
|
1915
|
+
if (enemyDists) {
|
|
1916
|
+
const d = distanceBetween(f, e);
|
|
1917
|
+
if (d == null || d > yards) continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (!hardControlCanLand(f, e, nova)) continue;
|
|
1920
|
+
holdable = true;
|
|
1921
|
+
break;
|
|
1922
|
+
}
|
|
1923
|
+
if (!holdable) (unholdableAoeCc ??= new Set()).add(nova.id);
|
|
1924
|
+
}
|
|
1925
|
+
// Pull sequence spent set for this ally (same contract as player combatSnapshot).
|
|
1926
|
+
// Maintained on push — the rescan here was O(whole run) per ally per tick.
|
|
1927
|
+
const pullSpentSpellIds = ctx.pullSpentSpellsFor(f.side);
|
|
1928
|
+
const pullSpentItemEntries = ctx.pullSpentItemsFor(f.side);
|
|
1929
|
+
// The nearest hazard edge in yards, 0 while inside, off the same registry the avoid move reads — so
|
|
1930
|
+
// the condition and the move cannot disagree. Walking the pools directly could not see a body.
|
|
1931
|
+
let hazardProximityYd: number | undefined;
|
|
1932
|
+
if (needs.groundPools && ctx.unitPositionsMutable) {
|
|
1933
|
+
const here = ctx.posOf?.(f.side);
|
|
1934
|
+
if (here) {
|
|
1935
|
+
let best: number | undefined;
|
|
1936
|
+
for (const h of hostileHazardsThisTick(ctx, dead)) {
|
|
1937
|
+
const d = combatDistanceYd(here, h.at);
|
|
1938
|
+
if (d == null) continue;
|
|
1939
|
+
const edge = Math.max(0, d - h.radiusYd);
|
|
1940
|
+
if (best == null || edge < best) best = edge;
|
|
1941
|
+
}
|
|
1942
|
+
if (best != null) hazardProximityYd = best;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
// Am I in an arc one of the focus's own cones would hit? The same test the fan runs. The widest arc
|
|
1946
|
+
// wins and a REAR cone counts, so "behind him" is not a safe answer and the flank row clears both.
|
|
1947
|
+
let inTargetCone: boolean | undefined;
|
|
1948
|
+
if (needs.targetCone && ctx.unitPositionsMutable && !dead(foe) && foe !== f) {
|
|
1949
|
+
const here = ctx.posOf?.(f.side);
|
|
1950
|
+
const there = ctx.posOf?.(foe.side);
|
|
1951
|
+
const victim = ctx.foeOf?.(foe);
|
|
1952
|
+
const facingAt = victim && victim !== foe ? ctx.posOf?.(victim.side) : undefined;
|
|
1953
|
+
if (here && there && facingAt) {
|
|
1954
|
+
const arcs = coneArcsOfKit(foe.spec.spells, castableKitIds(foe, ctx.spellOf));
|
|
1955
|
+
// coneCovers, not the bare arc: 41 of the 129 cone spells stop at 10 yd, so an
|
|
1956
|
+
// angle-only test told a healer at 30 yd it was standing in a Cleave.
|
|
1957
|
+
inTargetCone = arcs.length === 0
|
|
1958
|
+
? false
|
|
1959
|
+
: arcs.some((a) => coneCovers(there, facingAt, here, a));
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
let hostileCreatureEntries: Set<number> | undefined;
|
|
1963
|
+
if (needs.hostileCreatureEntries) {
|
|
1964
|
+
hostileCreatureEntries = new Set();
|
|
1965
|
+
for (const e of hostiles) {
|
|
1966
|
+
if (dead(e)) continue;
|
|
1967
|
+
const entry = e.spec.creatureEntry;
|
|
1968
|
+
if (entry != null) hostileCreatureEntries.add(entry);
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
// One body, two questions: the real gate, and the same gate with the shapeshift check
|
|
1972
|
+
// skipped so a rotation can ask "ready but for the stance".
|
|
1973
|
+
const usableSpellWith = (id: number, ignoreForm: boolean): boolean => {
|
|
1974
|
+
const s = kitById(f.spec.spells).get(id);
|
|
1975
|
+
if (!s || isTriggeredCompanion(f, s)) return false;
|
|
1976
|
+
if (s.onNextSwing && f.pendingSwing) return false;
|
|
1977
|
+
// Indoors: the cast would fail CheckCast, so the rule must not win the GCD with it.
|
|
1978
|
+
if (!spellAllowedAtLocation(s, cfg.outdoors !== false, cfg.battlegroundMap)) return false;
|
|
1979
|
+
// halt_dps row: refuse damaging casts matching the school (or all / a set).
|
|
1980
|
+
const halt = f.haltDpsSchool;
|
|
1981
|
+
// A SCOPED halt stops damage only on the bodies it names, so a both-up phase can hold fire on
|
|
1982
|
+
// the invulnerable one. Unscoped is every body, which is what a row written before this meant.
|
|
1983
|
+
if (
|
|
1984
|
+
halt && isHostileSpellDef(s) && haltDpsRefusesSchool(halt, s.school)
|
|
1985
|
+
&& haltDpsCoversEntry(f.haltDpsEntries, foe.spec.creatureEntry)
|
|
1986
|
+
) return false;
|
|
1987
|
+
// Pets already fight as session units — never GCD-cast summon mid-fight
|
|
1988
|
+
// (combat has no summon_pet apply; that path is OOC).
|
|
1989
|
+
if (roleOf(s) === 'pet_summon') return false;
|
|
1990
|
+
// Totems only once the pull is on — a totem placed while the party walks is left behind
|
|
1991
|
+
// at once, and the empty slot re-fires the rule. Cite: oocTotemSummonFailReason.
|
|
1992
|
+
if (!engaged && roleOf(s) === 'totem_summon') return false;
|
|
1993
|
+
// pet_heal only when this unit's pet is alive (health gate is the rotation rule).
|
|
1994
|
+
if (roleOf(s) === 'pet_heal' && (!mappedPet || dead(mappedPet))) return false;
|
|
1995
|
+
// Pure Kick/CS/Pummel: only when a body can actually take it (interruptible bar,
|
|
1996
|
+
// in range, not already claimed). Earth Shock keeps its filler when nobody is casting.
|
|
1997
|
+
if (pureInterruptOf(s)) {
|
|
1998
|
+
if (!kickableCastPresentOf(f, hostiles)) return false;
|
|
1999
|
+
if (!pickInterruptTarget(f, foe, s)) return false;
|
|
2000
|
+
} else if (interruptCastOf(s)) {
|
|
2001
|
+
if (interruptClaimedByOther(foe.side, f.side)) return false;
|
|
2002
|
+
if (!interruptCanLand(foe, s)) return false;
|
|
2003
|
+
}
|
|
2004
|
+
// One party Tranq/Purge/Cleanse per (target, category). Cite: AUTOPILOT-PLAN §4 row 21.
|
|
2005
|
+
if (dispelSpellClaimedByOther(f, foe, s)) return false;
|
|
2006
|
+
// One party battle rez per corpse — N druids stand up N bodies, not one body N times.
|
|
2007
|
+
if (battleRezClaimedByOther(f, s)) return false;
|
|
2008
|
+
// The pack swap: the single-target filler must not dump rage when its multi counterpart is on
|
|
2009
|
+
// the kit. Counted in the melee band, not fight-wide — a spread pack still wants the filler.
|
|
2010
|
+
{
|
|
2011
|
+
const { hasCleave, hasSwipe } = packFillersOf(f.spec.spells);
|
|
2012
|
+
if (hasCleave || hasSwipe) {
|
|
2013
|
+
let packNear = 0;
|
|
2014
|
+
if (blastDists) {
|
|
2015
|
+
for (const d of blastDists) if (d <= 8) packNear++;
|
|
2016
|
+
} else {
|
|
2017
|
+
packNear = blastEnemyCount();
|
|
2018
|
+
}
|
|
2019
|
+
if (
|
|
2020
|
+
isPackSuppressedStFiller(s, {
|
|
2021
|
+
enemyCount: packNear,
|
|
2022
|
+
hasCleave,
|
|
2023
|
+
hasSwipe,
|
|
2024
|
+
})
|
|
2025
|
+
) {
|
|
2026
|
+
return false;
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
// Multi and AoE damage dumps refuse under two hostiles in the blast geometry, measured around
|
|
2031
|
+
// the caster or the focus. A hostile-aura PBAoE stays fine at one target.
|
|
2032
|
+
if (packDensityOf(s)) {
|
|
2033
|
+
const yards = multiTargetGateYards(s);
|
|
2034
|
+
const dists = groundMultiOf(s) ? blastDistsFromTarget : blastDists;
|
|
2035
|
+
let near = 0;
|
|
2036
|
+
if (dists) {
|
|
2037
|
+
for (const d of dists) if (d <= yards) near++;
|
|
2038
|
+
} else {
|
|
2039
|
+
near = blastEnemyCount();
|
|
2040
|
+
}
|
|
2041
|
+
if (near < 2) return false;
|
|
2042
|
+
}
|
|
2043
|
+
// Authored mage packs gated Nova on fight-wide enemyCount — it fired at 25 yd
|
|
2044
|
+
// with an empty disc, then the kite walked the caster out of Frostbolt.
|
|
2045
|
+
if (novaKiteOf(s) && (blastDists || enemyDists)
|
|
2046
|
+
&& !novaDiscOccupied(blastDists ?? enemyDists, s)) {
|
|
2047
|
+
return false;
|
|
2048
|
+
}
|
|
2049
|
+
// Nothing in the disc the root can hold (immune boss / full root DR) — see above.
|
|
2050
|
+
// Pre-fix A/B on this ledger family: 1 nova and 7.7 yd of drift into a root-immune mob.
|
|
2051
|
+
if (unholdableAoeCc?.has(s.id)) return false;
|
|
2052
|
+
return canCastSpell(f, foe, s, ignoreForm ? { ignoreForm: true } : undefined);
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
// "Is the thing I am about to apply already on the body it lands on?" — asked by the engine,
|
|
2056
|
+
// so no authored row has to remember it. Periodic damage aims at the focus; a periodic heal is
|
|
2057
|
+
// the selected friendly or the caster. Cite: Spell.cpp:2298-2306 TARGET_UNIT_FRIEND.
|
|
2058
|
+
const periodicAlreadyOnAimedBody = (id: number, triageAim: boolean): boolean => {
|
|
2059
|
+
const s = kitById(f.spec.spells).get(id);
|
|
2060
|
+
if (!s) return false;
|
|
2061
|
+
const heals = s.effects.some((e) => e.kind === 'restore_health');
|
|
2062
|
+
const damages = s.effects.some((e) => e.kind === 'dot');
|
|
2063
|
+
if (heals === damages) return false; // neither, or a drain that is both — leave those to rows
|
|
2064
|
+
if (heals) {
|
|
2065
|
+
if (s.effects.some((e) => e.kind === 'restore_health' && e.applyTo === 'pet')) return false;
|
|
2066
|
+
const pin = f.friendlyPin ? ctx.bySide(f.friendlyPin) : null;
|
|
2067
|
+
const body = triageAim
|
|
2068
|
+
? (hurt ?? f)
|
|
2069
|
+
: selectedOrSelfFriendly(f, foe, pin, dead, isEnemySide);
|
|
2070
|
+
if (body === f) return auraCovered(selfSubj.buffs, id);
|
|
2071
|
+
return auraCovered(body.buffActive, id);
|
|
2072
|
+
}
|
|
2073
|
+
if (dead(foe) || foe === f || !targetSubj) return false;
|
|
2074
|
+
return auraCovered(targetSubj.debuffs, id);
|
|
2075
|
+
};
|
|
2076
|
+
|
|
2077
|
+
return {
|
|
2078
|
+
self: selfSubj,
|
|
2079
|
+
target: targetSubj,
|
|
2080
|
+
pet: petSubj,
|
|
2081
|
+
inCombat: fighterIsInCombat(f, t),
|
|
2082
|
+
combatTimeMs: fighterCombatTimeMs(f, t),
|
|
2083
|
+
sessionMs: t,
|
|
2084
|
+
enemyCount: hostiles.length,
|
|
2085
|
+
...(enemyDists ? { enemyDists } : {}),
|
|
2086
|
+
...(enemyDistsFromTarget ? { enemyDistsFromTarget } : {}),
|
|
2087
|
+
...(unholdableAoeCc ? { unholdableAoeCcSpellIds: unholdableAoeCc } : {}),
|
|
2088
|
+
...(needs.kickableCastPresent && kickableCastPresentOf(f, hostiles)
|
|
2089
|
+
? { kickableCastPresent: true }
|
|
2090
|
+
: {}),
|
|
2091
|
+
...(cfg.outdoors === false ? { outdoors: false } : {}),
|
|
2092
|
+
totemSlots: occupied,
|
|
2093
|
+
weaponImbueHands: imbueSetOf(f, t),
|
|
2094
|
+
// Dual-wield open hands: weapon profiles only (shield → no spec.offhand).
|
|
2095
|
+
weaponImbueOpenHands: openHandsOf(f, t),
|
|
2096
|
+
// M7: unit's own kit counts (ally / BG enemy / player — never another seat's bags).
|
|
2097
|
+
itemCounts,
|
|
2098
|
+
...(f.spec.equippedItemEntries?.length
|
|
2099
|
+
? { equippedItemEntries: new Set(f.spec.equippedItemEntries) }
|
|
2100
|
+
: {}),
|
|
2101
|
+
allyLowestHpPct: hurt ? (hurt.hp / Math.max(1, hurt.spec.maxHealth)) * 100 : 0,
|
|
2102
|
+
...(triageBuffs ? { allyTriageBuffs: triageBuffs } : {}),
|
|
2103
|
+
...(needs.allySubject
|
|
2104
|
+
? {
|
|
2105
|
+
allySubject: (fl?: AllyFilter) => allySubjectOf(f, fl),
|
|
2106
|
+
allyAimSide: (fl?: AllyFilter) => aimedAllyOf(f, fl)?.side ?? null,
|
|
2107
|
+
}
|
|
2108
|
+
: {}),
|
|
2109
|
+
allyHpPcts,
|
|
2110
|
+
...(allyDistHp ? { allyDistHp } : {}),
|
|
2111
|
+
// Arena seats pick via foeOf (GetVictim), not the creature threat list seeded on
|
|
2112
|
+
// the lead dps. Cite: PlayerbotRogueAI.cpp:318 pTarget->GetVictim().
|
|
2113
|
+
// allyHasAggro is the party peel half of the same walk (Blind the trainer on the healer).
|
|
2114
|
+
// Cite: PlayerbotRogueAI.cpp:208-219 FindAttacker(AIT_VICTIMSELF) then Blind.
|
|
2115
|
+
...(needs.hasAggro
|
|
2116
|
+
? {
|
|
2117
|
+
hasAggro: hostiles.some((e) => {
|
|
2118
|
+
if (dead(e) || e === f) return false;
|
|
2119
|
+
const v = foeOf(e);
|
|
2120
|
+
return (v !== e && v === f) || threatOf(e)?.getCurrentVictimId() === f.side;
|
|
2121
|
+
}),
|
|
2122
|
+
allyHasAggro: hostiles.some((e) => {
|
|
2123
|
+
if (dead(e) || e === f) return false;
|
|
2124
|
+
const v = foeOf(e);
|
|
2125
|
+
if (v !== e && v !== f && !dead(v) && friendlyPoolOf(f).includes(v)) return true;
|
|
2126
|
+
const vid = threatOf(e)?.getCurrentVictimId();
|
|
2127
|
+
if (vid == null || vid === f.side) return false;
|
|
2128
|
+
const victim = ctx.bySide(vid as Side);
|
|
2129
|
+
return !!victim && victim !== f && !dead(victim) && friendlyPoolOf(f).includes(victim);
|
|
2130
|
+
}),
|
|
2131
|
+
}
|
|
2132
|
+
: {}),
|
|
2133
|
+
targetAttackingMe: !dead(foe) && foe !== f && (
|
|
2134
|
+
(foeOf(foe) !== foe && foeOf(foe) === f)
|
|
2135
|
+
|| threatOf(foe)?.getCurrentVictimId() === f.side
|
|
2136
|
+
),
|
|
2137
|
+
...(enemyCastingOnAlly ? { enemyCastingOnAlly } : {}),
|
|
2138
|
+
...(enemyCastingOnSelf ? { enemyCastingOnSelf } : {}),
|
|
2139
|
+
...(needs.aggroHolder ? ((): {
|
|
2140
|
+
aggroHolderDebuffs?: Set<number>;
|
|
2141
|
+
aggroHolderDebuffStacks?: Map<number, number>;
|
|
2142
|
+
} => {
|
|
2143
|
+
if (dead(foe) || foe === f) return {};
|
|
2144
|
+
const vid = threatOf(foe)?.getCurrentVictimId();
|
|
2145
|
+
if (vid == null) return {};
|
|
2146
|
+
const victim = ctx.bySide(vid as Side);
|
|
2147
|
+
if (!victim || dead(victim)) return {};
|
|
2148
|
+
const { ids, stacks } = aggroHolderChipsOf(victim);
|
|
2149
|
+
return {
|
|
2150
|
+
aggroHolderDebuffs: ids,
|
|
2151
|
+
...(stacks.size ? { aggroHolderDebuffStacks: stacks } : {}),
|
|
2152
|
+
};
|
|
2153
|
+
})() : {}),
|
|
2154
|
+
charmedHostileCount,
|
|
2155
|
+
meleeAllyCount,
|
|
2156
|
+
// Counted in place: the spread built two throwaway arrays per fighter per tick. The PET term is
|
|
2157
|
+
// gone — a pet carries a class id, so a dead one added 1 to a metric no resurrect can act on.
|
|
2158
|
+
deadAllyCount: deadAllyCountNow(),
|
|
2159
|
+
...(enemyHealerDists ? { enemyHealerDists } : {}),
|
|
2160
|
+
...(healerSeatPowerPcts ? { healerPowerPcts: healerSeatPowerPcts } : {}),
|
|
2161
|
+
partySeats,
|
|
2162
|
+
...(hazardProximityYd != null ? { hazardProximityYd } : {}),
|
|
2163
|
+
...(() => {
|
|
2164
|
+
if (dead(foe) || foe === f) return {};
|
|
2165
|
+
const d = ctx.distanceBetween(f, foe);
|
|
2166
|
+
return d != null ? { targetDistanceYd: d } : {};
|
|
2167
|
+
})(),
|
|
2168
|
+
creatureDistanceYd: (entry) => {
|
|
2169
|
+
const id = entry | 0;
|
|
2170
|
+
if (id <= 0) return null;
|
|
2171
|
+
const here = ctx.posOf?.(f.side);
|
|
2172
|
+
if (!here) return null;
|
|
2173
|
+
let best: number | null = null;
|
|
2174
|
+
for (const h of hostiles) {
|
|
2175
|
+
if (dead(h) || ((h.spec.creatureEntry ?? 0) | 0) !== id) continue;
|
|
2176
|
+
const p = ctx.posOf?.(h.side);
|
|
2177
|
+
if (!p) continue;
|
|
2178
|
+
const d = combatDistanceYd(here, p);
|
|
2179
|
+
if (d != null && (best == null || d < best)) best = d;
|
|
2180
|
+
}
|
|
2181
|
+
return best;
|
|
2182
|
+
},
|
|
2183
|
+
// Lazy on purpose: a closure costs nothing until a camp-split row asks, so this needs no
|
|
2184
|
+
// ProviderNeeds flag. Cite: boss_thaddius.cpp:443-468 the same 13 yd both halves read.
|
|
2185
|
+
allyWithoutAuraNearYd: (aura) => {
|
|
2186
|
+
const here = ctx.posOf?.(f.side);
|
|
2187
|
+
if (!here) return null;
|
|
2188
|
+
let best: number | null = null;
|
|
2189
|
+
for (const u of alliesMissingAura(f, aura)) {
|
|
2190
|
+
const p = ctx.posOf?.(u.side);
|
|
2191
|
+
if (!p) continue;
|
|
2192
|
+
const d = combatDistanceYd(here, p);
|
|
2193
|
+
if (d != null && (best == null || d < best)) best = d;
|
|
2194
|
+
}
|
|
2195
|
+
return best;
|
|
2196
|
+
},
|
|
2197
|
+
// Does anyone in the party still need this buff? The gate on a `{ allyMissingAura }` row, and
|
|
2198
|
+
// it reads the same list the cast will pick from, so the row cannot fire with nobody to aim at.
|
|
2199
|
+
allyMissingAura: (aura, among) => alliesMissingAura(f, aura, among).length > 0,
|
|
2200
|
+
// Max stacks of `aura` on any living friendly. 0 when nobody holds it.
|
|
2201
|
+
// Cite: raidCustomRows RaidTriggerOn 'anyone'; buffActive is every live aura.
|
|
2202
|
+
allyAuraStacks: (aura) => {
|
|
2203
|
+
const id = aura | 0;
|
|
2204
|
+
if (id <= 0) return 0;
|
|
2205
|
+
let best = 0;
|
|
2206
|
+
for (const u of party.pool) {
|
|
2207
|
+
if (dead(u) || !u.buffActive.has(id)) continue;
|
|
2208
|
+
let stacks = 1;
|
|
2209
|
+
for (const e of ctx.effectsOn(u.side)) {
|
|
2210
|
+
if (e.spellId !== id) continue;
|
|
2211
|
+
const n = e.stacks ?? 1;
|
|
2212
|
+
if (n > stacks) stacks = n;
|
|
2213
|
+
}
|
|
2214
|
+
if (stacks > best) best = stacks;
|
|
2215
|
+
}
|
|
2216
|
+
return best;
|
|
2217
|
+
},
|
|
2218
|
+
...(inTargetCone != null ? { inTargetCone } : {}),
|
|
2219
|
+
...(hostileCreatureEntries ? { hostileCreatureEntries } : {}),
|
|
2220
|
+
pullSpentSpellIds,
|
|
2221
|
+
pullSpentItemEntries,
|
|
2222
|
+
...((): { followUpChain?: readonly RuleAfterStep[] } => {
|
|
2223
|
+
const chain = liveFollowUpChain(f, t);
|
|
2224
|
+
return chain.length ? { followUpChain: chain } : {};
|
|
2225
|
+
})(),
|
|
2226
|
+
dispellableOnFriendlies,
|
|
2227
|
+
dispellableOnTarget,
|
|
2228
|
+
...raidSnapFields(f, liveRaidPlan()),
|
|
2229
|
+
// `target: 'other'` rules ask this before they win the tick — same selector the cast
|
|
2230
|
+
// uses, so "the rule fired" and "a legal second mob exists" can never disagree.
|
|
2231
|
+
offTargetSpell: (id) => pickCcTarget(f, foe, kitById(f.spec.spells).get(id)) != null,
|
|
2232
|
+
// Same selector, as a subject: the gate on an off-target row reads the mob the cast hits.
|
|
2233
|
+
offTargetSubject: (id) => {
|
|
2234
|
+
const other = pickCcTarget(f, foe, kitById(f.spec.spells).get(id));
|
|
2235
|
+
return other ? subjectFromView(other, f) : null;
|
|
2236
|
+
},
|
|
2237
|
+
// Same record the off-target selector reads, asked about the focus: a rule with no
|
|
2238
|
+
// pack-density gate casts AT the focus, and re-applying there is the spam this fixes.
|
|
2239
|
+
ccBrokenOnTarget: (id) => {
|
|
2240
|
+
if (foe.ccDamageBrokenBy?.has(f.side) !== true) return false;
|
|
2241
|
+
const s = kitById(f.spec.spells).get(id);
|
|
2242
|
+
return s != null && isBreakOnDamageCc(s);
|
|
2243
|
+
},
|
|
2244
|
+
auraCovered,
|
|
2245
|
+
periodicAlreadyOnAimedBody,
|
|
2246
|
+
usableSpell: (id) => usableSpellWith(id, false),
|
|
2247
|
+
// The stance-row probe: the same gates with the shapeshift one skipped. Paired with
|
|
2248
|
+
// `stanceSpellForForm`, it lets the evaluator swap stance FOR a row instead of skipping it.
|
|
2249
|
+
usableSpellIgnoringForm: (id) => usableSpellWith(id, true),
|
|
2250
|
+
stanceSpellForForm: (formId) => {
|
|
2251
|
+
const want = formId | 0;
|
|
2252
|
+
if (!want) return null;
|
|
2253
|
+
for (const s of f.spec.spells) {
|
|
2254
|
+
if (!isFormSpell(s) || formIdFromSpell(s) !== want) continue;
|
|
2255
|
+
return canCastSpell(f, f, s) ? s.id : null;
|
|
2256
|
+
}
|
|
2257
|
+
return null;
|
|
2258
|
+
},
|
|
2259
|
+
formEnteredBySpell: (id) => {
|
|
2260
|
+
const s = kitById(f.spec.spells).get(id);
|
|
2261
|
+
return s && isFormSpell(s) ? (formIdFromSpell(s) || null) : null;
|
|
2262
|
+
},
|
|
2263
|
+
usableOnCreatureEntry: (id, entry) => {
|
|
2264
|
+
const s = kitById(f.spec.spells).get(id);
|
|
2265
|
+
if (!s || isTriggeredCompanion(f, s)) return false;
|
|
2266
|
+
if (s.onNextSwing && f.pendingSwing) return false;
|
|
2267
|
+
if (!spellAllowedAtLocation(s, cfg.outdoors !== false, cfg.battlegroundMap)) return false;
|
|
2268
|
+
const halt = f.haltDpsSchool;
|
|
2269
|
+
if (
|
|
2270
|
+
halt && isHostileSpellDef(s) && haltDpsRefusesSchool(halt, s.school)
|
|
2271
|
+
&& haltDpsCoversEntry(f.haltDpsEntries, entry)
|
|
2272
|
+
) return false;
|
|
2273
|
+
const tgt = livingHostilesFor(f).find(
|
|
2274
|
+
(h) => !dead(h) && h.spec.creatureEntry === entry,
|
|
2275
|
+
);
|
|
2276
|
+
if (!tgt) return false;
|
|
2277
|
+
return canCastSpell(f, tgt, s);
|
|
2278
|
+
},
|
|
2279
|
+
// The bag says the object EXISTS; the actionable test says it is worth pressing now. All six
|
|
2280
|
+
// gnomeface buttons are present all fight and only the lit one does anything.
|
|
2281
|
+
usableObject: (goEntry) => combatObjectBag.includes(goEntry | 0)
|
|
2282
|
+
&& objectActionable(goEntry),
|
|
2283
|
+
usableItem: (entry) => {
|
|
2284
|
+
const it = bag.find((x) => x.entry === entry);
|
|
2285
|
+
if (!it || it.count <= 0) return false;
|
|
2286
|
+
if (f.castingSpell) return false;
|
|
2287
|
+
if ((cdMap.get(entry) ?? 0) > t) return false;
|
|
2288
|
+
if (
|
|
2289
|
+
it.category != null
|
|
2290
|
+
&& it.categoryCooldownMs != null
|
|
2291
|
+
&& it.categoryCooldownMs > 0
|
|
2292
|
+
&& (f.categoryCooldowns.get(it.category) ?? 0) > t
|
|
2293
|
+
) {
|
|
2294
|
+
return false;
|
|
2295
|
+
}
|
|
2296
|
+
return true;
|
|
2297
|
+
},
|
|
2298
|
+
// Rank ladder: a family row drinks the best rung this unit actually carries. The kit
|
|
2299
|
+
// stamped `family`/`rank` (combatItems) because the session holds no DataStore.
|
|
2300
|
+
bestFamilyEntry: (family) => {
|
|
2301
|
+
let bestEntry: number | null = null;
|
|
2302
|
+
let bestRank = -1;
|
|
2303
|
+
for (const it of bag) {
|
|
2304
|
+
if (it.family !== family || it.count <= 0) continue;
|
|
2305
|
+
const rank = it.rank ?? 0;
|
|
2306
|
+
if (rank > bestRank) { bestRank = rank; bestEntry = it.entry; }
|
|
2307
|
+
}
|
|
2308
|
+
return bestEntry;
|
|
2309
|
+
},
|
|
2310
|
+
};
|
|
2311
|
+
};
|
|
2312
|
+
|
|
2313
|
+
/**
|
|
2314
|
+
* The capture wrapper. Absent `ctx.snapshotCapture` this is one call and one branch, so a
|
|
2315
|
+
* normal fight pays nothing; a probe sets the Map and then reads any seat's condition values.
|
|
2316
|
+
*/
|
|
2317
|
+
const allySnapshot = (f: Fighter, foe: Fighter, needs: ProviderNeeds = NEEDS_ALL): RotationSnapshot => {
|
|
2318
|
+
const snap = allySnapshotOf(f, foe, needs);
|
|
2319
|
+
cfg.snapshotCapture?.set(f.side, snap);
|
|
2320
|
+
return snap;
|
|
2321
|
+
};
|
|
2322
|
+
|
|
2323
|
+
const pickAction = (f: Fighter, foe: Fighter): SpellDef | null => {
|
|
2324
|
+
const t = ctx.t;
|
|
2325
|
+
// E6: MT/OT focus from the raid plan (boss-a / boss-b by stable side order — no entry id).
|
|
2326
|
+
// Latch onto allyManualTarget so the next foeOf / swing follows the same body.
|
|
2327
|
+
if (f.spec.groupRole === 'tank' && !dead(foe) && isEnemySide(foe.side)) {
|
|
2328
|
+
const planned = tankPlanFocus(f, foe, liveRaidPlan());
|
|
2329
|
+
if (planned !== foe && isAllySide(f.side) && isEnemySide(planned.side)) {
|
|
2330
|
+
ctx.allyManualTarget.set(f.side, planned.side);
|
|
2331
|
+
foe = planned;
|
|
2332
|
+
} else if (planned !== foe) {
|
|
2333
|
+
foe = planned;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
// Hard-controlled: only a breaker that strips the active control(s) (WotF under fear, not stun).
|
|
2337
|
+
// Cite: KI-741/KI-737 mechanic-aware isBreaker.
|
|
2338
|
+
if (isControlledHard(f)) {
|
|
2339
|
+
return f.spec.spells.find((s) => isBreaker(s, f) && !onCooldown(f, s) && canAfford(f, s)) ?? null;
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
// Ghost Wolf is a 3s cast — recast never lands while walking in. Buff-cancel, then kit.
|
|
2343
|
+
// Also cancel when the chip is up but formSpellId never seeded (display-aura path).
|
|
2344
|
+
if (
|
|
2345
|
+
isMobilityTravelForm(f.form)
|
|
2346
|
+
|| [...f.buffActive].some((id) => isMobilityFormSpellId(id))
|
|
2347
|
+
) {
|
|
2348
|
+
ctx.leaveForm?.(f);
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// The foe lookup returns the caster when no hostile lives. Enemies and charmed bodies idle here,
|
|
2352
|
+
// while friendlies fall through so the rotation can recast keep-ups.
|
|
2353
|
+
if (foe === f && (isCharmed(f) || isEnemySide(f.side))) return null;
|
|
2354
|
+
|
|
2355
|
+
// A charmed thrall and our own summons are PetAI-style: melee, or the first usable kit spell. Both
|
|
2356
|
+
// are creature bodies with no class rotation, so the ally path would leave them auto-attacking.
|
|
2357
|
+
if (isCharmed(f) || f.spec.summonOwnerSide != null) {
|
|
2358
|
+
const breaker = f.spec.spells.find((s) => isBreaker(s, f) && !onCooldown(f, s) && canAfford(f, s));
|
|
2359
|
+
if (breaker) return breaker; // WotF / similar if available while charmed
|
|
2360
|
+
const nuke = f.spec.spells.find(
|
|
2361
|
+
(s) =>
|
|
2362
|
+
!isTriggeredCompanion(f, s)
|
|
2363
|
+
&& s.effects.some((e) => e.kind === 'direct_damage' || e.kind === 'weapon_percent_damage')
|
|
2364
|
+
&& (!s.onNextSwing || !f.pendingSwing)
|
|
2365
|
+
&& canCastSpell(f, foe, s),
|
|
2366
|
+
);
|
|
2367
|
+
if (nuke) return nuke;
|
|
2368
|
+
// PetAI autocast over the rest of the charmed body's kit: the core puts a charmed creature's
|
|
2369
|
+
// spells on the pet bar, which is what lets a mind-controlled add perform a mechanic at all.
|
|
2370
|
+
const autocast = f.spec.spells.find(
|
|
2371
|
+
(s) =>
|
|
2372
|
+
!isTriggeredCompanion(f, s)
|
|
2373
|
+
&& s.passive !== true
|
|
2374
|
+
&& (s.effects.length > 0 || (s.rawEffects?.length ?? 0) > 0)
|
|
2375
|
+
&& (!s.onNextSwing || !f.pendingSwing)
|
|
2376
|
+
&& canCastSpell(f, foe, s),
|
|
2377
|
+
);
|
|
2378
|
+
return autocast ?? null; // null → auto-attack / swing
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// The enemy EventAI cast scheduler. A seat owning a Rotation skips it (the empty-slot return
|
|
2382
|
+
// would mute a BG playerbot), and so does a seat's PET, which takes the ally pet's heuristics.
|
|
2383
|
+
if (isEnemySide(f.side) && !ctx.unitActionProvider.get(f.side) && f.spec.petFullKit == null) {
|
|
2384
|
+
const castState = enemyCastBySide.get(f.side as EnemySide);
|
|
2385
|
+
if (castState) {
|
|
2386
|
+
// Fleeing: stop selecting casts (SetCombatScriptStatus / ORDER_FLEEING).
|
|
2387
|
+
// Cite: UnitAI.cpp DoFlee ~829–841.
|
|
2388
|
+
if (castState.eventAi.fleeing) return null;
|
|
2389
|
+
if (castState.slots.length === 0) return null;
|
|
2390
|
+
// No threat means no victim and so not in combat, and no cast is selectable. Only the endless
|
|
2391
|
+
// path was gated, so a world-fight mob cast at t=0 before the player's opener landed.
|
|
2392
|
+
const tm1 = threatOf(f);
|
|
2393
|
+
if (!tm1 || tm1.isEmpty()) return null;
|
|
2394
|
+
// UpdateSpellLists consumes its timer at the top of every evaluation, cast or not, so the poll
|
|
2395
|
+
// is a cadence rather than a cooldown on success. Off-poll ticks still run the other slots.
|
|
2396
|
+
const spellListPollOpen = t >= castState.spellListPollAt;
|
|
2397
|
+
if (spellListPollOpen) castState.spellListPollAt = t + SPELL_LIST_POLL_MS;
|
|
2398
|
+
castState.pendingSlot = null;
|
|
2399
|
+
const maxHp = Math.max(1, f.spec.maxHealth);
|
|
2400
|
+
const hpPct = Math.floor((f.hp * 100) / maxHp);
|
|
2401
|
+
const manaMax = f.maxMana > 0 ? f.maxMana : (f.spec.powerType === 'mana' ? f.spec.maxPower : 0);
|
|
2402
|
+
const manaNow = f.spec.powerType === 'mana' ? f.power : f.mana;
|
|
2403
|
+
const manaPct = manaMax > 0 ? Math.floor((manaNow * 100) / manaMax) : undefined;
|
|
2404
|
+
const foeMaxHp = Math.max(1, foe.spec.maxHealth);
|
|
2405
|
+
const targetHpPct = Math.floor((foe.hp * 100) / foeMaxHp);
|
|
2406
|
+
const foeManaMax = foe.maxMana > 0 ? foe.maxMana : (foe.spec.powerType === 'mana' ? foe.spec.maxPower : 0);
|
|
2407
|
+
const foeManaNow = foe.spec.powerType === 'mana' ? foe.power : foe.mana;
|
|
2408
|
+
const targetManaPct = foeManaMax > 0 ? Math.floor((foeManaNow * 100) / foeManaMax) : undefined;
|
|
2409
|
+
// The friendly-condition rows scan living pack mates. On the endless floor only engaged injects
|
|
2410
|
+
// are pack mates — a passive standing nearby is not.
|
|
2411
|
+
const isCcLocked = (u: Fighter): boolean =>
|
|
2412
|
+
u.stunnedUntil > t || u.fearedUntil > t || u.incapUntil > t;
|
|
2413
|
+
const packMate = (ally: Fighter): boolean => {
|
|
2414
|
+
if (ally === f || !(ally.hp > 0)) return false;
|
|
2415
|
+
if (!cfg.endless) return true;
|
|
2416
|
+
const tm2 = threatOf(ally);
|
|
2417
|
+
return !!tm2 && !tm2.isEmpty();
|
|
2418
|
+
};
|
|
2419
|
+
const friendlies = ctx.livingEnemies()
|
|
2420
|
+
.filter(packMate)
|
|
2421
|
+
.map((ally) => {
|
|
2422
|
+
const aMax = Math.max(1, ally.spec.maxHealth);
|
|
2423
|
+
return {
|
|
2424
|
+
hp: ally.hp,
|
|
2425
|
+
maxHp: aMax,
|
|
2426
|
+
hpPct: Math.floor((ally.hp * 100) / aMax),
|
|
2427
|
+
distanceYd: distanceBetween(f, ally),
|
|
2428
|
+
hasAura: (spellId: number, stacks: number) =>
|
|
2429
|
+
ally.buffActive.has(spellId) && stacks <= 1,
|
|
2430
|
+
hasCc: isCcLocked(ally),
|
|
2431
|
+
};
|
|
2432
|
+
});
|
|
2433
|
+
// Include self so healers can top themselves up (DoSelectLowestHpFriendly targetSelf).
|
|
2434
|
+
{
|
|
2435
|
+
const sMax = Math.max(1, f.spec.maxHealth);
|
|
2436
|
+
friendlies.push({
|
|
2437
|
+
hp: f.hp,
|
|
2438
|
+
maxHp: sMax,
|
|
2439
|
+
hpPct,
|
|
2440
|
+
distanceYd: 0,
|
|
2441
|
+
hasAura: (spellId: number, stacks: number) =>
|
|
2442
|
+
f.buffActive.has(spellId) && stacks <= 1,
|
|
2443
|
+
hasCc: isCcLocked(f),
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
// The combat-condition gate for spell-list rows, built here because only this scope can count
|
|
2447
|
+
// who is attacking whom. Counts stay undefined without geometry, which reads as not modelled.
|
|
2448
|
+
const condTable = cfg.combatConditionTable;
|
|
2449
|
+
const attackersOf = (u: Fighter): Fighter[] =>
|
|
2450
|
+
livingHostilesFor(u).filter((h) => !dead(h) && foeOf(h) === u);
|
|
2451
|
+
const conditionUnit = (u: Fighter): ConditionUnit => {
|
|
2452
|
+
const att = attackersOf(u);
|
|
2453
|
+
const uMaxHp = Math.max(1, u.spec.maxHealth);
|
|
2454
|
+
const out: ConditionUnit = {
|
|
2455
|
+
hpPct: Math.floor((u.hp * 100) / uMaxHp),
|
|
2456
|
+
attackers: att.length,
|
|
2457
|
+
casting: unitIsCastingOrChanneling(u, t),
|
|
2458
|
+
};
|
|
2459
|
+
// One null distance means the session has no geometry; a fabricated 0 would make
|
|
2460
|
+
// Thaddius throw Ball Lightning while tanked. Cite: isInMeleeOf "do not invent".
|
|
2461
|
+
let melee = 0;
|
|
2462
|
+
let known = true;
|
|
2463
|
+
for (const h of att) {
|
|
2464
|
+
const d = distanceBetween(u, h);
|
|
2465
|
+
if (d == null) { known = false; break; }
|
|
2466
|
+
if (d <= MIN_AGGRO_DISTANCE) melee++;
|
|
2467
|
+
}
|
|
2468
|
+
if (known) out.meleeAttackers = melee;
|
|
2469
|
+
return out;
|
|
2470
|
+
};
|
|
2471
|
+
|
|
2472
|
+
// ANY living hostile inside melee reach, for the existence-test rows. Undefined distances
|
|
2473
|
+
// leave it undefined, which reads as "not modelled" rather than "nobody".
|
|
2474
|
+
const anyHostileInMelee = ((): boolean | undefined => {
|
|
2475
|
+
let known = false;
|
|
2476
|
+
for (const h of livingHostilesFor(f)) {
|
|
2477
|
+
if (dead(h)) continue;
|
|
2478
|
+
const d = distanceBetween(f, h);
|
|
2479
|
+
if (d == null) continue;
|
|
2480
|
+
known = true;
|
|
2481
|
+
const reach = meleeContactYd(
|
|
2482
|
+
ctx.isMoving(f.side) && ctx.isMoving(h.side),
|
|
2483
|
+
f.spec.combatReachYd,
|
|
2484
|
+
h.spec.combatReachYd,
|
|
2485
|
+
);
|
|
2486
|
+
if (d <= reach) return true;
|
|
2487
|
+
}
|
|
2488
|
+
return known ? false : undefined;
|
|
2489
|
+
})();
|
|
2490
|
+
|
|
2491
|
+
let slot = pickCreatureCast(castState.slots, {
|
|
2492
|
+
t,
|
|
2493
|
+
spellListPollOpen,
|
|
2494
|
+
// A bar up / a live channel refuses every non-TRIGGERED, non-INTERRUPT_PREVIOUS row.
|
|
2495
|
+
// Cite: UnitAI.cpp:178,:280,:941,:1194.
|
|
2496
|
+
selfCasting: unitIsCastingOrChanneling(f, t),
|
|
2497
|
+
rng,
|
|
2498
|
+
hpPct,
|
|
2499
|
+
manaPct,
|
|
2500
|
+
targetHpPct,
|
|
2501
|
+
targetManaPct,
|
|
2502
|
+
// EventAI m_Phase for event_inverse_phase_mask. Cite: CheckEvent ~266–270.
|
|
2503
|
+
phase: castState.eventAi.phase,
|
|
2504
|
+
// Reaching here means the threat gate above passed, so the creature has a victim.
|
|
2505
|
+
inCombat: true,
|
|
2506
|
+
friendlies,
|
|
2507
|
+
// buffActive is spell-id presence (stacks modeled as 1 when present).
|
|
2508
|
+
hasAura: (spellId, stacks) => f.buffActive.has(spellId) && stacks <= 1,
|
|
2509
|
+
targetHasAura: (spellId, stacks) => foe.buffActive.has(spellId) && stacks <= 1,
|
|
2510
|
+
// CAST_AURA_NOT_PRESENT reads the SPELL'S target: the victim for a hostile row,
|
|
2511
|
+
// the caster for a self-cast. Cite: UnitAI.cpp:184-192.
|
|
2512
|
+
targetHasSpellAura: (spellId) => foe.buffActive.has(spellId),
|
|
2513
|
+
selfHasSpellAura: (spellId) => f.buffActive.has(spellId),
|
|
2514
|
+
targetCasting: unitIsCastingOrChanneling(foe, t),
|
|
2515
|
+
// EVENT_T_SPELLHIT: only a hit from this tick or the last one counts. The stamp
|
|
2516
|
+
// expires so a hit no row filters on cannot satisfy an "any spell" row later.
|
|
2517
|
+
spellHitId: freshSpellHit(f) ? f.spellHitId : undefined,
|
|
2518
|
+
spellHitSchoolMask: freshSpellHit(f) ? f.spellHitSchoolMask : undefined,
|
|
2519
|
+
// CAST_PLAYER_ONLY — player side is always a player; pets are not.
|
|
2520
|
+
// Cite: CreatureEventAI.cpp ~790–791 SELECT_FLAG_PLAYER.
|
|
2521
|
+
targetIsPlayer: foe.side === 'player' || isAllySide(foe.side),
|
|
2522
|
+
// getClass() pool for selectClass slots. Cite: boss_nefarian.cpp:266-280.
|
|
2523
|
+
hostileHasClass: (classId) => livingHostilesFor(f).some(
|
|
2524
|
+
(h) => (h.side === 'player' || isAllySide(h.side))
|
|
2525
|
+
&& ((h.spec.classId ?? 0) | 0) === (classId | 0),
|
|
2526
|
+
),
|
|
2527
|
+
// creature_spell_list CombatCondition. Cite: UnitAI.cpp:1218-1224.
|
|
2528
|
+
instanceDataOk: (field, equals, notEquals) => dataGatePasses(
|
|
2529
|
+
{ field, equals, notEquals, cite: 'pick' },
|
|
2530
|
+
ctx.instanceData,
|
|
2531
|
+
),
|
|
2532
|
+
anyHostileInMelee,
|
|
2533
|
+
victimInMelee: (() => {
|
|
2534
|
+
const d = distanceBetween(f, foe);
|
|
2535
|
+
const reach = meleeContactYd(
|
|
2536
|
+
ctx.isMoving(f.side) && ctx.isMoving(foe.side),
|
|
2537
|
+
f.spec.combatReachYd,
|
|
2538
|
+
foe.spec.combatReachYd,
|
|
2539
|
+
);
|
|
2540
|
+
return d == null ? undefined : d <= reach;
|
|
2541
|
+
})(),
|
|
2542
|
+
combatConditionOk: !condTable ? undefined : (conditionId: number) => combatConditionMeets(conditionId, {
|
|
2543
|
+
self: conditionUnit(f),
|
|
2544
|
+
target: conditionUnit(foe),
|
|
2545
|
+
// Friend/enemy pools are the same bodies the friendlies scan above walks.
|
|
2546
|
+
friends: ctx.livingEnemies().map(conditionUnit),
|
|
2547
|
+
enemies: livingHostilesFor(f).filter((h) => !dead(h)).map(conditionUnit),
|
|
2548
|
+
combatCondition: (cid) => condTable.combatCondition(cid),
|
|
2549
|
+
unitCondition: (uid) => condTable.unitCondition(uid),
|
|
2550
|
+
}),
|
|
2551
|
+
// CAST_IGNORE_UNSELECTABLE — player is never unselectable in this engine.
|
|
2552
|
+
targetUnselectable: false,
|
|
2553
|
+
// DG: pairwise yards to current threat target when unitPositionsYd seeded;
|
|
2554
|
+
// else legacy encounterDistanceYd. EVENT_T_RANGE gates when defined.
|
|
2555
|
+
distanceYd: distanceBetween(f, foe),
|
|
2556
|
+
spellReady: (s) => !onCooldown(f, s),
|
|
2557
|
+
canCast: (s) => {
|
|
2558
|
+
// Resolve the cast flags for this spell from the armed slots. A forced cast already bypassed
|
|
2559
|
+
// the check, so this path is non-force only, and a triggered one skips the power cost.
|
|
2560
|
+
const slotEntry = castState.slots.find(
|
|
2561
|
+
(sl) => sl.spell.id === s.id && !sl.exhausted,
|
|
2562
|
+
)?.entry;
|
|
2563
|
+
const slotFlags = slotEntry?.castFlags ?? 0;
|
|
2564
|
+
const triggered = (slotFlags & CAST_FLAG.TRIGGERED) !== 0;
|
|
2565
|
+
if (!triggered && !canAfford(f, s)) return false;
|
|
2566
|
+
if (onCooldown(f, s)) return false;
|
|
2567
|
+
// The Widow's Embrace lockout: while its debuff is on her, Faerlina's Enrage is refused —
|
|
2568
|
+
// that dummy's whole job. Without this the strip buys the raid nothing at all.
|
|
2569
|
+
if (widowsEmbraceRefusesCast(s.id, f.buffActive.has(WIDOWS_EMBRACE_CD_SPELL_ID))) {
|
|
2570
|
+
return false;
|
|
2571
|
+
}
|
|
2572
|
+
// Charge may be effect-only with empty effects after the bake and still has to cast so the
|
|
2573
|
+
// gap-close travel runs. Script holders and raw summon or knock opcodes cast too.
|
|
2574
|
+
if (s.effects.length === 0 && !isChargeRole(s) && !(s.strikeBonus)) {
|
|
2575
|
+
const rawOk = (s.rawEffects ?? []).some((r) => {
|
|
2576
|
+
const e = r.effect | 0;
|
|
2577
|
+
// The raw opcodes that still cast with empty effects: apply-aura holders, the two summon
|
|
2578
|
+
// kinds, charge, script and knock-back. Cite: SpellEffectDefines.h:67-68.
|
|
2579
|
+
return e === 6 || e === 41 || e === 42 || e === 58 || e === 77 || e === 98;
|
|
2580
|
+
});
|
|
2581
|
+
// Dummy 3 (Gate 23138) has no mapped effects; CAST_OK still runs timer.actions.
|
|
2582
|
+
// Cite: SpellEffects.cpp:910-933; SpellEffectDefines.h SPELL_EFFECT_DUMMY=3.
|
|
2583
|
+
const timerActions = (castState.scriptDev?.ir.timers ?? []).some(
|
|
2584
|
+
(tm) => tm.spellId === s.id && (tm.actions?.length ?? 0) > 0,
|
|
2585
|
+
);
|
|
2586
|
+
if (
|
|
2587
|
+
!getAuraScript(s.id) && !rawOk && !timerActions
|
|
2588
|
+
&& !isNefarianClassCallSpell(s.id)
|
|
2589
|
+
&& (s.id | 0) !== CORRUPTED_MIND_PRIMER_SPELL_ID
|
|
2590
|
+
&& (s.id | 0) !== CHROMAGGUS_BREATH_SELECTION_SPELL_ID
|
|
2591
|
+
) return false;
|
|
2592
|
+
}
|
|
2593
|
+
// CheckTargetCreatureType for creature casters too (e.g. beast-only scripts).
|
|
2594
|
+
if (isHostileSpellDef(s) && !spellTargetTypeAllowed(s, foe.creatureType)) return false;
|
|
2595
|
+
// Spell range plus charge engagement for a creature cast when geometry is known. The used
|
|
2596
|
+
// flag is the boar gap-close gate, and a random-target Charge repeats on its own timer.
|
|
2597
|
+
if (isChargeRole(s)) {
|
|
2598
|
+
const sl = castState.slots.find((x) => x.spell.id === s.id && !x.exhausted);
|
|
2599
|
+
const mode = sl?.entry.target;
|
|
2600
|
+
const randomCharge = mode === TARGET_T.HOSTILE_RANDOM
|
|
2601
|
+
|| mode === TARGET_T.HOSTILE_RANDOM_PLAYER
|
|
2602
|
+
|| mode === TARGET_T.HOSTILE_RANDOM_NOT_TOP
|
|
2603
|
+
|| mode === TARGET_T.HOSTILE_RANDOM_NOT_TOP_PLAYER;
|
|
2604
|
+
if (f.chargeUsed && !randomCharge) return false;
|
|
2605
|
+
if (!chargeCastOk(f, foe, s)) return false;
|
|
2606
|
+
} else if (slotEntry?.requireMeleeTargetExists && anyHostileInMelee === true) {
|
|
2607
|
+
// The reach test is ANY hostile, not the victim: a cast at nullptr over a threat-list
|
|
2608
|
+
// pick. Cite: darkshore.cpp:803-809; shadowfang_keep.cpp:477-481.
|
|
2609
|
+
return true;
|
|
2610
|
+
} else if ((spellNeedsRangeGate(s) || foeDirectedOpaqueCast(s))
|
|
2611
|
+
&& (!planarRangeHonestYd(ctx.posOf?.(f.side), ctx.posOf?.(foe.side))
|
|
2612
|
+
|| !isInSpellRange(
|
|
2613
|
+
distanceBetween(f, foe), spellGateRangeMaxYd(s), spellGateRangeMinYd(s),
|
|
2614
|
+
ctx.isMoving(f.side) && ctx.isMoving(foe.side),
|
|
2615
|
+
f.spec.combatReachYd, foe.spec.combatReachYd,
|
|
2616
|
+
))) {
|
|
2617
|
+
return false;
|
|
2618
|
+
}
|
|
2619
|
+
return true;
|
|
2620
|
+
},
|
|
2621
|
+
});
|
|
2622
|
+
if (slot) {
|
|
2623
|
+
// One SpellHit enqueues one execution, so spend the pulse here: a repeatable row with no
|
|
2624
|
+
// repeat timer re-arms immediately and would otherwise re-fire off the same hit next tick.
|
|
2625
|
+
if (slot.entry.eventType === EVENT_T.SPELLHIT) f.spellHitAt = undefined;
|
|
2626
|
+
// getClass() switch: one CombatAI action, pick by a random player's class.
|
|
2627
|
+
// Cite: boss_nefarian.cpp:266-287. Do not ResetCombatAction when no arm matches.
|
|
2628
|
+
if ((slot.entry.selectClass ?? 0) > 0 && slot.entry.scriptId != null) {
|
|
2629
|
+
// getClass() is spec.classId (Fighter has none). Cite: boss_nefarian.cpp:266-280.
|
|
2630
|
+
const players = livingHostilesFor(f)
|
|
2631
|
+
.filter((h) => h.hp > 0 && (h.side === 'player' || isAllySide(h.side)))
|
|
2632
|
+
.map((h) => ({ classId: h.spec.classId }));
|
|
2633
|
+
const classSlot = resolveClassCallCastSlot(castState.slots, slot, players, rng);
|
|
2634
|
+
if (!classSlot) return null;
|
|
2635
|
+
slot = classSlot;
|
|
2636
|
+
} else {
|
|
2637
|
+
// EFLAG_RANDOM_ACTION: one script row fires ONE of its CAST slots per period.
|
|
2638
|
+
// Cite: CreatureEventAI.cpp ProcessEvent ~625–650.
|
|
2639
|
+
slot = resolveRandomActionCastSlot(castState.slots, slot, rng);
|
|
2640
|
+
}
|
|
2641
|
+
// event_chance — fail → ResetEvent (re-arm) without casting.
|
|
2642
|
+
// Cite: CreatureEventAI.cpp ProcessEvent ~607–612.
|
|
2643
|
+
if (!rollCreatureCastChance(slot.entry, rng)) {
|
|
2644
|
+
markCreatureCastRowUsed(castState.slots, slot, t, rng);
|
|
2645
|
+
return null;
|
|
2646
|
+
}
|
|
2647
|
+
if (slot.entry.pollOnly) {
|
|
2648
|
+
markCreatureCastRowUsed(castState.slots, slot, t, rng);
|
|
2649
|
+
countCreatureCastFire(slot);
|
|
2650
|
+
ctx.applyScriptDevTimerActions?.(f, slot.entry.spellId);
|
|
2651
|
+
return null;
|
|
2652
|
+
}
|
|
2653
|
+
// A single outgoing possess: while this caster already holds a living victim, do not re-cast
|
|
2654
|
+
// the charm, and do not mark the timer used — wait until it is free.
|
|
2655
|
+
const isCharmCast = slot.spell.effects.some(
|
|
2656
|
+
(e) => e.kind === 'cc' && (e.mechanic === 'charm' || e.control === 'charm'),
|
|
2657
|
+
);
|
|
2658
|
+
if (isCharmCast) {
|
|
2659
|
+
const holding = ctx.partyUnits().some(
|
|
2660
|
+
(u) => isCharmed(u) && u.charmedBy === f.side,
|
|
2661
|
+
);
|
|
2662
|
+
if (holding) {
|
|
2663
|
+
// Poll again soon; do not burn the 30s re-arm on a refused cast.
|
|
2664
|
+
slot.readyAt = t + 1000;
|
|
2665
|
+
return null;
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
castState.pendingSlot = slot;
|
|
2669
|
+
return slot.spell;
|
|
2670
|
+
}
|
|
2671
|
+
return null;
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
// Run one unit's editable Rotation. When a provider exists it IS the brain, so a null pick means
|
|
2676
|
+
// auto-attack rather than "ask the heuristics". Shared by companions and the PLAYER alike.
|
|
2677
|
+
|
|
2678
|
+
const runRotationProvider = (provider: AllyActionProvider): SpellDef | null | undefined => {
|
|
2679
|
+
// Clear before evaluate so a prior tick's halt cannot veto a higher-priority nuke.
|
|
2680
|
+
f.haltDpsSchool = undefined;
|
|
2681
|
+
f.haltDpsEntries = undefined;
|
|
2682
|
+
f.haltDpsUntil = undefined;
|
|
2683
|
+
f.killOrderEntries = undefined;
|
|
2684
|
+
const needs = provider.needs ?? NEEDS_ALL;
|
|
2685
|
+
let snap = allySnapshot(f, foe, needs);
|
|
2686
|
+
stampCompanionKnobs(f.side, snap);
|
|
2687
|
+
const killOrder = provider.peekKillOrder?.(snap);
|
|
2688
|
+
if (killOrder?.length) {
|
|
2689
|
+
// The main tank keeps its assigned boss while DPS and off-tanks switch to the first living add.
|
|
2690
|
+
// This is the ONLY place answering "does this unit follow the order"; focus reads the answer.
|
|
2691
|
+
if (f.spec.groupRole !== 'tank' || isOffTankSeat(liveRaidPlan(), f.side)) {
|
|
2692
|
+
f.killOrderEntries = killOrder;
|
|
2693
|
+
const preferred = pickHostileByKillOrder(f, livingHostilesFor(f), killOrder);
|
|
2694
|
+
if (preferred && preferred !== foe) {
|
|
2695
|
+
foe = preferred;
|
|
2696
|
+
snap = allySnapshot(f, foe, needs);
|
|
2697
|
+
stampCompanionKnobs(f.side, snap);
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
// Arm school halt BEFORE evaluate so usableSpell can refuse the wrong school while a
|
|
2702
|
+
// lower frost/heal rule still fires (PLAN halt_dps(school) "swap or wait").
|
|
2703
|
+
const peeked = provider.peekHalt?.(snap);
|
|
2704
|
+
if (peeked) {
|
|
2705
|
+
f.haltDpsSchool = peeked.school;
|
|
2706
|
+
f.haltDpsEntries = peeked.entries;
|
|
2707
|
+
}
|
|
2708
|
+
// `let`, because a click whose object is out of reach becomes the WALK to it and falls
|
|
2709
|
+
// through to the move branch below - one executor, one arrival test, one feed line.
|
|
2710
|
+
let pick = provider(snap);
|
|
2711
|
+
if (pick != null && typeof pick === 'object' && 'haltDps' in pick) {
|
|
2712
|
+
f.pendingMechanicMove = undefined;
|
|
2713
|
+
const haltSchool = pick.school ?? 'all';
|
|
2714
|
+
f.haltDpsSchool = haltSchool;
|
|
2715
|
+
f.haltDpsEntries = pick.entries;
|
|
2716
|
+
// A multi-school halt is an ARRAY, so key the activation edge on its TEXT: comparing the
|
|
2717
|
+
// array by identity re-fires every tick, which is the per-tick push the event forbids.
|
|
2718
|
+
const haltKey = typeof haltSchool === 'string' ? haltSchool : haltSchool.join('+');
|
|
2719
|
+
if (haltSince.get(f.side) !== haltKey) {
|
|
2720
|
+
haltSince.set(f.side, haltKey);
|
|
2721
|
+
push({ t: ctx.t, kind: 'halt_dps', actor: f.side, school: haltKey });
|
|
2722
|
+
}
|
|
2723
|
+
return null;
|
|
2724
|
+
}
|
|
2725
|
+
if (pick != null && typeof pick === 'object' && 'useObject' in pick) {
|
|
2726
|
+
const go = pick.useObject | 0;
|
|
2727
|
+
const i = combatObjectBag.indexOf(go);
|
|
2728
|
+
if (i < 0) { f.pendingMechanicMove = undefined; return null; }
|
|
2729
|
+
// YOU WALK TO A BUTTON. Thermaplugg's six stand ~40 yd off his spawn, which IS the mechanic.
|
|
2730
|
+
// The walk is the move row the tree already ships, so one executor and one arrival test cover it.
|
|
2731
|
+
const site = objectSites.get(go);
|
|
2732
|
+
const at = ctx.posOf?.(f.side);
|
|
2733
|
+
if (site && at) {
|
|
2734
|
+
const gap = detHypot(site.x - at.x, site.y - at.y);
|
|
2735
|
+
if (gap > GO_INTERACT_YD) {
|
|
2736
|
+
f.pendingObjectClick = go;
|
|
2737
|
+
pick = {
|
|
2738
|
+
move: {
|
|
2739
|
+
kind: 'move_to',
|
|
2740
|
+
x: site.x,
|
|
2741
|
+
y: site.y,
|
|
2742
|
+
...(site.z != null ? { z: site.z } : {}),
|
|
2743
|
+
},
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
if (pick != null && typeof pick === 'object' && 'useObject' in pick) {
|
|
2749
|
+
const go = pick.useObject | 0;
|
|
2750
|
+
const i = combatObjectBag.indexOf(go);
|
|
2751
|
+
if (i < 0) { f.pendingMechanicMove = undefined; return null; }
|
|
2752
|
+
f.pendingObjectClick = undefined;
|
|
2753
|
+
f.pendingMechanicMove = undefined;
|
|
2754
|
+
if (!reusableObjects.has(go)) combatObjectBag.splice(i, 1);
|
|
2755
|
+
push({ t: ctx.t, kind: 'use_object', actor: f.side, goEntry: go });
|
|
2756
|
+
emitSimEffect(ctx, { kind: 'useCombatObject', goEntry: go, side: f.side });
|
|
2757
|
+
// The click's own payload: a GO whose DB script activates a trap lays that trap's pool
|
|
2758
|
+
// here, because the trap spell is in no creature kit. Cite: useObjectTrap.ts.
|
|
2759
|
+
const trapName = layCombatObjectTrap(ctx, cfg, go, f.side);
|
|
2760
|
+
if (trapName) push({ t: ctx.t, kind: 'aura', actor: f.side, name: trapName, applied: true });
|
|
2761
|
+
return null;
|
|
2762
|
+
}
|
|
2763
|
+
if (pick != null && typeof pick === 'object' && 'move' in pick) {
|
|
2764
|
+
f.pendingMechanicMove = pick.move;
|
|
2765
|
+
// The run-out owns MOTION, not the whole tick: an instant fires while walking, since only a cast
|
|
2766
|
+
// with time left is cancelled by movement. Otherwise a bomb holder casts nothing all window.
|
|
2767
|
+
const alt = 'altSpell' in pick && pick.altSpell != null
|
|
2768
|
+
? spellFromKit(f.spec.spells, pick.altSpell)
|
|
2769
|
+
: undefined;
|
|
2770
|
+
const since = runoutSince.get(f.side);
|
|
2771
|
+
// The claim EDGE: the motion phase has not run this row yet, so it may still abort on its
|
|
2772
|
+
// first step. Nothing it did is worth a cast until it has actually moved the unit.
|
|
2773
|
+
let justClaimed = false;
|
|
2774
|
+
if (!since || since.kind !== pick.move.kind) {
|
|
2775
|
+
runoutSince.set(f.side, { kind: pick.move.kind, at: ctx.t });
|
|
2776
|
+
// ONE push per row activation, not per tick: this branch is the new-claim edge and a
|
|
2777
|
+
// held row re-enters here every tick with `since.kind` unchanged.
|
|
2778
|
+
push({ t: ctx.t, kind: 'mechanic_move', actor: f.side, move: pick.move.kind });
|
|
2779
|
+
f.mechanicMoveDone = false;
|
|
2780
|
+
justClaimed = true;
|
|
2781
|
+
} else if (f.mechanicMoveDone) {
|
|
2782
|
+
// The row is STANDING, not walking. A condition is a latch, not a countdown, so owning the
|
|
2783
|
+
// tick here spent the whole fight's GCDs on a unit already where the row wanted it.
|
|
2784
|
+
return alt && canCastSpell(f, foe, alt) ? alt : null;
|
|
2785
|
+
} else if (ctx.t - since.at > RUNOUT_YIELD_AFTER_MS && !moveOwnsPastDeadline(f, pick.move)) {
|
|
2786
|
+
// Bomb flee_raid gives up after 4 s (Vael).
|
|
2787
|
+
f.pendingMechanicMove = undefined;
|
|
2788
|
+
return alt && canCastSpell(f, foe, alt) ? alt : null;
|
|
2789
|
+
}
|
|
2790
|
+
if (alt && (alt.castTimeMs ?? 0) <= 0 && canCastSpell(f, foe, alt)) return alt;
|
|
2791
|
+
// A keep_range row that aborts on its first step used to bin the Frostbolt anyway: the frost
|
|
2792
|
+
// mage cast 3 in a 39 s duel. Cancel once the row has proven it walks.
|
|
2793
|
+
if (justClaimed) return null;
|
|
2794
|
+
// Same cancel path as click-to-move / WASD (createSession cancelCastForMove).
|
|
2795
|
+
if (f.castingSpell) {
|
|
2796
|
+
f.castingSpell = null;
|
|
2797
|
+
f.castStartedAt = 0;
|
|
2798
|
+
f.castFinishAt = 0;
|
|
2799
|
+
f.castPushbacks = 0;
|
|
2800
|
+
f.castCost = 0;
|
|
2801
|
+
f.castCostPower = undefined;
|
|
2802
|
+
f.castCritAdd = 0;
|
|
2803
|
+
f.castEffectPct = 0;
|
|
2804
|
+
}
|
|
2805
|
+
if (f.channeling) maybeCancelChannelOnMove(f, channelInterruptCtx(ctx));
|
|
2806
|
+
f.pendingCharge = null;
|
|
2807
|
+
return null;
|
|
2808
|
+
}
|
|
2809
|
+
// A spell/item/null pick means the bomb run-out (etc.) no longer owns motion.
|
|
2810
|
+
f.pendingMechanicMove = undefined;
|
|
2811
|
+
f.pendingObjectClick = undefined;
|
|
2812
|
+
runoutSince.delete(f.side);
|
|
2813
|
+
// A spell/item/null pick is the halt row NOT winning this tick, so the window is over.
|
|
2814
|
+
haltSince.delete(f.side);
|
|
2815
|
+
if (pick != null && typeof pick === 'object' && 'item' in pick) {
|
|
2816
|
+
let targetId: CombatantId = f.side;
|
|
2817
|
+
if (pick.lowestAlly) {
|
|
2818
|
+
// Faction-relative: BG enemy healers triage their own team.
|
|
2819
|
+
const hurt = pick.among
|
|
2820
|
+
? aimedAllyOf(f, pick.among)
|
|
2821
|
+
: (ctx.lowestHpFriendlyOf?.(f) ?? lowestHpFriendly());
|
|
2822
|
+
if (!hurt) return null;
|
|
2823
|
+
targetId = hurt.side;
|
|
2824
|
+
} else if (pick.target != null) {
|
|
2825
|
+
targetId = pick.target;
|
|
2826
|
+
}
|
|
2827
|
+
useItem(pick.item, targetId, f.side);
|
|
2828
|
+
return null;
|
|
2829
|
+
}
|
|
2830
|
+
let spellId = typeof pick === 'number' ? pick : 0;
|
|
2831
|
+
let offTarget: Fighter | null = null;
|
|
2832
|
+
// Written to `friendlyPin` only on the branch that actually returns the spell: a pin the pick
|
|
2833
|
+
// then failed to use would redirect the NEXT friendly cast. applySpell's aimedFriendlyOf reads
|
|
2834
|
+
// it once and deliverSpellImpact clears it, the one-shot the queued Pre-buff order uses.
|
|
2835
|
+
let aimAlly: Fighter | null = null;
|
|
2836
|
+
const offMap = (ctx as { rotationOffTarget?: Map<Side, EnemySide> }).rotationOffTarget;
|
|
2837
|
+
offMap?.delete(f.side);
|
|
2838
|
+
if (pick != null && typeof pick === 'object' && 'spell' in pick) {
|
|
2839
|
+
spellId = pick.spell;
|
|
2840
|
+
armRuleAfter(f, spellId, pick.after);
|
|
2841
|
+
if (pick.offTarget) {
|
|
2842
|
+
offTarget = pickCcTarget(f, foe, kitById(f.spec.spells).get(spellId));
|
|
2843
|
+
// No second mob to control ⇒ skip the rule. Redirecting CC onto the focus is the
|
|
2844
|
+
// exact behaviour that makes hard CC useless (and breaks it on the next swing).
|
|
2845
|
+
if (!offTarget) return null;
|
|
2846
|
+
} else if (pick.creatureEntry != null) {
|
|
2847
|
+
// Mind Control that Worshipper (entry selector) — not a BWL hardcode.
|
|
2848
|
+
offTarget = livingHostilesFor(f).find(
|
|
2849
|
+
(h) => !dead(h) && h.spec.creatureEntry === pick.creatureEntry,
|
|
2850
|
+
) ?? null;
|
|
2851
|
+
if (!offTarget) return null;
|
|
2852
|
+
} else if (pick.priority != null) {
|
|
2853
|
+
offTarget = pickHostileByKillOrder(f, livingHostilesFor(f), pick.priority);
|
|
2854
|
+
if (!offTarget) return null;
|
|
2855
|
+
} else if (pick.role != null) {
|
|
2856
|
+
const role = pick.role;
|
|
2857
|
+
const pool = friendlyPoolOf(f).filter(
|
|
2858
|
+
(u) => !dead(u) && u !== f && u.spec.groupRole === role,
|
|
2859
|
+
);
|
|
2860
|
+
const ally = pool[0];
|
|
2861
|
+
if (!ally) return null;
|
|
2862
|
+
aimAlly = ally;
|
|
2863
|
+
} else if (pick.allyMissingAura != null) {
|
|
2864
|
+
// Same list the row's usability gate read, so the body the gate saw is the body hit.
|
|
2865
|
+
const ally = nearestAllyMissingAura(f, pick.allyMissingAura | 0, pick.among);
|
|
2866
|
+
if (!ally) return null;
|
|
2867
|
+
aimAlly = ally;
|
|
2868
|
+
} else if (pick.lowestAlly) {
|
|
2869
|
+
// `among` is the filter the row's ally gates read, so the cast lands on the body measured.
|
|
2870
|
+
const hurt = pick.among
|
|
2871
|
+
? aimedAllyOf(f, pick.among)
|
|
2872
|
+
: (ctx.lowestHpFriendlyOf?.(f) ?? lowestHpFriendly());
|
|
2873
|
+
if (!hurt) return null;
|
|
2874
|
+
aimAlly = hurt;
|
|
2875
|
+
}
|
|
2876
|
+
if (pick.dest && ctx.unitPositionsMutable) {
|
|
2877
|
+
(ctx as { rotationSpellDest?: Map<Side, { x: number; y: number; z?: number }> })
|
|
2878
|
+
.rotationSpellDest?.set(f.side, pick.dest);
|
|
2879
|
+
}
|
|
2880
|
+
if (pick.ally != null) {
|
|
2881
|
+
const ally = ctx.bySide(pick.ally as Side);
|
|
2882
|
+
if (ally && !dead(ally)) aimAlly = ally;
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
if (spellId > 0) {
|
|
2886
|
+
consumeFollowUp(f, spellId, t);
|
|
2887
|
+
const sp = kitById(f.spec.spells).get(spellId);
|
|
2888
|
+
// Defensive re-gate: the snapshot's usableSpell already vetted the pick, but a
|
|
2889
|
+
// misbehaving provider must not bypass cost/cooldown/target/form rules.
|
|
2890
|
+
if (sp && pureInterruptOf(sp) && !offTarget) {
|
|
2891
|
+
if (!kickableCastPresentOf(f, hostilesOf(f))) return null;
|
|
2892
|
+
const kickTgt = pickInterruptTarget(f, foe, sp);
|
|
2893
|
+
if (!kickTgt) return null;
|
|
2894
|
+
if (kickTgt !== foe) offTarget = kickTgt;
|
|
2895
|
+
}
|
|
2896
|
+
// The dispel twin of the kick re-gate: aim at the body carrying the category, so canCastSpell
|
|
2897
|
+
// prices range and sight against the unit the effect handler will actually strip.
|
|
2898
|
+
if (sp && !offTarget && isDispelSpell(sp)) {
|
|
2899
|
+
const de = sp.effects.find((e) => e.kind === 'dispel');
|
|
2900
|
+
if (de && de.kind === 'dispel') {
|
|
2901
|
+
const res = resolveDispelTarget(f, foe, de.dispelType) as
|
|
2902
|
+
{ unit: Fighter; friendlyDispel: boolean } | null;
|
|
2903
|
+
if (res && !res.friendlyDispel && res.unit !== foe && isEnemySide(res.unit.side)) {
|
|
2904
|
+
offTarget = res.unit;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
const aimed = offTarget ?? foe;
|
|
2909
|
+
if (sp && !isTriggeredCompanion(f, sp) && (!sp.onNextSwing || !f.pendingSwing) && canCastSpell(f, aimed, sp)) {
|
|
2910
|
+
if (offTarget && offMap) offMap.set(f.side, offTarget.side as EnemySide);
|
|
2911
|
+
if (aimAlly) f.friendlyPin = aimAlly.side;
|
|
2912
|
+
return sp;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return null;
|
|
2916
|
+
};
|
|
2917
|
+
|
|
2918
|
+
// The player, on the ticks the engine drives them (instant resolve / chained phases /
|
|
2919
|
+
// headless): their own rotation, not a second hidden brain.
|
|
2920
|
+
if (f.side === 'player' && cfg.playerActionProvider) {
|
|
2921
|
+
return runRotationProvider(cfg.playerActionProvider) ?? null;
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
// The enemy rotation provider, for BG playerbots: a null pick is white swings, never the class
|
|
2925
|
+
// heuristics for a seat that owns a Rotation. Null slots, like pets, fall through.
|
|
2926
|
+
if (isEnemySide(f.side)) {
|
|
2927
|
+
const provider = ctx.unitActionProvider.get(f.side);
|
|
2928
|
+
if (provider) return runRotationProvider(provider) ?? null;
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// The ally role branch — healer triage and the tank threat kit — then FALL THROUGH to the class
|
|
2932
|
+
// heuristics, which key off spec fields rather than side and so drive companions unchanged.
|
|
2933
|
+
if (isAllySide(f.side)) {
|
|
2934
|
+
// No provider for this slot (companion pets) → S0 role AI below.
|
|
2935
|
+
const provider = ctx.unitActionProvider.get(f.side);
|
|
2936
|
+
if (provider) return runRotationProvider(provider) ?? null;
|
|
2937
|
+
const role = f.spec.groupRole;
|
|
2938
|
+
const isFriendlyHeal = (s: SpellDef): boolean =>
|
|
2939
|
+
roleOf(s) !== 'pet_heal' && s.effects.some((e) => e.kind === 'heal');
|
|
2940
|
+
if (role === 'healer') {
|
|
2941
|
+
// lowestHpFriendlyOf, never lowestHpFriendly: the same body applyHealEffect lands on, or the
|
|
2942
|
+
// sight gate below prices a different one. Cite: applyHealEffect.ts:104 triage().
|
|
2943
|
+
const hurt = lowestHpFriendlyOf(f);
|
|
2944
|
+
if (hurt && hurt.hp / Math.max(1, hurt.spec.maxHealth) < ALLY_HEAL_THRESHOLD
|
|
2945
|
+
&& friendlyCastReaches(f, hurt)) {
|
|
2946
|
+
const heal = f.spec.spells.find(
|
|
2947
|
+
(s) => isFriendlyHeal(s) && !isTriggeredCompanion(f, s) && !onCooldown(f, s) && canAfford(f, s),
|
|
2948
|
+
);
|
|
2949
|
+
if (heal) return heal;
|
|
2950
|
+
}
|
|
2951
|
+
} else if (role === 'tank' && isEnemySide(foe.side) && !dead(foe)) {
|
|
2952
|
+
// Taunt back when the target is beating on someone else (EffectTaunt via applySpell).
|
|
2953
|
+
const victim = threatOf(foe)?.getCurrentVictimId();
|
|
2954
|
+
if (victim != null && victim !== f.side) {
|
|
2955
|
+
const taunt = f.spec.spells.find(
|
|
2956
|
+
(s) => s.effects.some((e) => e.kind === 'taunt' || e.kind === 'mod_taunt')
|
|
2957
|
+
&& !onCooldown(f, s) && canAfford(f, s),
|
|
2958
|
+
);
|
|
2959
|
+
if (taunt) return taunt;
|
|
2960
|
+
}
|
|
2961
|
+
// Prefer the threat kit (spell_threat flat/multiplier rows, SPELL_EFFECT_THREAT).
|
|
2962
|
+
const threatKit = f.spec.spells.find(
|
|
2963
|
+
(s) => !isTriggeredCompanion(f, s)
|
|
2964
|
+
&& !s.effects.some((e) => e.kind === 'taunt' || e.kind === 'mod_taunt')
|
|
2965
|
+
&& (spellThreatFromDef(s).flat > 0
|
|
2966
|
+
|| (s.threatMultiplier ?? 1) > 1
|
|
2967
|
+
|| s.effects.some((e) => e.kind === 'threat' && e.amount > 0))
|
|
2968
|
+
&& (!s.onNextSwing || !f.pendingSwing)
|
|
2969
|
+
&& canCastSpell(f, foe, s),
|
|
2970
|
+
);
|
|
2971
|
+
if (threatKit) return threatKit;
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// Owner mends a hurt pet first (so the Auto-resolve AI keeps it alive, like the rotation does).
|
|
2976
|
+
if (f.side === 'player' && pet && !dead(pet) && pet.hp / pet.spec.maxHealth < TUNING.mendPetThreshold) {
|
|
2977
|
+
const mend = find(f, 'pet_heal');
|
|
2978
|
+
if (mend && !pet.buffActive.has(mend.id) && canCastSpell(f, pet, mend)) return mend;
|
|
2979
|
+
}
|
|
2980
|
+
// The player as party healer: triage the lowest-HP friendly, the same gate ally healers use.
|
|
2981
|
+
// Without it, signing as healer only self-healed and never topped the tank.
|
|
2982
|
+
if (
|
|
2983
|
+
f.side === 'player'
|
|
2984
|
+
&& f.spec.groupRole === 'healer'
|
|
2985
|
+
&& (f.spec.powerType === 'mana' || f.spec.powerType === 'focus')
|
|
2986
|
+
) {
|
|
2987
|
+
const hurt = lowestHpFriendlyOf(f);
|
|
2988
|
+
if (hurt && hurt.hp / Math.max(1, hurt.spec.maxHealth) < ALLY_HEAL_THRESHOLD
|
|
2989
|
+
&& friendlyCastReaches(f, hurt)) {
|
|
2990
|
+
const heal = f.spec.spells.find(
|
|
2991
|
+
(s) =>
|
|
2992
|
+
roleOf(s) !== 'pet_heal'
|
|
2993
|
+
&& spellHeals(s)
|
|
2994
|
+
&& !isTriggeredCompanion(f, s)
|
|
2995
|
+
&& !onCooldown(f, s)
|
|
2996
|
+
&& canAfford(f, s)
|
|
2997
|
+
&& !hurt.buffActive.has(s.id),
|
|
2998
|
+
);
|
|
2999
|
+
if (heal) return heal;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
// A player self-heal when hurt, for mana casters only — the rage and energy branches own their own
|
|
3003
|
+
// kits. Skipped while already acting as group healer, since triage covers self and party.
|
|
3004
|
+
if (
|
|
3005
|
+
f.side === 'player'
|
|
3006
|
+
&& f.spec.groupRole !== 'healer'
|
|
3007
|
+
&& (f.spec.powerType === 'mana' || f.spec.powerType === 'focus')
|
|
3008
|
+
&& f.hp / Math.max(1, f.spec.maxHealth) < ALLY_HEAL_THRESHOLD
|
|
3009
|
+
) {
|
|
3010
|
+
const heal = f.spec.spells.find(
|
|
3011
|
+
(s) =>
|
|
3012
|
+
roleOf(s) !== 'pet_heal'
|
|
3013
|
+
&& spellHeals(s)
|
|
3014
|
+
&& !isTriggeredCompanion(f, s)
|
|
3015
|
+
&& !onCooldown(f, s)
|
|
3016
|
+
&& canAfford(f, s)
|
|
3017
|
+
// Skip while a HoT from this spell is already ticking (refresh starves ticks).
|
|
3018
|
+
&& !f.buffActive.has(s.id),
|
|
3019
|
+
);
|
|
3020
|
+
if (heal) return heal;
|
|
3021
|
+
}
|
|
3022
|
+
// Multi-target strike preference for the party's auto-resolve: two or more hostiles in the melee
|
|
3023
|
+
// band, not fight-wide. Party-side only, and byte-identical with one living enemy.
|
|
3024
|
+
const fanStrike = (): SpellDef | undefined => {
|
|
3025
|
+
if (isEnemySide(f.side)) return undefined;
|
|
3026
|
+
if (!kitHasFanStrike(f.spec.spells)) return undefined;
|
|
3027
|
+
let near = 0;
|
|
3028
|
+
if (ctx.unitPositionsMutable) {
|
|
3029
|
+
for (const e of livingEnemies()) {
|
|
3030
|
+
if (dead(e) || ccProtected(e)) continue;
|
|
3031
|
+
const d = distanceBetween(f, e);
|
|
3032
|
+
if (d != null && d <= 8) near++;
|
|
3033
|
+
}
|
|
3034
|
+
} else {
|
|
3035
|
+
near = blastEnemyCount();
|
|
3036
|
+
}
|
|
3037
|
+
if (near < 2) return undefined;
|
|
3038
|
+
return f.spec.spells.find(
|
|
3039
|
+
(s) =>
|
|
3040
|
+
roleOf(s) === 'strike'
|
|
3041
|
+
&& !isTriggeredCompanion(f, s)
|
|
3042
|
+
&& (s.aoe || nextSwingFanCap(s) != null),
|
|
3043
|
+
);
|
|
3044
|
+
};
|
|
3045
|
+
if (f.spec.powerType === 'rage') {
|
|
3046
|
+
const charge = find(f, 'charge');
|
|
3047
|
+
// Charge: OOC only (notInCombatOnly + combat timer). Re-arms after leave combat.
|
|
3048
|
+
// Cite: Spell.cpp IsNonCombatSpell; canCastSpell stealthPosError / chargeUsed.
|
|
3049
|
+
if (charge && canCastSpell(f, foe, charge)) return charge;
|
|
3050
|
+
// Find the first CASTABLE buff: mid kits list Shield Wall and Recklessness before Battle Shout,
|
|
3051
|
+
// and those cost nothing but need a stance the AI never enters, so the branch stuck on them.
|
|
3052
|
+
const shout = f.spec.spells.find(
|
|
3053
|
+
(s) =>
|
|
3054
|
+
roleOf(s) === 'buff'
|
|
3055
|
+
&& !isTriggeredCompanion(f, s)
|
|
3056
|
+
&& !f.buffActive.has(s.id)
|
|
3057
|
+
&& canCastSpell(f, foe, s),
|
|
3058
|
+
);
|
|
3059
|
+
if (shout) return shout;
|
|
3060
|
+
// Keep a snare on. The heuristic path only — rotations already keep these — but without it BG
|
|
3061
|
+
// bots on rage never cast a slow at all: the engine applied them and nobody pressed them.
|
|
3062
|
+
if (controlActive(foe, 'snare') !== true) {
|
|
3063
|
+
const snareImmune = targetImmunityIdsFor(foe, f).auras;
|
|
3064
|
+
const dist = distanceBetween(f, foe);
|
|
3065
|
+
const geo = !!ctx.unitPositionsMutable;
|
|
3066
|
+
const foeInMelee = inMeleeBand(dist ?? null, geo);
|
|
3067
|
+
const snare = f.spec.spells.find((s) => {
|
|
3068
|
+
if (isTriggeredCompanion(f, s) || snareImmune?.has(s.id)) return false;
|
|
3069
|
+
const speedSnare = s.effects.some((e) =>
|
|
3070
|
+
(e.kind === 'speed' && (e.hostile === true || (e.hostile !== false && (e.pct ?? 0) < 0)))
|
|
3071
|
+
|| (e.kind === 'cc' && (e as { mechanic?: string }).mechanic === 'snare'),
|
|
3072
|
+
);
|
|
3073
|
+
// Hamstring's snare is only on the APPLY_AURA slot (effects[] is the 5-damage hit).
|
|
3074
|
+
const auraSnare = mechanicAuraSlotsOf(s).some((r) => (r.mechanic | 0) === 11);
|
|
3075
|
+
if (!speedSnare && !auraSnare) return false;
|
|
3076
|
+
if (isPiercingHowlSpell(s) && piercingHowlRefused({
|
|
3077
|
+
inMelee: foeInMelee,
|
|
3078
|
+
snared: false,
|
|
3079
|
+
snareImmune: isImmuneToMechanic(foe.immunity, 'snare', {
|
|
3080
|
+
mechanicImmuneMask: foe.spec.mechanicImmuneMask,
|
|
3081
|
+
}),
|
|
3082
|
+
distanceYd: dist ?? null,
|
|
3083
|
+
radiusYd: s.radiusYd,
|
|
3084
|
+
})) return false;
|
|
3085
|
+
return canCastSpell(f, foe, s);
|
|
3086
|
+
});
|
|
3087
|
+
if (snare) return snare;
|
|
3088
|
+
}
|
|
3089
|
+
const rend = f.spec.spells.find(
|
|
3090
|
+
(s) =>
|
|
3091
|
+
roleOf(s) === 'dot'
|
|
3092
|
+
&& !isTriggeredCompanion(f, s)
|
|
3093
|
+
&& !ctx.effectsOn(foe.side).some((e) =>
|
|
3094
|
+
e.kind === 'dot' && e.source === f.side && e.spellId === s.id)
|
|
3095
|
+
&& canCastSpell(f, foe, s),
|
|
3096
|
+
);
|
|
3097
|
+
if (rend) return rend;
|
|
3098
|
+
// Prefer fan strike (Cleave/WW) when multi-target, else first castable single strike.
|
|
3099
|
+
// canCastSpell covers stance/form, cost, CD; onNextSwing still needs free pendingSwing.
|
|
3100
|
+
const fan = fanStrike();
|
|
3101
|
+
if (fan && (!fan.onNextSwing || !f.pendingSwing) && canCastSpell(f, foe, fan)) return fan;
|
|
3102
|
+
const strike = f.spec.spells.find(
|
|
3103
|
+
(s) =>
|
|
3104
|
+
roleOf(s) === 'strike'
|
|
3105
|
+
&& !isTriggeredCompanion(f, s)
|
|
3106
|
+
&& (!s.onNextSwing || !f.pendingSwing)
|
|
3107
|
+
&& canCastSpell(f, foe, s),
|
|
3108
|
+
);
|
|
3109
|
+
if (strike) return strike;
|
|
3110
|
+
return null;
|
|
3111
|
+
}
|
|
3112
|
+
// Energy (rogue): keep Slice and Dice up, dump finishers at 4–5 CP, otherwise builders.
|
|
3113
|
+
// canCastSpell already drops Ruthlessness-style free talent procs and weapon-gated Backstab.
|
|
3114
|
+
if (f.spec.powerType === 'energy') {
|
|
3115
|
+
const snd = f.spec.spells.find((s) => s.requiresComboPoints && s.effects.some((e) => e.kind === 'buff' && e.mod === 'haste'));
|
|
3116
|
+
if (snd && !f.buffActive.has(snd.id) && f.comboPoints > 0 && canCastSpell(f, foe, snd)) return snd;
|
|
3117
|
+
const evis = f.spec.spells.find((s) => s.requiresComboPoints && s.effects.some((e) => e.kind === 'direct_damage'));
|
|
3118
|
+
if (evis && f.comboPoints >= 4 && canCastSpell(f, foe, evis)) return evis;
|
|
3119
|
+
const builder = f.spec.spells.find((s) => !!s.comboPointsGenerated && !s.requiresComboPoints && canCastSpell(f, foe, s));
|
|
3120
|
+
if (builder) return builder;
|
|
3121
|
+
const strike = fanStrike() ?? find(f, 'strike');
|
|
3122
|
+
if (strike && canCastSpell(f, foe, strike)) return strike;
|
|
3123
|
+
return null;
|
|
3124
|
+
}
|
|
3125
|
+
// Pet upkeep before it starts nuking: self-heal when hurt, keep its self-buffs up, and pulse
|
|
3126
|
+
// positive threat. Heals are excluded from the buff scan so a full-health pet does not re-cast one.
|
|
3127
|
+
if (f.side === 'pet') {
|
|
3128
|
+
const isHeal = (s: SpellDef): boolean =>
|
|
3129
|
+
s.effects.some((e) => e.kind === 'heal' || e.kind === 'restore_health');
|
|
3130
|
+
if (f.hp / f.spec.maxHealth < TUNING.mendPetThreshold) {
|
|
3131
|
+
const heal = f.spec.spells.find(
|
|
3132
|
+
(s) =>
|
|
3133
|
+
isHeal(s)
|
|
3134
|
+
&& !isTriggeredCompanion(f, s)
|
|
3135
|
+
&& canAfford(f, s)
|
|
3136
|
+
&& !onCooldown(f, s)
|
|
3137
|
+
// Direct heals never set buffActive; HoTs do — skip while the aura is live.
|
|
3138
|
+
&& !f.buffActive.has(s.id),
|
|
3139
|
+
);
|
|
3140
|
+
if (heal) return heal;
|
|
3141
|
+
}
|
|
3142
|
+
// PetAI CanAutoCast → CheckRange: Charge fires in the 8–25 band, not from melee.
|
|
3143
|
+
// Cite: Spell.cpp:6126 CanAutoCast; :5971 CheckPetCast → CheckCast(true).
|
|
3144
|
+
const charge = f.spec.spells.find((s) => isChargeRole(s) && canCastSpell(f, foe, s));
|
|
3145
|
+
if (charge) return charge;
|
|
3146
|
+
// A buff the DBC aims at a party member is checked on the ALLY, not on the pet: the imp's
|
|
3147
|
+
// Fire Shield sits on its master, so a self buffActive gate re-cast it every GCD.
|
|
3148
|
+
// Cite: PetAI.cpp:292-305 walk m_AllySet until CanAutoCast; Spell.cpp:2325-2330.
|
|
3149
|
+
const buff = f.spec.spells.find(
|
|
3150
|
+
(s) =>
|
|
3151
|
+
roleOf(s) === 'buff'
|
|
3152
|
+
&& !isHeal(s)
|
|
3153
|
+
&& !isTriggeredCompanion(f, s)
|
|
3154
|
+
&& (auraHolderExcludesCaster(s)
|
|
3155
|
+
? nearestAllyMissingAura(f, s.id) != null
|
|
3156
|
+
: !f.buffActive.has(s.id))
|
|
3157
|
+
&& canAfford(f, s)
|
|
3158
|
+
&& !onCooldown(f, s),
|
|
3159
|
+
);
|
|
3160
|
+
if (buff) {
|
|
3161
|
+
if (auraHolderExcludesCaster(buff)) {
|
|
3162
|
+
const ally = nearestAllyMissingAura(f, buff.id);
|
|
3163
|
+
if (ally) f.friendlyPin = ally.side;
|
|
3164
|
+
}
|
|
3165
|
+
return buff;
|
|
3166
|
+
}
|
|
3167
|
+
// Keep its damage-over-time up — the caster branch below only nukes, so without this a pet would
|
|
3168
|
+
// never apply one. Refresh only when this pet has no dot of its own already ticking.
|
|
3169
|
+
const dot = find(f, 'dot');
|
|
3170
|
+
if (dot && canCastSpell(f, foe, dot) && !ctx.effectsOn(foe.side).some((e) => e.kind === 'dot' && e.source === f.side)) return dot;
|
|
3171
|
+
}
|
|
3172
|
+
// Paladin: keep a seal up, since it procs on every swing, then spend Judgement on cooldown to
|
|
3173
|
+
// unleash it. It fires only for a fighter that knows seals, so every other pick order is unchanged.
|
|
3174
|
+
const seals = f.spec.spells.filter((s) => roleOf(s) === 'seal');
|
|
3175
|
+
if (seals.length) {
|
|
3176
|
+
if (!f.activeSeal) {
|
|
3177
|
+
const best = seals.find((s) => s.effects.some((e) => e.kind === 'seal' && (e.seal === 'command' || e.seal === 'righteousness'))) ?? seals[0];
|
|
3178
|
+
if (best && canAfford(f, best) && !onCooldown(f, best)) return best;
|
|
3179
|
+
} else {
|
|
3180
|
+
const judge = find(f, 'judgement');
|
|
3181
|
+
if (judge && canCastSpell(f, foe, judge)) return judge;
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
// Control: hard CC under the existing gate, and interrupt-type control only while the foe is mid
|
|
3185
|
+
// interruptible cast. Never pick CC that cannot land — a cost-free fear would re-select every GCD.
|
|
3186
|
+
|
|
3187
|
+
const control = f.spec.spells.find((s) => {
|
|
3188
|
+
if (roleOf(s) !== 'control' || isTriggeredCompanion(f, s)) return false;
|
|
3189
|
+
if (isNonCastableTalentProc(s)) return false;
|
|
3190
|
+
if (onCooldown(f, s) || !canAfford(f, s)) return false;
|
|
3191
|
+
// Interrupts only mid-cast (Earth Shock / Kick / Spell Lock), on whoever is actually
|
|
3192
|
+
// casting — foeOf can be a melee dummy while the healer add holds the bar.
|
|
3193
|
+
if (s.effects.some((e) => e.kind === 'interrupt_cast')) {
|
|
3194
|
+
return kickableCastPresentOf(f, hostilesOf(f)) && pickInterruptTarget(f, foe, s) != null;
|
|
3195
|
+
}
|
|
3196
|
+
// Pure dispel → dispel branch (Purge was starving resto shaman nukes — KI-600).
|
|
3197
|
+
const hasHardCc = s.effects.some((e) => e.kind === 'cc' || e.kind === 'root');
|
|
3198
|
+
if (!hasHardCc) return false;
|
|
3199
|
+
// ONE brain decides player-faction CC, and it is the rotation the player can see and edit. This
|
|
3200
|
+
// heuristic was a second one, aimed at the party's kill focus. Enemies keep it: their list IS it.
|
|
3201
|
+
if (!isEnemySide(f.side)) return false;
|
|
3202
|
+
if (isControlledHard(foe) || foe.rootedUntil > t) return false;
|
|
3203
|
+
// Hard CC / root must be able to land — otherwise fall through to DoT/nuke (KI-148).
|
|
3204
|
+
return hardControlCanLand(f, foe, s);
|
|
3205
|
+
});
|
|
3206
|
+
if (control) {
|
|
3207
|
+
if (interruptCastOf(control)) {
|
|
3208
|
+
const kickTgt = pickInterruptTarget(f, foe, control);
|
|
3209
|
+
if (kickTgt && kickTgt !== foe) {
|
|
3210
|
+
(ctx as { rotationOffTarget?: Map<Side, EnemySide> }).rotationOffTarget
|
|
3211
|
+
?.set(f.side, kickTgt.side as EnemySide);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
return control;
|
|
3215
|
+
}
|
|
3216
|
+
// Dispel, auto-placed like interrupts: a companion uses its cure whenever a valid target exists.
|
|
3217
|
+
// The same selector the effect handler uses, so "would it do anything" equals "does it fire".
|
|
3218
|
+
const dispelSpell = f.spec.spells.find((s) => {
|
|
3219
|
+
if (isTriggeredCompanion(f, s)) return false;
|
|
3220
|
+
if (onCooldown(f, s) || !canAfford(f, s)) return false;
|
|
3221
|
+
const de = s.effects.find((e) => e.kind === 'dispel');
|
|
3222
|
+
if (!de || de.kind !== 'dispel') return false;
|
|
3223
|
+
const res = resolveDispelTarget(f, foe, de.dispelType);
|
|
3224
|
+
if (!res) return false;
|
|
3225
|
+
if (dispelCatClaimedByOther(res.unit.side, f.side, de.dispelType)) return false;
|
|
3226
|
+
return true;
|
|
3227
|
+
});
|
|
3228
|
+
if (dispelSpell) return dispelSpell;
|
|
3229
|
+
// The pet tank pulse, after the interrupt and before the nuke tail. Positive threat only, so a
|
|
3230
|
+
// negative-amount kiss is not treated as an offensive cast. Pet-scoped, so other AI is unchanged.
|
|
3231
|
+
if (f.side === 'pet') {
|
|
3232
|
+
const torment = f.spec.spells.find(
|
|
3233
|
+
(s) =>
|
|
3234
|
+
!isTriggeredCompanion(f, s)
|
|
3235
|
+
&& s.effects.some((e) => e.kind === 'threat' && e.amount > 0)
|
|
3236
|
+
&& !f.buffActive.has(s.id)
|
|
3237
|
+
&& canCastSpell(f, foe, s),
|
|
3238
|
+
);
|
|
3239
|
+
if (torment) return torment;
|
|
3240
|
+
}
|
|
3241
|
+
// DoT upkeep for mana and focus casters, per spell rather than any-dot, so two curses each keep
|
|
3242
|
+
// themselves. A hybrid applies once. Range and stand-down are checked, never cost and cooldown alone.
|
|
3243
|
+
const hasDotPayload = (s: SpellDef): boolean => memoSpellBool(dotPayloadCache, (sp) => sp.effects.some((e) => e.kind === 'dot'), s);
|
|
3244
|
+
const ownDotLive = (s: SpellDef): boolean =>
|
|
3245
|
+
ctx.effectsOn(foe.side).some(
|
|
3246
|
+
(e) => e.kind === 'dot' && e.spellId === s.id && e.source === f.side,
|
|
3247
|
+
);
|
|
3248
|
+
if (f.side !== 'pet') {
|
|
3249
|
+
// Pure DoTs first (Corruption / CoA), then hybrid openers (Immolate) — multi-DoT kit.
|
|
3250
|
+
// canCastSpell already skips school-immune first picks (KI-695 Flame Shock vs nature mask).
|
|
3251
|
+
const missingDotOf = (want: 'pure' | 'hybrid'): SpellDef | undefined =>
|
|
3252
|
+
f.spec.spells.find((s) => {
|
|
3253
|
+
if (isTriggeredCompanion(f, s) || isNonCastableTalentProc(s)) return false;
|
|
3254
|
+
const r = roleOf(s);
|
|
3255
|
+
const pureDot = r === 'dot';
|
|
3256
|
+
const hybrid = hasDotPayload(s) && (r === 'nuke' || r === 'instant_nuke');
|
|
3257
|
+
if (want === 'pure' ? !pureDot : !hybrid) return false;
|
|
3258
|
+
if (!canCastSpell(f, foe, s)) return false;
|
|
3259
|
+
return !ownDotLive(s);
|
|
3260
|
+
});
|
|
3261
|
+
const missingDot = missingDotOf('pure') ?? missingDotOf('hybrid');
|
|
3262
|
+
if (missingDot) return missingDot;
|
|
3263
|
+
}
|
|
3264
|
+
// Instant / cast-time fillers: skip hybrids already ticking (Immolate spam → Shadow Bolt, KI-633).
|
|
3265
|
+
const pickFiller = (role: 'instant_nuke' | 'nuke'): SpellDef | undefined => {
|
|
3266
|
+
const pure = f.spec.spells.find(
|
|
3267
|
+
(s) =>
|
|
3268
|
+
roleOf(s) === role
|
|
3269
|
+
&& !isTriggeredCompanion(f, s)
|
|
3270
|
+
&& !isNonCastableTalentProc(s)
|
|
3271
|
+
&& !hasDotPayload(s)
|
|
3272
|
+
&& canCastSpell(f, foe, s),
|
|
3273
|
+
);
|
|
3274
|
+
if (pure) return pure;
|
|
3275
|
+
return f.spec.spells.find(
|
|
3276
|
+
(s) =>
|
|
3277
|
+
roleOf(s) === role
|
|
3278
|
+
&& !isTriggeredCompanion(f, s)
|
|
3279
|
+
&& !isNonCastableTalentProc(s)
|
|
3280
|
+
&& canCastSpell(f, foe, s)
|
|
3281
|
+
&& !(hasDotPayload(s) && ownDotLive(s)),
|
|
3282
|
+
);
|
|
3283
|
+
};
|
|
3284
|
+
const instant = pickFiller('instant_nuke');
|
|
3285
|
+
if (instant) return instant;
|
|
3286
|
+
const nuke = pickFiller('nuke');
|
|
3287
|
+
if (nuke) return nuke;
|
|
3288
|
+
// Free Shoot/Auto Shot when not already ranged (casters OOM; hunters after melee weave). Cite: KI-745/870.
|
|
3289
|
+
const shoot = f.spec.spells.find(
|
|
3290
|
+
(s) => isRangedAutoRepeatToggle(s) && !isTriggeredCompanion(f, s) && canCastSpell(f, foe, s),
|
|
3291
|
+
);
|
|
3292
|
+
if (shoot && f.spec.ranged && f.mode !== 'ranged') {
|
|
3293
|
+
return shoot;
|
|
3294
|
+
}
|
|
3295
|
+
return null;
|
|
3296
|
+
};
|
|
3297
|
+
|
|
3298
|
+
// The off-GCD next-swing arm. Classic queues these beside the GCD, and a single provider pick made
|
|
3299
|
+
// pack Cleave starve whenever a GCD ability won. The rage reserve holds only a ready signature dump.
|
|
3300
|
+
const isCooldownGatedSpender = (s: SpellDef): boolean => {
|
|
3301
|
+
if (s.onNextSwing || (s.cost ?? 0) < 20 || ((s.cooldownMs ?? 0) | 0) <= 0) return false;
|
|
3302
|
+
const n = (s.name ?? '').toLowerCase();
|
|
3303
|
+
if (n === 'mortal strike' || n === 'bloodthirst' || n === 'whirlwind' || n === 'shield slam') return true;
|
|
3304
|
+
return roleOf(s) === 'strike' && !s.aoe && (s.cost ?? 0) >= 25;
|
|
3305
|
+
};
|
|
3306
|
+
/** canCastSpell without the cost gate — used to reserve rage while still short. */
|
|
3307
|
+
const canCastIgnoringCost = (f: Fighter, foe: Fighter, spell: SpellDef): boolean => {
|
|
3308
|
+
if (onCooldown(f, spell)) return false;
|
|
3309
|
+
if (schoolLocked(f, spell)) return false;
|
|
3310
|
+
if (isHostileSpellDef(spell) && !spellTargetTypeAllowed(spell, foe.creatureType)) return false;
|
|
3311
|
+
if (focusImmunityBlocksSpell(
|
|
3312
|
+
spell,
|
|
3313
|
+
foe,
|
|
3314
|
+
selfCenteredOf(spell) ? blastEnemyCount() : 1,
|
|
3315
|
+
)) return false;
|
|
3316
|
+
if (requiresLivingPetTarget(spell) && !livingPetOf(f)) return false;
|
|
3317
|
+
if (spell.requiresComboPoints && comboOn(f, foe) <= 0) return false;
|
|
3318
|
+
const gate = resolveFormCast(spell, f.form);
|
|
3319
|
+
if (!gate.ok) return false;
|
|
3320
|
+
if (stealthPosError(f, foe, spell)) return false;
|
|
3321
|
+
if (isChargeRole(spell)) {
|
|
3322
|
+
if (f.chargeUsed && spell.notInCombatOnly) return false;
|
|
3323
|
+
if (!chargeCastOk(f, foe, spell)) return false;
|
|
3324
|
+
} else if ((spellNeedsRangeGate(spell) || foeDirectedOpaqueCast(spell))
|
|
3325
|
+
&& (!planarRangeHonestYd(ctx.posOf?.(f.side), ctx.posOf?.(foe.side))
|
|
3326
|
+
|| !isInSpellRange(
|
|
3327
|
+
distanceBetween(f, foe), spellGateRangeMaxYd(spell), spellGateRangeMinYd(spell),
|
|
3328
|
+
ctx.isMoving(f.side) && ctx.isMoving(foe.side),
|
|
3329
|
+
f.spec.combatReachYd, foe.spec.combatReachYd,
|
|
3330
|
+
))) {
|
|
3331
|
+
return false;
|
|
3332
|
+
} else if (beyondAcquireRange(f, foe, spell)) {
|
|
3333
|
+
return false;
|
|
3334
|
+
}
|
|
3335
|
+
if (spell.condition || (spell.conditionId != null && spell.conditionId !== 0)) {
|
|
3336
|
+
if (!passesConditionGate(spell, conditionSnapshotFor(f), cfg.conditionTable).ok) return false;
|
|
3337
|
+
}
|
|
3338
|
+
if (reactiveCastGate(f, foe, spell, ctx.t) != null) return false;
|
|
3339
|
+
if (targetCastGate(f, foe, spell, ctx.t) != null) return false;
|
|
3340
|
+
if (isPlayerCharacterSide(f.side) && playerMissingRequiredEquippedItem(spell, f.spec)) return false;
|
|
3341
|
+
return true;
|
|
3342
|
+
};
|
|
3343
|
+
// The only spells this can return are the five named arms, so a kit without one has nothing to find —
|
|
3344
|
+
// yet every fighter paid the reserve scan, 17.2% of an 80-unit profile. Pre-split per kit array.
|
|
3345
|
+
const SWING_ARM_MULTI = new Set(['cleave', 'swipe']);
|
|
3346
|
+
const SWING_ARM_SINGLE = new Set(['heroic strike', 'maul', 'raptor strike']);
|
|
3347
|
+
type SwingArms = { arms: SpellDef[]; spenders: SpellDef[] };
|
|
3348
|
+
const swingArmCache = new WeakMap<readonly SpellDef[], SwingArms>();
|
|
3349
|
+
const swingArmsOf = (spells: readonly SpellDef[]): SwingArms => {
|
|
3350
|
+
let hit = swingArmCache.get(spells);
|
|
3351
|
+
if (!hit) {
|
|
3352
|
+
const arms: SpellDef[] = [];
|
|
3353
|
+
for (const s of spells) {
|
|
3354
|
+
if (!s.onNextSwing) continue;
|
|
3355
|
+
const n = (s.name ?? '').toLowerCase();
|
|
3356
|
+
if (SWING_ARM_MULTI.has(n) || SWING_ARM_SINGLE.has(n)) arms.push(s);
|
|
3357
|
+
}
|
|
3358
|
+
hit = { arms, spenders: arms.length ? spells.filter(isCooldownGatedSpender) : [] };
|
|
3359
|
+
swingArmCache.set(spells, hit);
|
|
3360
|
+
}
|
|
3361
|
+
return hit;
|
|
3362
|
+
};
|
|
3363
|
+
|
|
3364
|
+
const pickNextSwingArm = (f: Fighter, foe: Fighter): SpellDef | null => {
|
|
3365
|
+
if (f.pendingSwing || isEnemySide(f.side) || dead(f)) return null;
|
|
3366
|
+
// The self-sentinel means no living hostiles. Every arm here is a hostile ability the cast path
|
|
3367
|
+
// refuses at that sentinel, so the whole search could only pick something then dropped.
|
|
3368
|
+
if (foe === f || dead(foe)) return null;
|
|
3369
|
+
const { arms, spenders } = swingArmsOf(f.spec.spells);
|
|
3370
|
+
if (arms.length === 0) return null;
|
|
3371
|
+
let hasMultiArm = false;
|
|
3372
|
+
for (const a of arms) {
|
|
3373
|
+
if (SWING_ARM_MULTI.has((a.name ?? '').toLowerCase())) { hasMultiArm = true; break; }
|
|
3374
|
+
}
|
|
3375
|
+
// The loop below refuses anything the unit cannot pay for INCLUDING the reserve, which is never
|
|
3376
|
+
// negative — so failing the CHEAPEST arm first skips the distance loop and the spender walk.
|
|
3377
|
+
let cheapestArm = Number.POSITIVE_INFINITY;
|
|
3378
|
+
for (const a of arms) {
|
|
3379
|
+
const c = a.cost ?? 0;
|
|
3380
|
+
if (c < cheapestArm) cheapestArm = c;
|
|
3381
|
+
}
|
|
3382
|
+
if (f.power < cheapestArm) return null;
|
|
3383
|
+
// Cleave arm only when 2+ hostiles are actually in the fan band (CC'd ones do not count —
|
|
3384
|
+
// a Cleave that only frees the sheep is not a pack).
|
|
3385
|
+
let multi = false;
|
|
3386
|
+
if (hasMultiArm) {
|
|
3387
|
+
if (ctx.unitPositionsMutable) {
|
|
3388
|
+
let near = 0;
|
|
3389
|
+
for (const e of livingEnemies()) {
|
|
3390
|
+
if (dead(e) || ccProtected(e)) continue;
|
|
3391
|
+
const d = distanceBetween(f, e);
|
|
3392
|
+
if (d != null && d <= 8) near++;
|
|
3393
|
+
}
|
|
3394
|
+
multi = near >= 2;
|
|
3395
|
+
} else {
|
|
3396
|
+
multi = blastEnemyCount() >= 2;
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
3399
|
+
let reserve = 0;
|
|
3400
|
+
for (const s of spenders) {
|
|
3401
|
+
if (isTriggeredCompanion(f, s)) continue;
|
|
3402
|
+
if (!canCastIgnoringCost(f, foe, s)) continue;
|
|
3403
|
+
const c = s.cost ?? 0;
|
|
3404
|
+
if (c > reserve) reserve = c;
|
|
3405
|
+
}
|
|
3406
|
+
let best: SpellDef | null = null;
|
|
3407
|
+
let bestKey = -1;
|
|
3408
|
+
for (const s of arms) {
|
|
3409
|
+
if (isTriggeredCompanion(f, s)) continue;
|
|
3410
|
+
const n = (s.name ?? '').toLowerCase();
|
|
3411
|
+
if (multi) {
|
|
3412
|
+
if (hasMultiArm ? !SWING_ARM_MULTI.has(n) : !SWING_ARM_SINGLE.has(n)) continue;
|
|
3413
|
+
} else if (!SWING_ARM_SINGLE.has(n)) {
|
|
3414
|
+
continue;
|
|
3415
|
+
}
|
|
3416
|
+
// Pool via reserve (signatures), not flat powerPct — provider still has powerPct>=40.
|
|
3417
|
+
const armCost = s.cost ?? 0;
|
|
3418
|
+
if (f.power < armCost + reserve) continue;
|
|
3419
|
+
if (!canCastSpell(f, foe, s)) continue;
|
|
3420
|
+
const key = (s.rank ?? 0) * 1000 + (s.reqLevel ?? 0);
|
|
3421
|
+
if (key > bestKey) {
|
|
3422
|
+
best = s;
|
|
3423
|
+
bestKey = key;
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
return best;
|
|
3427
|
+
};
|
|
3428
|
+
|
|
3429
|
+
ctx.onCooldown = onCooldown;
|
|
3430
|
+
ctx.canCastSpell = canCastSpell;
|
|
3431
|
+
ctx.allySnapshot = allySnapshot;
|
|
3432
|
+
ctx.pickAction = pickAction;
|
|
3433
|
+
ctx.pickCcTarget = pickCcTarget;
|
|
3434
|
+
ctx.pickNextSwingArm = pickNextSwingArm;
|
|
3435
|
+
ctx.schoolLocked = schoolLocked;
|
|
3436
|
+
ctx.stealthPosError = stealthPosError;
|
|
3437
|
+
ctx.chargeCastOk = chargeCastOk;
|
|
3438
|
+
ctx.isInMeleeOf = isInMeleeOf;
|
|
3439
|
+
ctx.isEngagedMelee = isEngagedMelee;
|
|
3440
|
+
ctx.isChargeRole = isChargeRole;
|
|
3441
|
+
ctx.requiresLivingPetTarget = requiresLivingPetTarget;
|
|
3442
|
+
ctx.isTriggeredCompanion = isTriggeredCompanion;
|
|
3443
|
+
ctx.find = find;
|
|
3444
|
+
ctx.livingPetOf = livingPetOf;
|
|
3445
|
+
ctx.conditionSnapshotFor = conditionSnapshotFor;
|
|
3446
|
+
ctx.canAfford = canAfford;
|
|
3447
|
+
ctx.getGcdMs = getGcdMs;
|
|
3448
|
+
ctx.spellIncursGcd = spellIncursGcd;
|
|
3449
|
+
}
|