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