azeroth-shell 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -2
- package/bin/azsh.mjs +19 -0
- package/docs/aifid/eventai-texts.json +4414 -0
- package/docs/dungeons/atlas/atlas-rows-dungeons.json +8989 -0
- package/docs/dungeons/survey/pack-id-migration.json +2418 -0
- package/package.json +35 -4
- package/src/autopilot/bossSkip.ts +26 -0
- package/src/autopilot/combatRotation.ts +824 -0
- package/src/autopilot/config.ts +533 -0
- package/src/autopilot/consumables.ts +58 -0
- package/src/autopilot/encounterStand.ts +121 -0
- package/src/autopilot/objectGatePlan.ts +53 -0
- package/src/autopilot/pathEngage.ts +229 -0
- package/src/autopilot/plan.ts +1643 -0
- package/src/autopilot/questPolicy.ts +274 -0
- package/src/autopilot/routeStep.ts +69 -0
- package/src/autopilot/sessionReport.ts +88 -0
- package/src/autopilot/topOffPower.ts +26 -0
- package/src/autopilot/view.ts +476 -0
- package/src/cli/bootCheck.mjs +34 -0
- package/src/cli/bootData.mjs +57 -0
- package/src/cli/combatLogFilter.ts +153 -0
- package/src/cli/combatLogFormat.ts +613 -0
- package/src/cli/fileRepository.mjs +105 -0
- package/src/cli/gzLoader.mjs +31 -0
- package/src/cli/liveGauntletEscort.ts +978 -0
- package/src/cli/liveStratUndead.ts +305 -0
- package/src/cli/macroHostCombat.ts +545 -0
- package/src/cli/macroHostWorld.ts +330 -0
- package/src/cli/prefs.mjs +31 -0
- package/src/cli/register.mjs +4 -0
- package/src/cli/repl.mjs +693 -0
- package/src/cli/rotationText.mjs +226 -0
- package/src/cli/squad.mjs +105 -0
- package/src/cli/term.mjs +144 -0
- package/src/cli/text.mjs +122 -0
- package/src/cli/tui.mjs +626 -0
- package/src/cli/tuiArena.mjs +109 -0
- package/src/cli/tuiAutopilotSettings.mjs +93 -0
- package/src/cli/tuiBags.mjs +180 -0
- package/src/cli/tuiBars.mjs +133 -0
- package/src/cli/tuiBg.mjs +243 -0
- package/src/cli/tuiBulk.mjs +162 -0
- package/src/cli/tuiCheck.mjs +1882 -0
- package/src/cli/tuiCity.mjs +396 -0
- package/src/cli/tuiCompanionKit.mjs +300 -0
- package/src/cli/tuiCompanions.mjs +471 -0
- package/src/cli/tuiDev.mjs +62 -0
- package/src/cli/tuiDungeon.mjs +606 -0
- package/src/cli/tuiEnchantTarget.mjs +120 -0
- package/src/cli/tuiFight.mjs +577 -0
- package/src/cli/tuiFightPanes.mjs +228 -0
- package/src/cli/tuiFloor.mjs +506 -0
- package/src/cli/tuiGear.mjs +158 -0
- package/src/cli/tuiHonor.mjs +74 -0
- package/src/cli/tuiLoot.mjs +97 -0
- package/src/cli/tuiMacros.mjs +144 -0
- package/src/cli/tuiOutfitter.mjs +115 -0
- package/src/cli/tuiPet.mjs +116 -0
- package/src/cli/tuiProfessions.mjs +800 -0
- package/src/cli/tuiRaid.mjs +127 -0
- package/src/cli/tuiRep.mjs +65 -0
- package/src/cli/tuiSaves.mjs +69 -0
- package/src/cli/tuiSearch.mjs +363 -0
- package/src/cli/tuiSettings.mjs +151 -0
- package/src/cli/tuiSimLab.mjs +80 -0
- package/src/cli/tuiTalents.mjs +60 -0
- package/src/cli/tuiViews.mjs +1178 -0
- package/src/cli/tuiWiki.mjs +181 -0
- package/src/cli/tuiWorld.mjs +361 -0
- package/src/cli/wikiIndex.ts +259 -0
- package/src/cli/wikiSources.ts +355 -0
- package/src/data/alteracValley.ts +176 -0
- package/src/data/appearanceOptions.ts +534 -0
- package/src/data/applyLootChanceSanity.ts +21 -0
- package/src/data/createFlavor.ts +96 -0
- package/src/data/customItems.ts +33 -0
- package/src/data/dataStore.ts +1904 -0
- package/src/data/dungeonGameObjects.ts +84 -0
- package/src/data/dungeonPlateCover.ts +66 -0
- package/src/data/dungeonSpawns.ts +294 -0
- package/src/data/dungeons/alterac-valley.ts +135 -0
- package/src/data/dungeons/apply-dungeon-rooms.ts +29 -0
- package/src/data/dungeons/apply-encounter-outdoors.ts +72 -0
- package/src/data/dungeons/apply-instance-geometry.ts +65 -0
- package/src/data/dungeons/apply-spawn-placement.ts +171 -0
- package/src/data/dungeons/blackfathom-deeps.ts +539 -0
- package/src/data/dungeons/blackrock-depths.ts +1410 -0
- package/src/data/dungeons/blackwing-lair.ts +129 -0
- package/src/data/dungeons/boss-eventai.ts +8 -0
- package/src/data/dungeons/deadmines.ts +410 -0
- package/src/data/dungeons/dire-maul-east.ts +980 -0
- package/src/data/dungeons/dire-maul-north.ts +661 -0
- package/src/data/dungeons/dire-maul-west.ts +785 -0
- package/src/data/dungeons/dungeon-rooms.json +5479 -0
- package/src/data/dungeons/encounter-outdoors.json +359 -0
- package/src/data/dungeons/eventai/blackfathom-deeps.ts +308 -0
- package/src/data/dungeons/eventai/blackrock-depths.ts +1257 -0
- package/src/data/dungeons/eventai/deadmines.ts +264 -0
- package/src/data/dungeons/eventai/dire-maul-east.ts +320 -0
- package/src/data/dungeons/eventai/gnomeregan.ts +213 -0
- package/src/data/dungeons/eventai/helpers.ts +79 -0
- package/src/data/dungeons/eventai/index.ts +133 -0
- package/src/data/dungeons/eventai/lbrs.ts +372 -0
- package/src/data/dungeons/eventai/maraudon.ts +323 -0
- package/src/data/dungeons/eventai/ragefire-chasm.ts +108 -0
- package/src/data/dungeons/eventai/razorfen-downs.ts +207 -0
- package/src/data/dungeons/eventai/razorfen-kraul.ts +326 -0
- package/src/data/dungeons/eventai/scholomance.ts +680 -0
- package/src/data/dungeons/eventai/shadowfang-keep.ts +230 -0
- package/src/data/dungeons/eventai/sm-cathedral.ts +75 -0
- package/src/data/dungeons/eventai/sm-graveyard.ts +150 -0
- package/src/data/dungeons/eventai/sm-library.ts +55 -0
- package/src/data/dungeons/eventai/stratholme-undead.ts +127 -0
- package/src/data/dungeons/eventai/sunken-temple.ts +582 -0
- package/src/data/dungeons/eventai/the-stockade.ts +228 -0
- package/src/data/dungeons/eventai/ubrs.ts +225 -0
- package/src/data/dungeons/eventai/uldaman.ts +355 -0
- package/src/data/dungeons/eventai/wailing-caverns.ts +396 -0
- package/src/data/dungeons/eventai/zul-farrak.ts +591 -0
- package/src/data/dungeons/gnomeregan.ts +909 -0
- package/src/data/dungeons/index.ts +126 -0
- package/src/data/dungeons/instance-geometry.json +5233 -0
- package/src/data/dungeons/key-sources.json +169 -0
- package/src/data/dungeons/lbrs.ts +996 -0
- package/src/data/dungeons/maraudon.ts +786 -0
- package/src/data/dungeons/molten-core.ts +194 -0
- package/src/data/dungeons/naxxramas.ts +173 -0
- package/src/data/dungeons/onyxias-lair.ts +71 -0
- package/src/data/dungeons/pack-from-clusters.ts +1256 -0
- package/src/data/dungeons/ragefire-chasm.ts +238 -0
- package/src/data/dungeons/razorfen-downs.ts +698 -0
- package/src/data/dungeons/razorfen-kraul.ts +766 -0
- package/src/data/dungeons/ruins-of-ahnqiraj.ts +121 -0
- package/src/data/dungeons/scholomance.ts +1076 -0
- package/src/data/dungeons/shadowfang-keep.ts +740 -0
- package/src/data/dungeons/sm-armory.ts +266 -0
- package/src/data/dungeons/sm-cathedral.ts +430 -0
- package/src/data/dungeons/sm-graveyard.ts +482 -0
- package/src/data/dungeons/sm-library.ts +337 -0
- package/src/data/dungeons/stratholme-live.ts +808 -0
- package/src/data/dungeons/stratholme-undead.ts +1164 -0
- package/src/data/dungeons/sunken-temple.ts +931 -0
- package/src/data/dungeons/temple-of-ahnqiraj.ts +119 -0
- package/src/data/dungeons/the-stockade.ts +282 -0
- package/src/data/dungeons/ubrs.ts +1286 -0
- package/src/data/dungeons/uldaman.ts +685 -0
- package/src/data/dungeons/wailing-caverns.ts +356 -0
- package/src/data/dungeons/zul-farrak.ts +686 -0
- package/src/data/dungeons/zul-gurub.ts +142 -0
- package/src/data/dungeons.ts +42 -0
- package/src/data/fixtures.ts +446 -0
- package/src/data/gameobject-data.json.gz +0 -0
- package/src/data/item-data.json.gz +0 -0
- package/src/data/loot-data.json.gz +0 -0
- package/src/data/maps/atlas-approach-maps.json +1425 -0
- package/src/data/maps/atlas-landmarks.json +1711 -0
- package/src/data/maps/dungeon-viewports.json +392 -0
- package/src/data/materializeModDamagePercentTaken.ts +70 -0
- package/src/data/materializeModHealthRegen.ts +89 -0
- package/src/data/materializeSpellReputation.ts +26 -0
- package/src/data/pageTexts.ts +19 -0
- package/src/data/profession-data.json.gz +0 -0
- package/src/data/raid-plans/blackfathom-deeps.json +86 -0
- package/src/data/raid-plans/blackrock-depths.json +419 -0
- package/src/data/raid-plans/blackwing-lair.json +328 -0
- package/src/data/raid-plans/deadmines.json +55 -0
- package/src/data/raid-plans/dire-maul-east.json +94 -0
- package/src/data/raid-plans/dire-maul-north.json +70 -0
- package/src/data/raid-plans/dire-maul-west.json +75 -0
- package/src/data/raid-plans/gnomeregan.json +131 -0
- package/src/data/raid-plans/lbrs.json +176 -0
- package/src/data/raid-plans/maraudon.json +102 -0
- package/src/data/raid-plans/molten-core.json +169 -0
- package/src/data/raid-plans/naxxramas.json +354 -0
- package/src/data/raid-plans/onyxias-lair.json +18 -0
- package/src/data/raid-plans/ragefire-chasm.json +30 -0
- package/src/data/raid-plans/razorfen-downs.json +75 -0
- package/src/data/raid-plans/razorfen-kraul.json +53 -0
- package/src/data/raid-plans/ruins-of-ahnqiraj.json +110 -0
- package/src/data/raid-plans/scholomance.json +170 -0
- package/src/data/raid-plans/shadowfang-keep.json +87 -0
- package/src/data/raid-plans/sm-armory.json +53 -0
- package/src/data/raid-plans/sm-cathedral.json +72 -0
- package/src/data/raid-plans/sm-graveyard.json +63 -0
- package/src/data/raid-plans/sm-library.json +23 -0
- package/src/data/raid-plans/stratholme-live.json +129 -0
- package/src/data/raid-plans/stratholme-undead.json +73 -0
- package/src/data/raid-plans/sunken-temple.json +159 -0
- package/src/data/raid-plans/temple-of-ahnqiraj.json +163 -0
- package/src/data/raid-plans/the-stockade.json +42 -0
- package/src/data/raid-plans/ubrs.json +53 -0
- package/src/data/raid-plans/uldaman.json +131 -0
- package/src/data/raid-plans/wailing-caverns.json +90 -0
- package/src/data/raid-plans/zul-farrak.json +156 -0
- package/src/data/raid-plans/zul-gurub.json +307 -0
- package/src/data/raidInstanceCreatures.mjs +473 -0
- package/src/data/rotations/index.ts +152 -0
- package/src/data/rotations/layout.mjs +72 -0
- package/src/data/rotations/pve/druid/balance.json +754 -0
- package/src/data/rotations/pve/druid/feral-tank.json +2448 -0
- package/src/data/rotations/pve/druid/feral.json +2660 -0
- package/src/data/rotations/pve/druid/restoration.json +424 -0
- package/src/data/rotations/pve/hunter/beast-mastery.json +1288 -0
- package/src/data/rotations/pve/hunter/marksmanship.json +1260 -0
- package/src/data/rotations/pve/hunter/survival.json +1373 -0
- package/src/data/rotations/pve/mage/arcane-leveling.json +1798 -0
- package/src/data/rotations/pve/mage/fire-leveling.json +2003 -0
- package/src/data/rotations/pve/mage/frost-leveling.json +1912 -0
- package/src/data/rotations/pve/paladin/holy.json +755 -0
- package/src/data/rotations/pve/paladin/protection.json +962 -0
- package/src/data/rotations/pve/paladin/retribution.json +924 -0
- package/src/data/rotations/pve/priest/discipline.json +772 -0
- package/src/data/rotations/pve/priest/holy.json +707 -0
- package/src/data/rotations/pve/priest/shadow.json +787 -0
- package/src/data/rotations/pve/rogue/assassination.json +2477 -0
- package/src/data/rotations/pve/rogue/combat.json +2483 -0
- package/src/data/rotations/pve/rogue/subtlety.json +2523 -0
- package/src/data/rotations/pve/shaman/elemental.json +1157 -0
- package/src/data/rotations/pve/shaman/enhancement.json +1085 -0
- package/src/data/rotations/pve/shaman/restoration.json +592 -0
- package/src/data/rotations/pve/warlock/affliction.json +1185 -0
- package/src/data/rotations/pve/warlock/demonology.json +1165 -0
- package/src/data/rotations/pve/warlock/destruction.json +1177 -0
- package/src/data/rotations/pve/warrior/arms.json +3020 -0
- package/src/data/rotations/pve/warrior/fury.json +2996 -0
- package/src/data/rotations/pve/warrior/protection.json +2509 -0
- package/src/data/rotations/pvp/druid/balance.json +593 -0
- package/src/data/rotations/pvp/druid/feral.json +3276 -0
- package/src/data/rotations/pvp/druid/restoration.json +662 -0
- package/src/data/rotations/pvp/hunter/beast-mastery.json +665 -0
- package/src/data/rotations/pvp/hunter/marksmanship.json +2406 -0
- package/src/data/rotations/pvp/hunter/survival.json +635 -0
- package/src/data/rotations/pvp/mage/arcane.json +574 -0
- package/src/data/rotations/pvp/mage/fire.json +570 -0
- package/src/data/rotations/pvp/mage/frost.json +4048 -0
- package/src/data/rotations/pvp/paladin/holy.json +1498 -0
- package/src/data/rotations/pvp/paladin/protection.json +567 -0
- package/src/data/rotations/pvp/paladin/retribution.json +1110 -0
- package/src/data/rotations/pvp/priest/discipline.json +1848 -0
- package/src/data/rotations/pvp/priest/holy.json +6340 -0
- package/src/data/rotations/pvp/priest/shadow.json +2329 -0
- package/src/data/rotations/pvp/rogue/assassination.json +1322 -0
- package/src/data/rotations/pvp/rogue/combat.json +5258 -0
- package/src/data/rotations/pvp/rogue/subtlety.json +586 -0
- package/src/data/rotations/pvp/shaman/elemental.json +1404 -0
- package/src/data/rotations/pvp/shaman/enhancement.json +1277 -0
- package/src/data/rotations/pvp/shaman/restoration.json +2490 -0
- package/src/data/rotations/pvp/warlock/affliction.json +1017 -0
- package/src/data/rotations/pvp/warlock/demonology.json +1503 -0
- package/src/data/rotations/pvp/warlock/destruction.json +638 -0
- package/src/data/rotations/pvp/warrior/arms.json +5665 -0
- package/src/data/rotations/pvp/warrior/fury.json +850 -0
- package/src/data/rotations/pvp/warrior/protection.json +777 -0
- package/src/data/schema/bundle.ts +511 -0
- package/src/data/schema/constants.ts +49 -0
- package/src/data/schema/creature.ts +454 -0
- package/src/data/schema/encounter.ts +155 -0
- package/src/data/schema/faction.ts +48 -0
- package/src/data/schema/item.ts +297 -0
- package/src/data/schema/itemEnchant.ts +105 -0
- package/src/data/schema/npc.ts +44 -0
- package/src/data/schema/primitives.ts +295 -0
- package/src/data/schema/profession.ts +226 -0
- package/src/data/schema/quest.ts +154 -0
- package/src/data/schema/spell.ts +1149 -0
- package/src/data/schema/talent.ts +74 -0
- package/src/data/schema/world.ts +124 -0
- package/src/data/schema.ts +19 -0
- package/src/data/skinNodeGapFill.ts +68 -0
- package/src/data/spell-data.json.gz +0 -0
- package/src/data/spell-script-targets.json +1 -0
- package/src/data/tables/ahItemValues.json.gz +0 -0
- package/src/data/tables/alteracValley.json.gz +0 -0
- package/src/data/tables/areatriggerTeleports.json.gz +0 -0
- package/src/data/tables/botNames.json.gz +0 -0
- package/src/data/tables/botPopulation.json.gz +0 -0
- package/src/data/tables/botSpecs.json.gz +0 -0
- package/src/data/tables/classLevelStats.json.gz +0 -0
- package/src/data/tables/combatConditions.json.gz +0 -0
- package/src/data/tables/combatSkillMeta.json.gz +0 -0
- package/src/data/tables/conditions.json.gz +0 -0
- package/src/data/tables/createInfo.json.gz +0 -0
- package/src/data/tables/creatureAggroGroups.json.gz +0 -0
- package/src/data/tables/creatureClassLevelStats.json.gz +0 -0
- package/src/data/tables/creaturePatrolPacks.json.gz +0 -0
- package/src/data/tables/creatureWeapons.json.gz +0 -0
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/tables/dbScripts.json.gz +0 -0
- package/src/data/tables/dungeonAuthoredRoutes.json.gz +0 -0
- package/src/data/tables/dungeonCensus.json.gz +0 -0
- package/src/data/tables/dungeonGameObjects.json.gz +0 -0
- package/src/data/tables/dungeonPacks.json.gz +0 -0
- package/src/data/tables/dungeonPlateCover.json.gz +0 -0
- package/src/data/tables/dungeonSpawns.json.gz +0 -0
- package/src/data/tables/dungeonSurvey.json.gz +0 -0
- package/src/data/tables/durabilityCosts.json.gz +0 -0
- package/src/data/tables/durabilityQuality.json.gz +0 -0
- package/src/data/tables/enchantApplicators.json.gz +0 -0
- package/src/data/tables/entranceYards.json.gz +0 -0
- package/src/data/tables/explorationBasexp.json.gz +0 -0
- package/src/data/tables/factionTemplates.json.gz +0 -0
- package/src/data/tables/factions.json.gz +0 -0
- package/src/data/tables/gameEvents.json.gz +0 -0
- package/src/data/tables/gameObjects.json.gz +0 -0
- package/src/data/tables/gossipMenus.json.gz +0 -0
- package/src/data/tables/graveyards.json.gz +0 -0
- package/src/data/tables/instanceMaps.json.gz +0 -0
- package/src/data/tables/itemEnchantmentTemplates.json.gz +0 -0
- package/src/data/tables/itemEnchants.json.gz +0 -0
- package/src/data/tables/itemRandomProperties.json.gz +0 -0
- package/src/data/tables/itemSets.json.gz +0 -0
- package/src/data/tables/items.json.gz +0 -0
- package/src/data/tables/levelStats.json.gz +0 -0
- package/src/data/tables/locks.json.gz +0 -0
- package/src/data/tables/map-pin-overrides.json.gz +0 -0
- package/src/data/tables/nonCombatTrash.json.gz +0 -0
- package/src/data/tables/npcTexts.json.gz +0 -0
- package/src/data/tables/pageTexts.json.gz +0 -0
- package/src/data/tables/petAbilityTP.json.gz +0 -0
- package/src/data/tables/petGenericSpells.json.gz +0 -0
- package/src/data/tables/petLevelStats.json.gz +0 -0
- package/src/data/tables/playerbot-weightscales.json.gz +0 -0
- package/src/data/tables/pools.json.gz +0 -0
- package/src/data/tables/quests.json.gz +0 -0
- package/src/data/tables/races.json.gz +0 -0
- package/src/data/tables/reputationRanks.json.gz +0 -0
- package/src/data/tables/reputationRates.json.gz +0 -0
- package/src/data/tables/reputationSpillover.json.gz +0 -0
- package/src/data/tables/ridingSpells.json.gz +0 -0
- package/src/data/tables/scriptTexts.json.gz +0 -0
- package/src/data/tables/soundClipVolume.json.gz +0 -0
- package/src/data/tables/spellChain.json.gz +0 -0
- package/src/data/tables/spellTeleports.json.gz +0 -0
- package/src/data/tables/spells.json.gz +0 -0
- package/src/data/tables/talents.json.gz +0 -0
- package/src/data/tables/tavernTriggers.json.gz +0 -0
- package/src/data/tables/taxiNodes.json.gz +0 -0
- package/src/data/tables/taxiRoutes.json.gz +0 -0
- package/src/data/tables/trainers.json.gz +0 -0
- package/src/data/tables/transports.json.gz +0 -0
- package/src/data/tables/travelAnchors.json.gz +0 -0
- package/src/data/tables/travelEdges.json.gz +0 -0
- package/src/data/tables/unitConditions.json.gz +0 -0
- package/src/data/tables/vendors.json.gz +0 -0
- package/src/data/tables/weaponEnchantProcs.json.gz +0 -0
- package/src/data/tables/xp.json.gz +0 -0
- package/src/data/tables/zoneWeather.json.gz +0 -0
- package/src/data/tables/zones.json.gz +0 -0
- package/src/data/world-data.ts +56 -0
- package/src/data/zone-music-sets.json +2751 -0
- package/src/dev/etl/lib/loot-chance-sanity.mjs +180 -0
- package/src/domain/audioQuality.mjs +145 -0
- package/src/domain/bg/ab.ts +433 -0
- package/src/domain/bg/arenaComps.ts +644 -0
- package/src/domain/bg/arenaLab.ts +695 -0
- package/src/domain/bg/arenaPace.ts +31 -0
- package/src/domain/bg/arenaRating.ts +110 -0
- package/src/domain/bg/av.ts +758 -0
- package/src/domain/bg/avEnemyPush.ts +219 -0
- package/src/domain/bg/avField.ts +283 -0
- package/src/domain/bg/avInstanceNodes.ts +197 -0
- package/src/domain/bg/avInstanceScore.ts +123 -0
- package/src/domain/bg/avQuestItems.ts +55 -0
- package/src/domain/bg/avQuests.ts +210 -0
- package/src/domain/bg/avSession.ts +196 -0
- package/src/domain/bg/bgConsumes.ts +269 -0
- package/src/domain/bg/bgPrebuff.ts +153 -0
- package/src/domain/bg/bgRest.ts +91 -0
- package/src/domain/bg/bgRoster.ts +305 -0
- package/src/domain/bg/bgTier.ts +61 -0
- package/src/domain/bg/bgWorld.ts +862 -0
- package/src/domain/bg/match.ts +215 -0
- package/src/domain/bg/strategies.ts +552 -0
- package/src/domain/bg/types.ts +291 -0
- package/src/domain/bg/wsg.ts +264 -0
- package/src/domain/bots/actionProviderSpec.ts +38 -0
- package/src/domain/bots/autoConsumables.ts +285 -0
- package/src/domain/bots/betweenPullRest.ts +239 -0
- package/src/domain/bots/botGear.ts +248 -0
- package/src/domain/bots/botInventory.ts +486 -0
- package/src/domain/bots/botPet.ts +535 -0
- package/src/domain/bots/botPool.ts +291 -0
- package/src/domain/bots/botProfessions.ts +334 -0
- package/src/domain/bots/companionActionBar.ts +209 -0
- package/src/domain/bots/companionHireCost.ts +236 -0
- package/src/domain/bots/companionMissions.ts +582 -0
- package/src/domain/bots/companionPrep.ts +254 -0
- package/src/domain/bots/companionQuests.ts +270 -0
- package/src/domain/bots/companionRep.ts +75 -0
- package/src/domain/bots/companionRotation.ts +708 -0
- package/src/domain/bots/companionXp.ts +66 -0
- package/src/domain/bots/consumableShare.ts +811 -0
- package/src/domain/bots/dpsMate.ts +1072 -0
- package/src/domain/bots/groupCompReport.ts +132 -0
- package/src/domain/bots/healthstoneSupply.ts +198 -0
- package/src/domain/bots/outfitter.ts +1056 -0
- package/src/domain/bots/paladinPrePull.ts +231 -0
- package/src/domain/bots/partyRole.ts +40 -0
- package/src/domain/bots/playerbotEquipCache.ts +412 -0
- package/src/domain/bots/playerbotItemEligibility.ts +60 -0
- package/src/domain/bots/playerbotStatWeight.ts +853 -0
- package/src/domain/bots/playerbotUnavailableItems.ts +147 -0
- package/src/domain/bots/prePullBuffs.ts +219 -0
- package/src/domain/bots/prePullStealth.ts +82 -0
- package/src/domain/bots/runSupply.ts +251 -0
- package/src/domain/bots/runSupplyPrefs.ts +28 -0
- package/src/domain/bots/sendTradeGoods.ts +394 -0
- package/src/domain/bots/soulShardCap.ts +35 -0
- package/src/domain/bots/squad.ts +842 -0
- package/src/domain/bots/squadRun.ts +81 -0
- package/src/domain/character/actionbar.ts +346 -0
- package/src/domain/character/appearance.ts +165 -0
- package/src/domain/character/buildCombatant.ts +1726 -0
- package/src/domain/character/character.ts +356 -0
- package/src/domain/character/characterName.ts +43 -0
- package/src/domain/character/characterRatings.ts +424 -0
- package/src/domain/character/characterSpells.ts +282 -0
- package/src/domain/character/characterStats.ts +1559 -0
- package/src/domain/character/characterTalents.ts +398 -0
- package/src/domain/character/combatSkills.ts +994 -0
- package/src/domain/character/displayForm.ts +72 -0
- package/src/domain/character/formTalentAuras.ts +171 -0
- package/src/domain/character/forms.ts +499 -0
- package/src/domain/character/races.ts +40 -0
- package/src/domain/character/resurrectionSickness.ts +49 -0
- package/src/domain/character/spellbook.ts +169 -0
- package/src/domain/character/stanceBar.ts +354 -0
- package/src/domain/character/stancePrereq.ts +70 -0
- package/src/domain/character/talentBuilds.ts +63 -0
- package/src/domain/character/talentImport.ts +130 -0
- package/src/domain/character/talentLink.ts +123 -0
- package/src/domain/character/talents.ts +201 -0
- package/src/domain/core/conditionSnapshot.ts +92 -0
- package/src/domain/core/hover.ts +14 -0
- package/src/domain/core/ids.ts +58 -0
- package/src/domain/core/internalNameTags.ts +5 -0
- package/src/domain/core/runtimeDataPatches.ts +357 -0
- package/src/domain/creature/aiTargeting.ts +588 -0
- package/src/domain/creature/combatCondition.ts +157 -0
- package/src/domain/creature/creatureAggro.ts +804 -0
- package/src/domain/creature/creatureAi.ts +206 -0
- package/src/domain/creature/creatureAiConstants.ts +661 -0
- package/src/domain/creature/creatureCastList.ts +1007 -0
- package/src/domain/creature/creatureEventAi.ts +1409 -0
- package/src/domain/creature/creatureGroups.ts +144 -0
- package/src/domain/creature/creatureLinking.ts +333 -0
- package/src/domain/creature/creatureScaling.ts +320 -0
- package/src/domain/creature/creatureTrigger.ts +53 -0
- package/src/domain/creature/creatureWander.ts +74 -0
- package/src/domain/creature/eventAiTexts.ts +145 -0
- package/src/domain/creature/faction.ts +287 -0
- package/src/domain/creature/forcedReaction.ts +48 -0
- package/src/domain/creature/outdoorHuntable.ts +74 -0
- package/src/domain/creature/patrolPacks.ts +210 -0
- package/src/domain/creature/reputation.ts +967 -0
- package/src/domain/creature/unitFlags.ts +74 -0
- package/src/domain/creature/worldBosses.ts +393 -0
- package/src/domain/dungeonMusic.mjs +48 -0
- package/src/domain/economy/auction.ts +282 -0
- package/src/domain/economy/auctionBot.ts +1060 -0
- package/src/domain/economy/honorPrice.ts +100 -0
- package/src/domain/economy/mail.ts +958 -0
- package/src/domain/economy/money.ts +42 -0
- package/src/domain/economy/syntheticClassTrainers.ts +62 -0
- package/src/domain/economy/trading.ts +296 -0
- package/src/domain/economy/vendorSellAll.ts +252 -0
- package/src/domain/economy/vendorStock.ts +146 -0
- package/src/domain/items/ammo.ts +817 -0
- package/src/domain/items/bank.ts +162 -0
- package/src/domain/items/consumableFamily.ts +205 -0
- package/src/domain/items/craftUpgrades.ts +83 -0
- package/src/domain/items/createItem.ts +93 -0
- package/src/domain/items/durability.ts +581 -0
- package/src/domain/items/equipment.ts +441 -0
- package/src/domain/items/equipmentHelper.ts +767 -0
- package/src/domain/items/inventory.ts +561 -0
- package/src/domain/items/itemBonding.ts +93 -0
- package/src/domain/items/itemContainer.ts +245 -0
- package/src/domain/items/itemDisplayName.ts +33 -0
- package/src/domain/items/itemInstance.ts +181 -0
- package/src/domain/items/itemRandomEnchantOutcomes.ts +315 -0
- package/src/domain/items/itemRandomProps.ts +77 -0
- package/src/domain/items/itemRestrictions.ts +329 -0
- package/src/domain/items/itemTeaches.ts +82 -0
- package/src/domain/items/itemUseCooldown.ts +52 -0
- package/src/domain/items/itemUseTarget.ts +90 -0
- package/src/domain/items/junkCategories.ts +174 -0
- package/src/domain/items/junkFilter.ts +368 -0
- package/src/domain/items/keyring.ts +131 -0
- package/src/domain/items/loot.ts +221 -0
- package/src/domain/items/mounts.ts +190 -0
- package/src/domain/items/onUsePayload.ts +102 -0
- package/src/domain/items/pendingLoot.ts +107 -0
- package/src/domain/items/relic.ts +105 -0
- package/src/domain/items/rocketBoots.ts +36 -0
- package/src/domain/items/sortContainer.ts +139 -0
- package/src/domain/items/spareContainers.ts +71 -0
- package/src/domain/items/spellReagentIndex.ts +131 -0
- package/src/domain/items/staleConsumables.ts +66 -0
- package/src/domain/items/weaponSpender.ts +110 -0
- package/src/domain/listDungeonIds.mjs +28 -0
- package/src/domain/macro/compose.ts +239 -0
- package/src/domain/macro/conditions.ts +260 -0
- package/src/domain/macro/creatureTypes.ts +40 -0
- package/src/domain/macro/describe.ts +169 -0
- package/src/domain/macro/icon.ts +60 -0
- package/src/domain/macro/index.ts +20 -0
- package/src/domain/macro/normalize.ts +112 -0
- package/src/domain/macro/parse.ts +316 -0
- package/src/domain/macro/plan.ts +288 -0
- package/src/domain/macro/rotation.ts +590 -0
- package/src/domain/macro/rotationEnv.ts +173 -0
- package/src/domain/macro/slots.ts +101 -0
- package/src/domain/macro/snapshot.ts +114 -0
- package/src/domain/macro/templates.ts +446 -0
- package/src/domain/macro/types.ts +70 -0
- package/src/domain/macro/units.ts +93 -0
- package/src/domain/macro/weaponTypes.ts +83 -0
- package/src/domain/nav/cityTeleports.ts +10 -0
- package/src/domain/nav/mounting.ts +113 -0
- package/src/domain/nav/nav.ts +43 -0
- package/src/domain/nav/navCollision.ts +606 -0
- package/src/domain/nav/navOutdoors.ts +91 -0
- package/src/domain/nav/navRegistry.ts +179 -0
- package/src/domain/nav/navRoomGraph.ts +302 -0
- package/src/domain/nav/navTypes.ts +43 -0
- package/src/domain/nav/navmesh.ts +1715 -0
- package/src/domain/nav/speed.ts +200 -0
- package/src/domain/nav/storey.ts +10 -0
- package/src/domain/nav/travel.ts +725 -0
- package/src/domain/nav/travelTypes.ts +75 -0
- package/src/domain/pet/pet.ts +1150 -0
- package/src/domain/pet/petActionBar.ts +180 -0
- package/src/domain/pet/petSpellbook.ts +39 -0
- package/src/domain/pet/petStable.ts +81 -0
- package/src/domain/professions/autoDisenchant.ts +236 -0
- package/src/domain/professions/characterEnchants.ts +176 -0
- package/src/domain/professions/craftCooldown.ts +52 -0
- package/src/domain/professions/enchantApply.ts +62 -0
- package/src/domain/professions/enchantTargets.ts +114 -0
- package/src/domain/professions/enchanting.ts +236 -0
- package/src/domain/professions/gatherFilter.ts +149 -0
- package/src/domain/professions/gearEnchant.ts +137 -0
- package/src/domain/professions/heldTools.ts +29 -0
- package/src/domain/professions/learnAllRecipes.ts +146 -0
- package/src/domain/professions/miningHarvest.ts +69 -0
- package/src/domain/professions/partyDisenchant.ts +294 -0
- package/src/domain/professions/partyWork.ts +83 -0
- package/src/domain/professions/pickpocket.ts +172 -0
- package/src/domain/professions/poisons.ts +240 -0
- package/src/domain/professions/professions.ts +677 -0
- package/src/domain/professions/rareNodes.ts +45 -0
- package/src/domain/professions/skinLoot.ts +27 -0
- package/src/domain/professions/vendorReagents.ts +152 -0
- package/src/domain/professions/weaponImbueHand.ts +171 -0
- package/src/domain/questFarmFilter.mjs +42 -0
- package/src/domain/quests/doQuest.ts +1204 -0
- package/src/domain/quests/gossip.ts +308 -0
- package/src/domain/quests/gossipTalk.ts +298 -0
- package/src/domain/quests/interpreter/canComplete.ts +150 -0
- package/src/domain/quests/interpreter/claim.ts +137 -0
- package/src/domain/quests/interpreter/dbscript/handlers.ts +1371 -0
- package/src/domain/quests/interpreter/dbscript/handlersBatch.ts +1251 -0
- package/src/domain/quests/interpreter/dbscript/hooks.ts +331 -0
- package/src/domain/quests/interpreter/dbscript/index.ts +212 -0
- package/src/domain/quests/interpreter/dbscript/opcodes.ts +257 -0
- package/src/domain/quests/interpreter/dbscript/registry.ts +1194 -0
- package/src/domain/quests/interpreter/dbscript/registryAreatriggerScripts.ts +539 -0
- package/src/domain/quests/interpreter/dbscript/registryAshenvale.ts +553 -0
- package/src/domain/quests/interpreter/dbscript/registryAzshara.ts +272 -0
- package/src/domain/quests/interpreter/dbscript/registryDarkshore.ts +637 -0
- package/src/domain/quests/interpreter/dbscript/registryDesolace.ts +501 -0
- package/src/domain/quests/interpreter/dbscript/registryDustwallowMarsh.ts +735 -0
- package/src/domain/quests/interpreter/dbscript/registryFelwood.ts +493 -0
- package/src/domain/quests/interpreter/dbscript/registryFeralas.ts +485 -0
- package/src/domain/quests/interpreter/dbscript/registryGoScripts.ts +203 -0
- package/src/domain/quests/interpreter/dbscript/registryMoonglade.ts +288 -0
- package/src/domain/quests/interpreter/dbscript/registryNpcProfessions.ts +230 -0
- package/src/domain/quests/interpreter/dbscript/registryNpcsSpecial.ts +545 -0
- package/src/domain/quests/interpreter/dbscript/registryRazorfenDowns.ts +293 -0
- package/src/domain/quests/interpreter/dbscript/registryRazorfenKraul.ts +221 -0
- package/src/domain/quests/interpreter/dbscript/registryRedridge.ts +112 -0
- package/src/domain/quests/interpreter/dbscript/registrySearingGorge.ts +125 -0
- package/src/domain/quests/interpreter/dbscript/registrySiblings.ts +51 -0
- package/src/domain/quests/interpreter/dbscript/registrySilverpineForest.ts +172 -0
- package/src/domain/quests/interpreter/dbscript/registryStonetalonMountains.ts +180 -0
- package/src/domain/quests/interpreter/dbscript/registryStormwindCity.ts +369 -0
- package/src/domain/quests/interpreter/dbscript/registryStranglethornVale.ts +115 -0
- package/src/domain/quests/interpreter/dbscript/registryStratholme.ts +146 -0
- package/src/domain/quests/interpreter/dbscript/registrySunkenTemple.ts +149 -0
- package/src/domain/quests/interpreter/dbscript/registrySwampOfSorrows.ts +163 -0
- package/src/domain/quests/interpreter/dbscript/registryTanaris.ts +318 -0
- package/src/domain/quests/interpreter/dbscript/registryTeldrassil.ts +120 -0
- package/src/domain/quests/interpreter/dbscript/registryTheBarrens.ts +603 -0
- package/src/domain/quests/interpreter/dbscript/registryThousandNeedles.ts +499 -0
- package/src/domain/quests/interpreter/dbscript/registryTirisfalGlades.ts +222 -0
- package/src/domain/quests/interpreter/dbscript/registryUngoroCrater.ts +291 -0
- package/src/domain/quests/interpreter/dbscript/registryWarEffort.ts +773 -0
- package/src/domain/quests/interpreter/dbscript/registryWesternPlaguelands.ts +206 -0
- package/src/domain/quests/interpreter/dbscript/registryWestfall.ts +190 -0
- package/src/domain/quests/interpreter/dbscript/registryWetlands.ts +186 -0
- package/src/domain/quests/interpreter/dbscript/registryWinterspring.ts +235 -0
- package/src/domain/quests/interpreter/dbscript/types.ts +817 -0
- package/src/domain/quests/interpreter/dbscript/vm.ts +171 -0
- package/src/domain/quests/interpreter/diff.ts +349 -0
- package/src/domain/quests/interpreter/fixtures/oracle-questlog-qi2.json +2417 -0
- package/src/domain/quests/interpreter/flags.ts +101 -0
- package/src/domain/quests/interpreter/index.ts +113 -0
- package/src/domain/quests/interpreter/live.ts +178 -0
- package/src/domain/quests/interpreter/types.ts +81 -0
- package/src/domain/quests/interpreter/updaters.ts +412 -0
- package/src/domain/quests/questCorpseUse.ts +155 -0
- package/src/domain/quests/questDialog.ts +43 -0
- package/src/domain/quests/questFight.ts +34 -0
- package/src/domain/quests/questObjectiveLabel.ts +39 -0
- package/src/domain/quests/questRewardChoice.ts +85 -0
- package/src/domain/quests/questScriptCredit.ts +26 -0
- package/src/domain/quests/questScriptItemUse.ts +74 -0
- package/src/domain/quests/questTameRods.ts +79 -0
- package/src/domain/quests/questlog.ts +1545 -0
- package/src/domain/quests/talkDeliver.ts +170 -0
- package/src/domain/raid/globalActionBar.ts +195 -0
- package/src/domain/raid/instanceRoster.ts +194 -0
- package/src/domain/raid/partySummon.ts +198 -0
- package/src/domain/raid/partyVault.ts +320 -0
- package/src/domain/raid/raidBossAbilities.ts +268 -0
- package/src/domain/raid/raidComp.ts +335 -0
- package/src/domain/raid/raidCustomRows.ts +453 -0
- package/src/domain/raid/raidEncounterGear.ts +298 -0
- package/src/domain/raid/raidEncounterTactics.ts +412 -0
- package/src/domain/raid/raidPlan.ts +509 -0
- package/src/domain/raid/raidPlanEdits.ts +98 -0
- package/src/domain/raid/raidPlanPacks.ts +480 -0
- package/src/domain/raid/raidRecovery.ts +183 -0
- package/src/domain/raid/raidRosterTemplate.ts +138 -0
- package/src/domain/rotation/auraSide.ts +68 -0
- package/src/domain/rotation/build.ts +3031 -0
- package/src/domain/rotation/classes/druid.ts +58 -0
- package/src/domain/rotation/classes/hooks.ts +59 -0
- package/src/domain/rotation/classes/hunter.ts +156 -0
- package/src/domain/rotation/classes/index.ts +104 -0
- package/src/domain/rotation/classes/mage.ts +99 -0
- package/src/domain/rotation/classes/paladin.ts +296 -0
- package/src/domain/rotation/classes/priest.ts +94 -0
- package/src/domain/rotation/classes/pullOpener.ts +92 -0
- package/src/domain/rotation/classes/rogue.ts +69 -0
- package/src/domain/rotation/classes/shaman.ts +29 -0
- package/src/domain/rotation/classes/supportSpec.ts +94 -0
- package/src/domain/rotation/classes/warlock.ts +81 -0
- package/src/domain/rotation/classes/warrior.ts +402 -0
- package/src/domain/rotation/classify.ts +1381 -0
- package/src/domain/rotation/condMetricPicker.ts +379 -0
- package/src/domain/rotation/consumables.ts +242 -0
- package/src/domain/rotation/evaluate.ts +1115 -0
- package/src/domain/rotation/finisherLadder.ts +145 -0
- package/src/domain/rotation/invent.ts +938 -0
- package/src/domain/rotation/killOrder.ts +30 -0
- package/src/domain/rotation/openerForm.ts +22 -0
- package/src/domain/rotation/paladinBuffs.ts +568 -0
- package/src/domain/rotation/prebuff.ts +283 -0
- package/src/domain/rotation/raidRowTuning.ts +34 -0
- package/src/domain/rotation/rotation.ts +11 -0
- package/src/domain/rotation/rotationProfiles.ts +180 -0
- package/src/domain/rotation/rotationSuggestions.ts +851 -0
- package/src/domain/rotation/ruleAfterChain.ts +49 -0
- package/src/domain/rotation/spec.ts +70 -0
- package/src/domain/rotation/spellPicker.ts +140 -0
- package/src/domain/rotation/stand.ts +143 -0
- package/src/domain/rotation/types.ts +718 -0
- package/src/domain/run/battlegroundRun.ts +111 -0
- package/src/domain/run/canEnterContext.ts +32 -0
- package/src/domain/run/death.ts +561 -0
- package/src/domain/run/deathRecovery.ts +289 -0
- package/src/domain/run/duel.ts +141 -0
- package/src/domain/run/dungeonRun.ts +344 -0
- package/src/domain/run/floorRest.ts +159 -0
- package/src/domain/run/honor.ts +557 -0
- package/src/domain/run/honorWeek.ts +180 -0
- package/src/domain/run/whenMap.ts +977 -0
- package/src/domain/scriptdev/arathiHighlands.ts +445 -0
- package/src/domain/scriptdev/ashenvale.ts +635 -0
- package/src/domain/scriptdev/ashenvaleHelpers.ts +1215 -0
- package/src/domain/scriptdev/azshara.ts +892 -0
- package/src/domain/scriptdev/blackfathomDeeps.ts +212 -0
- package/src/domain/scriptdev/blackrockDepths.ts +1434 -0
- package/src/domain/scriptdev/blackrockSpire.ts +730 -0
- package/src/domain/scriptdev/blackwingLair.ts +521 -0
- package/src/domain/scriptdev/burningSteppes.ts +62 -0
- package/src/domain/scriptdev/classFilter.ts +43 -0
- package/src/domain/scriptdev/darkshore.ts +817 -0
- package/src/domain/scriptdev/darkshoreHelpers.ts +853 -0
- package/src/domain/scriptdev/deadmines.ts +331 -0
- package/src/domain/scriptdev/desolace.ts +1239 -0
- package/src/domain/scriptdev/direMaul.ts +859 -0
- package/src/domain/scriptdev/durotar.ts +532 -0
- package/src/domain/scriptdev/dustwallowMarsh.ts +758 -0
- package/src/domain/scriptdev/dustwallowMarshHelpers.ts +994 -0
- package/src/domain/scriptdev/easternPlaguelands.ts +21 -0
- package/src/domain/scriptdev/felwood.ts +1055 -0
- package/src/domain/scriptdev/feralas.ts +571 -0
- package/src/domain/scriptdev/feralasHelpers.ts +838 -0
- package/src/domain/scriptdev/gnomeregan.ts +466 -0
- package/src/domain/scriptdev/guards.ts +616 -0
- package/src/domain/scriptdev/hinterlands.ts +693 -0
- package/src/domain/scriptdev/host.ts +1134 -0
- package/src/domain/scriptdev/index.ts +123 -0
- package/src/domain/scriptdev/instanceRaids.ts +136 -0
- package/src/domain/scriptdev/itemScripts.ts +37 -0
- package/src/domain/scriptdev/lochModan.ts +283 -0
- package/src/domain/scriptdev/maraudon.ts +232 -0
- package/src/domain/scriptdev/moltenCore.ts +831 -0
- package/src/domain/scriptdev/moonglade.ts +659 -0
- package/src/domain/scriptdev/moongladeHelpers.ts +933 -0
- package/src/domain/scriptdev/naxxramas.ts +577 -0
- package/src/domain/scriptdev/naxxramasConstruct.ts +364 -0
- package/src/domain/scriptdev/naxxramasFrostwyrm.ts +378 -0
- package/src/domain/scriptdev/naxxramasMilitary.ts +276 -0
- package/src/domain/scriptdev/npcProfessions.ts +1025 -0
- package/src/domain/scriptdev/npcsSpecial.ts +1379 -0
- package/src/domain/scriptdev/onyxiasLair.ts +313 -0
- package/src/domain/scriptdev/orgrimmar.ts +147 -0
- package/src/domain/scriptdev/professionSpecConsumer.ts +481 -0
- package/src/domain/scriptdev/razorfenDowns.ts +468 -0
- package/src/domain/scriptdev/razorfenKraul.ts +410 -0
- package/src/domain/scriptdev/redridgeMountains.ts +163 -0
- package/src/domain/scriptdev/registry.ts +945 -0
- package/src/domain/scriptdev/ruinsOfAhnqiraj.ts +356 -0
- package/src/domain/scriptdev/scarletMonastery.ts +713 -0
- package/src/domain/scriptdev/scholomance.ts +682 -0
- package/src/domain/scriptdev/searingGorge.ts +295 -0
- package/src/domain/scriptdev/shadowfangKeep.ts +181 -0
- package/src/domain/scriptdev/silithus.ts +71 -0
- package/src/domain/scriptdev/silverpineForest.ts +662 -0
- package/src/domain/scriptdev/stonetalonMountains.ts +493 -0
- package/src/domain/scriptdev/stormwindCity.ts +1192 -0
- package/src/domain/scriptdev/stranglethornVale.ts +402 -0
- package/src/domain/scriptdev/stratholme.ts +650 -0
- package/src/domain/scriptdev/stratholmeScripts.ts +505 -0
- package/src/domain/scriptdev/sunkenTemple.ts +736 -0
- package/src/domain/scriptdev/swampOfSorrows.ts +22 -0
- package/src/domain/scriptdev/tanaris.ts +391 -0
- package/src/domain/scriptdev/tanarisHelpers.ts +661 -0
- package/src/domain/scriptdev/teldrassil.ts +143 -0
- package/src/domain/scriptdev/templeOfAhnqiraj.ts +1013 -0
- package/src/domain/scriptdev/theBarrens.ts +709 -0
- package/src/domain/scriptdev/theBarrensHelpers.ts +1026 -0
- package/src/domain/scriptdev/thousandNeedles.ts +426 -0
- package/src/domain/scriptdev/thousandNeedlesHelpers.ts +566 -0
- package/src/domain/scriptdev/tirisfalGlades.ts +533 -0
- package/src/domain/scriptdev/types.ts +1026 -0
- package/src/domain/scriptdev/uldaman.ts +437 -0
- package/src/domain/scriptdev/ungoroCrater.ts +549 -0
- package/src/domain/scriptdev/ungoroCraterHelpers.ts +1006 -0
- package/src/domain/scriptdev/wailingCaverns.ts +700 -0
- package/src/domain/scriptdev/warEffort.ts +371 -0
- package/src/domain/scriptdev/westernPlaguelands.ts +952 -0
- package/src/domain/scriptdev/westfall.ts +767 -0
- package/src/domain/scriptdev/wetlands.ts +660 -0
- package/src/domain/scriptdev/winterspring.ts +482 -0
- package/src/domain/scriptdev/winterspringHelpers.ts +801 -0
- package/src/domain/scriptdev/worldBosses.ts +605 -0
- package/src/domain/scriptdev/zulfarrak.ts +478 -0
- package/src/domain/scriptdev/zulgurub.ts +1152 -0
- package/src/domain/sim/dummySim.ts +408 -0
- package/src/domain/sim/targetDummy.ts +29 -0
- package/src/domain/social/guild.ts +148 -0
- package/src/domain/spells/areaAuraMembership.ts +93 -0
- package/src/domain/spells/breakableStunCc.ts +166 -0
- package/src/domain/spells/cancelAura.ts +80 -0
- package/src/domain/spells/diminishing.ts +167 -0
- package/src/domain/spells/dispel.ts +149 -0
- package/src/domain/spells/dispelMechanic.ts +105 -0
- package/src/domain/spells/equipSpells.ts +247 -0
- package/src/domain/spells/learnOnTargets.ts +260 -0
- package/src/domain/spells/mergeSchoolImmunityFromRaw.ts +66 -0
- package/src/domain/spells/resurrectSpell.ts +124 -0
- package/src/domain/spells/spellPushback.ts +39 -0
- package/src/domain/spells/spellReflect.ts +43 -0
- package/src/domain/spells/stealth.ts +183 -0
- package/src/domain/spells/totem.ts +394 -0
- package/src/domain/spells/worldCast.ts +187 -0
- package/src/domain/world/areaTriggerLocks.ts +177 -0
- package/src/domain/world/dungeonMusic.ts +108 -0
- package/src/domain/world/gameEvents.ts +119 -0
- package/src/domain/world/gameObjects.ts +1030 -0
- package/src/domain/world/goEventFire.ts +234 -0
- package/src/domain/world/goMoTransport.ts +74 -0
- package/src/domain/world/goTransport.ts +81 -0
- package/src/domain/world/hazards.ts +459 -0
- package/src/domain/world/openLock.ts +398 -0
- package/src/domain/world/pools.ts +470 -0
- package/src/domain/world/restArea.ts +37 -0
- package/src/domain/world/weather.ts +201 -0
- package/src/domain/zoneAmbience.mjs +87 -0
- package/src/domain/zoneMusic.mjs +62 -0
- package/src/domain/zoneMusicQueue.mjs +7 -0
- package/src/engine/combat/activeSeal.ts +39 -0
- package/src/engine/combat/aiTargetResolve.ts +64 -0
- package/src/engine/combat/aoeCast.ts +289 -0
- package/src/engine/combat/applyConsumeAura.ts +74 -0
- package/src/engine/combat/applyDemonicSacrifice.ts +70 -0
- package/src/engine/combat/applyDispelMechanic.ts +30 -0
- package/src/engine/combat/applyHealEffect.ts +301 -0
- package/src/engine/combat/applySchoolAbsorb.ts +114 -0
- package/src/engine/combat/applySpell.ts +3169 -0
- package/src/engine/combat/applySpellLate.ts +2026 -0
- package/src/engine/combat/applySpellLateBag.ts +144 -0
- package/src/engine/combat/areaAura.ts +283 -0
- package/src/engine/combat/attackTable.ts +508 -0
- package/src/engine/combat/battleRez.ts +87 -0
- package/src/engine/combat/castAction.ts +36 -0
- package/src/engine/combat/castTargetGates.ts +75 -0
- package/src/engine/combat/castableKit.ts +68 -0
- package/src/engine/combat/ccMirrorUpgrade.ts +39 -0
- package/src/engine/combat/chainFan.ts +210 -0
- package/src/engine/combat/channel.ts +423 -0
- package/src/engine/combat/chaseCloseness.ts +95 -0
- package/src/engine/combat/cmangosCastGates.ts +129 -0
- package/src/engine/combat/combatFlag.ts +77 -0
- package/src/engine/combat/combatGeometry.ts +1329 -0
- package/src/engine/combat/combustion.ts +68 -0
- package/src/engine/combat/context.ts +730 -0
- package/src/engine/combat/createSession.ts +1165 -0
- package/src/engine/combat/creatureLeash.ts +38 -0
- package/src/engine/combat/crowdControl.ts +1259 -0
- package/src/engine/combat/dispelDuty.ts +54 -0
- package/src/engine/combat/dispelReflect.ts +609 -0
- package/src/engine/combat/displaceFloor.ts +113 -0
- package/src/engine/combat/duelBounds.ts +47 -0
- package/src/engine/combat/equippedItemGate.ts +297 -0
- package/src/engine/combat/evadeLifecycle.ts +63 -0
- package/src/engine/combat/eventAi.ts +2220 -0
- package/src/engine/combat/facing.ts +74 -0
- package/src/engine/combat/fearFlee.ts +137 -0
- package/src/engine/combat/fighterFactory.ts +1144 -0
- package/src/engine/combat/forceDespawnAction.ts +51 -0
- package/src/engine/combat/formulas.ts +807 -0
- package/src/engine/combat/friendlyFan.ts +55 -0
- package/src/engine/combat/groundPoolTick.ts +210 -0
- package/src/engine/combat/hazards.ts +114 -0
- package/src/engine/combat/healTargets.ts +226 -0
- package/src/engine/combat/heartbeatResist.ts +49 -0
- package/src/engine/combat/hostileItemUse.ts +75 -0
- package/src/engine/combat/interruptAuras.ts +66 -0
- package/src/engine/combat/itemRescue.ts +49 -0
- package/src/engine/combat/itemUse.ts +260 -0
- package/src/engine/combat/kitById.ts +23 -0
- package/src/engine/combat/knockBack.ts +201 -0
- package/src/engine/combat/leap.ts +89 -0
- package/src/engine/combat/mitigation.ts +587 -0
- package/src/engine/combat/modTaunt.ts +43 -0
- package/src/engine/combat/motionClaim.ts +268 -0
- package/src/engine/combat/motionGate.ts +150 -0
- package/src/engine/combat/motionGenerator.ts +27 -0
- package/src/engine/combat/navSight.ts +138 -0
- package/src/engine/combat/perfSink.ts +28 -0
- package/src/engine/combat/periodicDue.ts +55 -0
- package/src/engine/combat/petAuras.ts +86 -0
- package/src/engine/combat/pickAction.ts +3449 -0
- package/src/engine/combat/powerForm.ts +735 -0
- package/src/engine/combat/powerPools.ts +49 -0
- package/src/engine/combat/predicates.ts +538 -0
- package/src/engine/combat/procsDamage.ts +1823 -0
- package/src/engine/combat/reactiveGates.ts +135 -0
- package/src/engine/combat/roles.ts +389 -0
- package/src/engine/combat/rotationSubject.ts +78 -0
- package/src/engine/combat/ruleAfter.ts +106 -0
- package/src/engine/combat/seedDisplayAuras.ts +203 -0
- package/src/engine/combat/seedOocEffects.ts +317 -0
- package/src/engine/combat/seedSchoolImmunities.ts +45 -0
- package/src/engine/combat/session-types.ts +458 -0
- package/src/engine/combat/sessionApi.ts +1785 -0
- package/src/engine/combat/sessionCharge.ts +124 -0
- package/src/engine/combat/sessionChase.ts +869 -0
- package/src/engine/combat/sessionDeaths.ts +469 -0
- package/src/engine/combat/sessionFocus.ts +506 -0
- package/src/engine/combat/sessionFollow.ts +167 -0
- package/src/engine/combat/sessionGeometry.ts +188 -0
- package/src/engine/combat/sessionHome.ts +75 -0
- package/src/engine/combat/sessionInject.ts +719 -0
- package/src/engine/combat/sessionMechanicMove.ts +555 -0
- package/src/engine/combat/sessionMotion.ts +1553 -0
- package/src/engine/combat/sessionMotionKit.ts +247 -0
- package/src/engine/combat/sessionMoveIntent.ts +49 -0
- package/src/engine/combat/sessionMoveOrder.ts +200 -0
- package/src/engine/combat/sessionPathTo.ts +657 -0
- package/src/engine/combat/sessionPools.ts +688 -0
- package/src/engine/combat/sessionSteer.ts +144 -0
- package/src/engine/combat/sessionStepBack.ts +151 -0
- package/src/engine/combat/sessionStepWrap.ts +116 -0
- package/src/engine/combat/sessionView.ts +442 -0
- package/src/engine/combat/simEffects.ts +32 -0
- package/src/engine/combat/simFacade.ts +620 -0
- package/src/engine/combat/simPump.ts +264 -0
- package/src/engine/combat/simSnapshot.ts +165 -0
- package/src/engine/combat/simThreadPool.ts +35 -0
- package/src/engine/combat/skeramImages.ts +57 -0
- package/src/engine/combat/slowUpdate.ts +178 -0
- package/src/engine/combat/snapToFloor.ts +42 -0
- package/src/engine/combat/speedAura.ts +29 -0
- package/src/engine/combat/spellReagents.ts +71 -0
- package/src/engine/combat/stealthDetect.ts +93 -0
- package/src/engine/combat/stepBackMotion.ts +171 -0
- package/src/engine/combat/stepSession.ts +3102 -0
- package/src/engine/combat/summonCast.ts +243 -0
- package/src/engine/combat/thaddiusPolarity.ts +97 -0
- package/src/engine/combat/threat.ts +388 -0
- package/src/engine/combat/threatOps.ts +272 -0
- package/src/engine/combat/tickWorkerBody.ts +211 -0
- package/src/engine/combat/totemAuras.ts +122 -0
- package/src/engine/combat/triggerPayload.ts +42 -0
- package/src/engine/combat/triggeredCompanion.ts +63 -0
- package/src/engine/combat/types.ts +1897 -0
- package/src/engine/combat/useObjectTrap.ts +140 -0
- package/src/engine/combat/walkSurface.ts +54 -0
- package/src/engine/combat/weapon.ts +1248 -0
- package/src/engine/combat/worldFrame.ts +43 -0
- package/src/engine/combat.solo-golden.json +2242 -0
- package/src/engine/combat.ts +84 -0
- package/src/engine/core/detMath.ts +289 -0
- package/src/engine/core/events.ts +357 -0
- package/src/engine/core/rng.ts +36 -0
- package/src/engine/game/actionBars.ts +124 -0
- package/src/engine/game/activateObjectFromSpell.ts +320 -0
- package/src/engine/game/applyActiveObjectFromDbScript.ts +98 -0
- package/src/engine/game/applyDeathAndSpellScripts.ts +268 -0
- package/src/engine/game/applyDoorFromDbScript.ts +388 -0
- package/src/engine/game/applyEscortFromDbScript.ts +290 -0
- package/src/engine/game/applyGoSpellcasterCast.ts +40 -0
- package/src/engine/game/applyHoverFromDbScript.ts +110 -0
- package/src/engine/game/applyOocResurrect.ts +71 -0
- package/src/engine/game/applySpellDurability.ts +115 -0
- package/src/engine/game/applyTailFromDbScript.ts +478 -0
- package/src/engine/game/applyTerminateFromDbScript.ts +95 -0
- package/src/engine/game/auctionMail.ts +269 -0
- package/src/engine/game/bagOverflow.ts +130 -0
- package/src/engine/game/bank.ts +110 -0
- package/src/engine/game/barAutofill.ts +26 -0
- package/src/engine/game/battleground.ts +369 -0
- package/src/engine/game/combatEnemySpec.ts +216 -0
- package/src/engine/game/combatObjectTraps.ts +167 -0
- package/src/engine/game/combatSeed.ts +320 -0
- package/src/engine/game/combatSession.ts +3241 -0
- package/src/engine/game/combatSessionClock.ts +62 -0
- package/src/engine/game/companionQuestActions.ts +242 -0
- package/src/engine/game/companionTraining.ts +40 -0
- package/src/engine/game/consumables.ts +624 -0
- package/src/engine/game/context.ts +53 -0
- package/src/engine/game/createHealthstone.ts +45 -0
- package/src/engine/game/deathFlow.ts +127 -0
- package/src/engine/game/devGrant.ts +117 -0
- package/src/engine/game/dynamicObject.ts +365 -0
- package/src/engine/game/enchantItem.ts +177 -0
- package/src/engine/game/equipmentHelper.ts +806 -0
- package/src/engine/game/factionState.ts +108 -0
- package/src/engine/game/garmentsOoc.ts +69 -0
- package/src/engine/game/globalActionBar.ts +179 -0
- package/src/engine/game/goInteractQuestCredit.ts +51 -0
- package/src/engine/game/goMoTransportUse.ts +61 -0
- package/src/engine/game/goTransportUse.ts +60 -0
- package/src/engine/game/healthstoneUpkeep.ts +59 -0
- package/src/engine/game/killSkin.ts +124 -0
- package/src/engine/game/learnFromItem.ts +118 -0
- package/src/engine/game/loadReconcile.ts +551 -0
- package/src/engine/game/loot.ts +294 -0
- package/src/engine/game/macros.ts +125 -0
- package/src/engine/game/mail.ts +293 -0
- package/src/engine/game/meetingStoneQueue.ts +75 -0
- package/src/engine/game/meetingStoneUse.ts +54 -0
- package/src/engine/game/newGameState.ts +363 -0
- package/src/engine/game/oocAuraInterrupt.ts +92 -0
- package/src/engine/game/oocCast.ts +241 -0
- package/src/engine/game/oocCastEffects.ts +803 -0
- package/src/engine/game/oocCastMethods.ts +1077 -0
- package/src/engine/game/openLockFromSpell.ts +174 -0
- package/src/engine/game/outdoorPvp.ts +187 -0
- package/src/engine/game/outfitter.ts +644 -0
- package/src/engine/game/paladinBoard.ts +393 -0
- package/src/engine/game/partyAuras.ts +121 -0
- package/src/engine/game/persistCombatAuras.ts +91 -0
- package/src/engine/game/pets.ts +459 -0
- package/src/engine/game/prePullHealthstones.ts +154 -0
- package/src/engine/game/professions.ts +2227 -0
- package/src/engine/game/pruneStaleRuntime.ts +39 -0
- package/src/engine/game/questFightCredit.ts +36 -0
- package/src/engine/game/questItemOnCorpse.ts +166 -0
- package/src/engine/game/questItemOnCreature.ts +103 -0
- package/src/engine/game/questLog.ts +879 -0
- package/src/engine/game/questSpellComplete.ts +21 -0
- package/src/engine/game/questTurnIn.ts +598 -0
- package/src/engine/game/raidParty.ts +32 -0
- package/src/engine/game/reagentBuyIn.ts +39 -0
- package/src/engine/game/regenRest.ts +186 -0
- package/src/engine/game/rotation.ts +425 -0
- package/src/engine/game/runFullDungeon.ts +611 -0
- package/src/engine/game/runSupply.ts +80 -0
- package/src/engine/game/sanitizeDriftedItems.ts +135 -0
- package/src/engine/game/scriptDevGoUse.ts +397 -0
- package/src/engine/game/scriptDevInnkeeper.ts +150 -0
- package/src/engine/game/scriptDevProfessionSpec.ts +99 -0
- package/src/engine/game/sendEventFromSpell.ts +87 -0
- package/src/engine/game/sortContainers.ts +85 -0
- package/src/engine/game/spellReputation.ts +69 -0
- package/src/engine/game/squad/squadDungeonSeating.ts +197 -0
- package/src/engine/game/squadDungeon.ts +4222 -0
- package/src/engine/game/squadQuestShare.ts +260 -0
- package/src/engine/game/summonObjectSlot.ts +69 -0
- package/src/engine/game/summonObjectWild.ts +73 -0
- package/src/engine/game/summoningRitualUse.ts +141 -0
- package/src/engine/game/talents.ts +356 -0
- package/src/engine/game/tamingRodUse.ts +66 -0
- package/src/engine/game/tempSpawnCreature.ts +136 -0
- package/src/engine/game/tickDungeonPatrolScripts.ts +205 -0
- package/src/engine/game/tickTimedQuests.ts +28 -0
- package/src/engine/game/trade.ts +586 -0
- package/src/engine/game/travel.ts +1155 -0
- package/src/engine/game/types.ts +1855 -0
- package/src/engine/game/vendorOffers.ts +244 -0
- package/src/engine/game/weaponImbueItem.ts +281 -0
- package/src/engine/game/worldSession.ts +183 -0
- package/src/engine/game/worldState.ts +166 -0
- package/src/engine/game.ts +968 -0
- package/src/engine/spells/GAP.md +67 -0
- package/src/engine/spells/absorbs.ts +376 -0
- package/src/engine/spells/addTargetTrigger.ts +53 -0
- package/src/engine/spells/auraPerSecondCost.ts +50 -0
- package/src/engine/spells/auraStacking.ts +497 -0
- package/src/engine/spells/cmangos-special-cases-combustion.ts +13 -0
- package/src/engine/spells/cmangos-special-cases-s18.ts +445 -0
- package/src/engine/spells/cmangos-special-cases-s19.ts +324 -0
- package/src/engine/spells/cmangos-special-cases-s20.ts +385 -0
- package/src/engine/spells/cmangos-special-cases-s21.ts +116 -0
- package/src/engine/spells/cmangos-special-cases-s22.ts +24 -0
- package/src/engine/spells/cmangos-special-cases-s23.ts +52 -0
- package/src/engine/spells/cmangos-special-cases.ts +1570 -0
- package/src/engine/spells/conditions.ts +718 -0
- package/src/engine/spells/effects.ts +203 -0
- package/src/engine/spells/fixtures/oracle-effects-lock9a.json +1 -0
- package/src/engine/spells/immunity.ts +942 -0
- package/src/engine/spells/intentEffects.ts +351 -0
- package/src/engine/spells/interpreter/GAPS.md +21 -0
- package/src/engine/spells/interpreter/amount.ts +109 -0
- package/src/engine/spells/interpreter/class-scripts-kit.ts +478 -0
- package/src/engine/spells/interpreter/diff.ts +246 -0
- package/src/engine/spells/interpreter/duration.ts +90 -0
- package/src/engine/spells/interpreter/form-attrs.json +1 -0
- package/src/engine/spells/interpreter/form-attrs.ts +95 -0
- package/src/engine/spells/interpreter/handlers/aura-remove.ts +113 -0
- package/src/engine/spells/interpreter/handlers/auras-control.ts +1547 -0
- package/src/engine/spells/interpreter/handlers/auras-core.ts +1583 -0
- package/src/engine/spells/interpreter/handlers/auras-misc.ts +1774 -0
- package/src/engine/spells/interpreter/handlers/effect-disenchant.ts +17 -0
- package/src/engine/spells/interpreter/handlers/effect-skinning.ts +20 -0
- package/src/engine/spells/interpreter/handlers/effects-action-utility.ts +1455 -0
- package/src/engine/spells/interpreter/handlers/effects-core.ts +432 -0
- package/src/engine/spells/interpreter/handlers/effects-remainder.ts +105 -0
- package/src/engine/spells/interpreter/handlers/effects-summon-item.ts +687 -0
- package/src/engine/spells/interpreter/handlers/mod-increase-energy-percent.ts +36 -0
- package/src/engine/spells/interpreter/handlers/mod-increase-energy.ts +36 -0
- package/src/engine/spells/interpreter/handlers/periodic-trigger-zero.ts +327 -0
- package/src/engine/spells/interpreter/handlers/procs.ts +131 -0
- package/src/engine/spells/interpreter/handlers/registry.ts +975 -0
- package/src/engine/spells/interpreter/handlers/shared.ts +667 -0
- package/src/engine/spells/interpreter/handlers.ts +9 -0
- package/src/engine/spells/interpreter/hunter-kit.ts +202 -0
- package/src/engine/spells/interpreter/index.ts +525 -0
- package/src/engine/spells/interpreter/item-triggers.ts +1121 -0
- package/src/engine/spells/interpreter/lift-consume-energy.ts +37 -0
- package/src/engine/spells/interpreter/mage-portals.ts +37 -0
- package/src/engine/spells/interpreter/materialize-charm.ts +88 -0
- package/src/engine/spells/interpreter/materialize-dispel-mechanic.ts +25 -0
- package/src/engine/spells/interpreter/materialize-rating-buffs.ts +81 -0
- package/src/engine/spells/interpreter/materialize-speed.ts +109 -0
- package/src/engine/spells/interpreter/materialize-spell-magnet.ts +22 -0
- package/src/engine/spells/interpreter/opcodes.ts +789 -0
- package/src/engine/spells/interpreter/paladin-kit.ts +95 -0
- package/src/engine/spells/interpreter/paladin-seals.ts +499 -0
- package/src/engine/spells/interpreter/priest-warlock-kit.ts +135 -0
- package/src/engine/spells/interpreter/proc.ts +443 -0
- package/src/engine/spells/interpreter/registry/helpers.ts +16 -0
- package/src/engine/spells/interpreter/registry/index.ts +216 -0
- package/src/engine/spells/interpreter/registry/packs/class-abilities.ts +304 -0
- package/src/engine/spells/interpreter/registry/packs/dire-maul.ts +44 -0
- package/src/engine/spells/interpreter/registry/packs/dummy-script-tail.ts +1065 -0
- package/src/engine/spells/interpreter/registry/packs/ex-wave-m.ts +70 -0
- package/src/engine/spells/interpreter/registry/packs/life-tap.ts +107 -0
- package/src/engine/spells/interpreter/registry/packs/paladin.ts +246 -0
- package/src/engine/spells/interpreter/registry/packs/raid-script-summons.ts +26 -0
- package/src/engine/spells/interpreter/registry/packs/raid-threat-wipe.ts +22 -0
- package/src/engine/spells/interpreter/registry/packs/shaman.ts +214 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-a.ts +269 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-b.ts +370 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-generic-c.ts +172 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-hunter.ts +60 -0
- package/src/engine/spells/interpreter/registry/packs/si4-dummy-warrior.ts +31 -0
- package/src/engine/spells/interpreter/registry/packs/si4-script-generic-a.ts +114 -0
- package/src/engine/spells/interpreter/registry/packs/si4-script-generic-b.ts +249 -0
- package/src/engine/spells/interpreter/registry/packs/si4-script-generic-c.ts +233 -0
- package/src/engine/spells/interpreter/registry/types.ts +48 -0
- package/src/engine/spells/interpreter/talent-scripted.ts +329 -0
- package/src/engine/spells/interpreter/targeting.ts +90 -0
- package/src/engine/spells/interpreter/types-intent.ts +1501 -0
- package/src/engine/spells/interpreter/types.ts +419 -0
- package/src/engine/spells/interpreter/warrior-druid-kit.ts +314 -0
- package/src/engine/spells/interpreter/weapon-imbue-kit.ts +384 -0
- package/src/engine/spells/interrupt.ts +217 -0
- package/src/engine/spells/meleeAttackReset.ts +60 -0
- package/src/engine/spells/oocTriggerSpell.ts +49 -0
- package/src/engine/spells/overrideClassScripts.ts +319 -0
- package/src/engine/spells/parryHaste.ts +20 -0
- package/src/engine/spells/procs.ts +300 -0
- package/src/engine/spells/recovery.ts +203 -0
- package/src/engine/spells/scriptTargetDest.ts +76 -0
- package/src/engine/spells/scriptTargets.ts +85 -0
- package/src/engine/spells/singleTargetAura.ts +82 -0
- package/src/engine/spells/specialCasePayloads.ts +57 -0
- package/src/engine/spells/spellAttributes.ts +249 -0
- package/src/engine/spells/spellBonus.ts +143 -0
- package/src/engine/spells/spellModIntents.ts +419 -0
- package/src/engine/spells/spellMods.ts +283 -0
- package/src/engine/spells/spellRadius.ts +15 -0
- package/src/engine/spells/trapObjects.ts +26 -0
- package/src/json.d.ts +7 -0
- package/src/persistence/memoryRepository.ts +23 -0
- package/src/persistence/saveBackups.ts +162 -0
- package/src/persistence/saveFile.ts +68 -0
- package/src/persistence/saveRepository.ts +11 -0
- package/src/persistence/saveShape.ts +668 -0
- package/src/progression/leveling.ts +89 -0
- package/src/progression/rest.ts +1434 -0
- package/src/progression/training.ts +226 -0
- package/src/progression/xpGain.ts +369 -0
- package/src/quests/quests.ts +1580 -0
- package/src/world/aoePull.ts +198 -0
- package/src/world/areaTableDbc.ts +39 -0
- package/src/world/areaTableMapIdPairs.ts +141 -0
- package/src/world/bakeSlug.ts +43 -0
- package/src/world/continuousDungeon.ts +1250 -0
- package/src/world/dungeonBakeProjection.ts +190 -0
- package/src/world/dungeonMapDensity.ts +342 -0
- package/src/world/dungeonMapProjection.ts +135 -0
- package/src/world/dungeonNav.ts +904 -0
- package/src/world/dungeonNpcSim.ts +147 -0
- package/src/world/dungeonObjectives.ts +234 -0
- package/src/world/dungeonPreset.ts +35 -0
- package/src/world/dungeonRoute.ts +311 -0
- package/src/world/dungeonRouteGeometry.ts +732 -0
- package/src/world/dungeonRouteOrder.ts +1037 -0
- package/src/world/dungeonWingRooms.ts +426 -0
- package/src/world/escort.ts +296 -0
- package/src/world/goPoolRoll.ts +75 -0
- package/src/world/instanceObjects.ts +785 -0
- package/src/world/instances.ts +1572 -0
- package/src/world/meetingStones.ts +56 -0
- package/src/world/navmeshFloors.ts +145 -0
- package/src/world/nearGrid.ts +167 -0
- package/src/world/packBudget.ts +613 -0
- package/src/world/rareRoll.ts +335 -0
- package/src/world/routeAuthoring.ts +132 -0
- package/src/world/routeEdit.ts +209 -0
- package/src/world/world.ts +812 -0
- package/src/world/zoneHazards.ts +47 -0
|
@@ -0,0 +1,3169 @@
|
|
|
1
|
+
import { SPIRIT_OF_REDEMPTION_AURA_SPELL_ID } from '../spells/cmangos-special-cases.ts';
|
|
2
|
+
// Peeled from createSession (Wave 4 hub: applySpell). Pattern (D). Extracted modules must not import
|
|
3
|
+
// combat.ts. NOTE: imports may need expanding when first run against seam tests.
|
|
4
|
+
|
|
5
|
+
import type { CombatContext } from './context.ts';
|
|
6
|
+
import { applySpellEffectLate } from './applySpellLate.ts';
|
|
7
|
+
import { bindLateEffectBag } from './applySpellLateBag.ts';
|
|
8
|
+
import { applySchoolAbsorbEffect, applyInstakillEffect } from './applySchoolAbsorb.ts';
|
|
9
|
+
import { runCastSummons } from './summonCast.ts';
|
|
10
|
+
import { upgradeBreakOnDamageMirror } from './ccMirrorUpgrade.ts';
|
|
11
|
+
import {
|
|
12
|
+
beginChannel,
|
|
13
|
+
channelInterruptCtx,
|
|
14
|
+
interruptChannel,
|
|
15
|
+
isChannelSpell,
|
|
16
|
+
} from './channel.ts';
|
|
17
|
+
import {
|
|
18
|
+
wantsRaidAndClass, filterSameClass, filterPartyFanByRadius, isPartyFanBody, sameSubgroup,
|
|
19
|
+
auraHolderIsCastTarget, auraHolderExcludesCaster, auraRadiatesToParty, selectedOrSelfFriendly,
|
|
20
|
+
} from './healTargets.ts';
|
|
21
|
+
import { spellBuffApplyToFromRaw } from '../spells/interpreter/targeting.ts';
|
|
22
|
+
import type { Fighter, ActiveEffect } from './types.ts';
|
|
23
|
+
import type { SpellDef, SpellEffect, School, BuffMod, ControlKind, Mechanic } from '../../data/schema.ts';
|
|
24
|
+
import type { Side, HitResult } from '../core/events.ts';
|
|
25
|
+
import { isEnemySide, isAllySide, isTotemSide, isPlayerCharacterSide } from '../core/events.ts';
|
|
26
|
+
import {
|
|
27
|
+
TUNING, rollBetween, meleeBaseDamage, spellCritChance, critMultiplierWithVersus,
|
|
28
|
+
} from './formulas.ts';
|
|
29
|
+
import { schoolMaskFromSchool } from '../spells/procs.ts';
|
|
30
|
+
import { resolveCombatSpell } from '../spells/interpreter/index.ts';
|
|
31
|
+
import { applyConsumeAuraEffect, targetAurasFromLive } from './applyConsumeAura.ts';
|
|
32
|
+
import { AURA_INTERRUPT } from '../../domain/spells/stealth.ts';
|
|
33
|
+
import { BASE_RUN_SPEED, effectiveSpeeds } from '../../domain/nav/speed.ts';
|
|
34
|
+
import { pickBattleRezTarget, battleRezPayload, reviveInCombat } from './battleRez.ts';
|
|
35
|
+
import { durMs } from '../spells/interpreter/duration.ts';
|
|
36
|
+
import { flametongueSwingDamage } from '../spells/interpreter/weapon-imbue-kit.ts';
|
|
37
|
+
import {
|
|
38
|
+
FLAMETONGUE_TOTEM_PROC_RANKS,
|
|
39
|
+
FLAMETONGUE_WEAPON_PROC_RANKS,
|
|
40
|
+
} from '../spells/interpreter/registry/packs/shaman.ts';
|
|
41
|
+
import { DIRE_BEAR_FORM_SPELL_ID } from '../spells/interpreter/registry/packs/class-abilities.ts';
|
|
42
|
+
import { isAttackSpeedOverriddenShapeShift } from '../../domain/character/forms.ts';
|
|
43
|
+
import { JUDGEMENT_OF_COMMAND_DAMAGE_IDS } from '../spells/interpreter/paladin-seals.ts';
|
|
44
|
+
import { fighterSpellCrit, applyBuffMod, reverseBuffMod, signedAtLeastOne } from './fighterFactory.ts';
|
|
45
|
+
import {
|
|
46
|
+
applyOwnedSpellMod,
|
|
47
|
+
SPELLMOD_CRITICAL_CHANCE, SPELLMOD_DAMAGE, SPELLMOD_DOT,
|
|
48
|
+
SPELLMOD_DURATION, SPELLMOD_ALL_EFFECTS,
|
|
49
|
+
} from '../spells/spellModIntents.ts';
|
|
50
|
+
import { improvedSprintShouldProc } from '../spells/cmangos-special-cases-s21.ts';
|
|
51
|
+
import {
|
|
52
|
+
SPELL_NEGATIVE_CHARGE,
|
|
53
|
+
SPELL_NEGATIVE_CHARGE_BUFF,
|
|
54
|
+
SPELL_POSITIVE_CHARGE,
|
|
55
|
+
SPELL_POSITIVE_CHARGE_BUFF,
|
|
56
|
+
countSameChargeNear,
|
|
57
|
+
isPolarityShift,
|
|
58
|
+
pickPolarityCharge,
|
|
59
|
+
polarityBuffForCharge,
|
|
60
|
+
polarityDamageSkipped,
|
|
61
|
+
polarityStackDue,
|
|
62
|
+
polarityStackSpell,
|
|
63
|
+
} from './thaddiusPolarity.ts';
|
|
64
|
+
|
|
65
|
+
/** Aura::isAffectedOnSpell for aura 109. Cite: SpellAuras.cpp:696–713; SpellMgr.h:1950–1958. */
|
|
66
|
+
function addTargetTriggerAffects(
|
|
67
|
+
row: { familyName: number; familyFlags: number; triggerSpellId: number },
|
|
68
|
+
spell: SpellDef,
|
|
69
|
+
): boolean {
|
|
70
|
+
if ((spell.id | 0) === (row.triggerSpellId | 0)) return false;
|
|
71
|
+
if (((spell.spellFamilyName ?? 0) | 0) !== (row.familyName | 0)) return false;
|
|
72
|
+
const mask = row.familyFlags >>> 0;
|
|
73
|
+
if (!mask) return false;
|
|
74
|
+
return ((Number(spell.spellFamilyFlags ?? 0) >>> 0) & mask) !== 0;
|
|
75
|
+
}
|
|
76
|
+
import { roleOf } from './roles.ts';
|
|
77
|
+
import {
|
|
78
|
+
isHostileSpellDef,
|
|
79
|
+
isDrainSoulSpell,
|
|
80
|
+
spellTargetTypeAllowed,
|
|
81
|
+
normalizeDivineIntervention,
|
|
82
|
+
normalizeIceBlock,
|
|
83
|
+
isIceBlockSpell,
|
|
84
|
+
isDivineInterventionSpell,
|
|
85
|
+
isDivineInterventionCast,
|
|
86
|
+
isHostileBuffEffect,
|
|
87
|
+
isSelfTargetedDot,
|
|
88
|
+
isSelfTargetedSpell,
|
|
89
|
+
isFoeLandingLateEffect,
|
|
90
|
+
spellWantsFriendlyTarget,
|
|
91
|
+
} from './predicates.ts';
|
|
92
|
+
import { isHostileSpeedEffect } from './speedAura.ts';
|
|
93
|
+
import { applySpellDamageBonusIfPresent } from '../spells/spellBonus.ts';
|
|
94
|
+
import { sumVersusForCreatureType } from '../../domain/character/characterStats.ts';
|
|
95
|
+
import {
|
|
96
|
+
selectMagnetTarget,
|
|
97
|
+
isMagnetRedirectableSpell,
|
|
98
|
+
magnetCandidatesOnVictim,
|
|
99
|
+
} from '../spells/absorbs.ts';
|
|
100
|
+
import {
|
|
101
|
+
isImmuneToMechanic,
|
|
102
|
+
isImmuneToMechanicId,
|
|
103
|
+
isImmuneToHarmfulAura,
|
|
104
|
+
isTotemCreatureType,
|
|
105
|
+
totemRefusesNegativeAuraApply,
|
|
106
|
+
isEveryEffectSlotImmune,
|
|
107
|
+
isImmuneToSpellEffectSlot,
|
|
108
|
+
AURA_TYPE,
|
|
109
|
+
MECHANIC,
|
|
110
|
+
SPELL_SCHOOL_MASK_ALL,
|
|
111
|
+
schoolMaskFromSchoolName,
|
|
112
|
+
ccImmunityRiderMaskForSpell,
|
|
113
|
+
mechanicIdFromName,
|
|
114
|
+
makeCcImmunityAura,
|
|
115
|
+
immunityBoostAurasForSpell,
|
|
116
|
+
} from '../spells/immunity.ts';
|
|
117
|
+
import {
|
|
118
|
+
SPELL_ATTR, SPELL_ATTR_EX, SPELL_ATTR_EX3, SPELL_ATTR_EX4,
|
|
119
|
+
hasAttr, hasAttrEx, hasAttrEx3, hasAttrEx4, spellCanCrit, spellPiercesSchoolImmunity,
|
|
120
|
+
} from '../spells/spellAttributes.ts';
|
|
121
|
+
import { armHeartbeatResist } from './heartbeatResist.ts';
|
|
122
|
+
import { laySpellTrapSites } from './useObjectTrap.ts';
|
|
123
|
+
import { drStepOf } from './crowdControl.ts';
|
|
124
|
+
import {
|
|
125
|
+
isResistableDmgClass,
|
|
126
|
+
resolveSpellBinary,
|
|
127
|
+
spellDamageLandChance,
|
|
128
|
+
SPELL_DAMAGE_CLASS,
|
|
129
|
+
} from './mitigation.ts';
|
|
130
|
+
import {
|
|
131
|
+
resolveHealMaxHealth,
|
|
132
|
+
resolvePowerDrain,
|
|
133
|
+
resolvePowerBurn,
|
|
134
|
+
resolveHealthLeech,
|
|
135
|
+
resolveEnergize,
|
|
136
|
+
resolveAddExtraAttacks,
|
|
137
|
+
resolveWeaponPercentDamage,
|
|
138
|
+
POWER_MANA,
|
|
139
|
+
} from '../spells/effects.ts';
|
|
140
|
+
import {
|
|
141
|
+
planAuraApply, auraHolderCharges, resolveChainPair,
|
|
142
|
+
auraStackIdentity, existingStackIsStronger, stackIdentitiesExclude,
|
|
143
|
+
} from '../spells/auraStacking.ts';
|
|
144
|
+
import {
|
|
145
|
+
BROOD_AFFLICTION_BRONZE_SPELL_ID,
|
|
146
|
+
captureSpiritAllowsEntry,
|
|
147
|
+
HOURGLASS_SAND_SPELL_ID,
|
|
148
|
+
ONYXIA_SCALE_CLOAK_SPELL_ID,
|
|
149
|
+
SHADOW_FLAME_SPELL_ID,
|
|
150
|
+
shadowFlameAppliesDot,
|
|
151
|
+
} from '../spells/cmangos-special-cases.ts';
|
|
152
|
+
import {
|
|
153
|
+
BRITTLE_ARMOR_PEEL_SPELL_ID,
|
|
154
|
+
BRITTLE_ARMOR_STACK_SPELL_ID,
|
|
155
|
+
expandHakkarBloodSiphonFunnel,
|
|
156
|
+
HAKKAR_BLOOD_SIPHON_FUNNEL_SPELL_ID,
|
|
157
|
+
HAKKAR_BLOOD_SIPHON_POISON_SPELL_ID,
|
|
158
|
+
HAKKAR_BLOOD_SIPHON_SPELL_ID,
|
|
159
|
+
POISONOUS_BLOOD_CLOUD_SPELL_ID,
|
|
160
|
+
} from '../spells/cmangos-special-cases-s18.ts';
|
|
161
|
+
import {
|
|
162
|
+
MERCURIAL_SHIELD_PEEL_SPELL_ID,
|
|
163
|
+
MERCURIAL_SHIELD_STACK_SPELL_ID,
|
|
164
|
+
} from '../spells/cmangos-special-cases-s19.ts';
|
|
165
|
+
import {
|
|
166
|
+
MAGIC_ABSORPTION_TRIGGER_SPELL_ID,
|
|
167
|
+
magicAbsorptionManaAmount,
|
|
168
|
+
} from '../spells/cmangos-special-cases-s20.ts';
|
|
169
|
+
import {
|
|
170
|
+
FAERLINA_ENRAGE_SPELL_ID,
|
|
171
|
+
WIDOWS_EMBRACE_CD_SPELL_ID,
|
|
172
|
+
WIDOWS_EMBRACE_INSTAKILL_SPELL_ID,
|
|
173
|
+
WIDOWS_EMBRACE_SPELL_ID,
|
|
174
|
+
widowsEmbraceLockoutMs,
|
|
175
|
+
} from '../spells/cmangos-special-cases-s22.ts';
|
|
176
|
+
import {
|
|
177
|
+
CHROMAGGUS_BREATH_SELECTION_SPELL_ID,
|
|
178
|
+
ELEMENTAL_SHIELD_DUMMY_SPELL_ID,
|
|
179
|
+
pickChromaggusBreathPair,
|
|
180
|
+
TYPE_CHROMA_LBREATH,
|
|
181
|
+
TYPE_CHROMA_RBREATH,
|
|
182
|
+
} from '../spells/cmangos-special-cases-s23.ts';
|
|
183
|
+
import { buffProcChargesForSpell, refuseCmangosCheckCast, RECENTLY_BANDAGED_SPELL_ID } from './cmangosCastGates.ts';
|
|
184
|
+
import { isPlayerCharacterTarget } from './castTargetGates.ts';
|
|
185
|
+
import {
|
|
186
|
+
CORRUPTED_MIND_PRIMER_SPELL_ID,
|
|
187
|
+
corruptedMindLockoutSpellId,
|
|
188
|
+
overrideScriptAuraTargetIsCaster,
|
|
189
|
+
overrideScriptsOnSpell,
|
|
190
|
+
refuseOverrideClassScriptCheckCast,
|
|
191
|
+
unitHasOverrideScript,
|
|
192
|
+
} from '../spells/overrideClassScripts.ts';
|
|
193
|
+
import { refuseForMissingCorpse } from '../spells/scriptTargets.ts';
|
|
194
|
+
import { isReflectableSpell, rollSpellReflect } from '../../domain/spells/spellReflect.ts';
|
|
195
|
+
import {
|
|
196
|
+
SAP_SPELL_IDS,
|
|
197
|
+
IMPROVED_SAP_TRIGGER_SPELL_ID,
|
|
198
|
+
improvedSapHolderFromKnown,
|
|
199
|
+
procChanceUnitFromRow,
|
|
200
|
+
SWIFTMEND_SPELL_ID,
|
|
201
|
+
hasSwiftmendHot,
|
|
202
|
+
shatterFrozenCritBonus,
|
|
203
|
+
targetIsFrozenForShatter,
|
|
204
|
+
effectsWithScriptRiders,
|
|
205
|
+
} from '../spells/interpreter/class-scripts-kit.ts';
|
|
206
|
+
import {
|
|
207
|
+
areaAuraCarrierSides, hasAreaAuraRow, stripAreaAuraRowsFrom, stripHolderRowsInGroup,
|
|
208
|
+
} from './areaAura.ts';
|
|
209
|
+
import { isPartyAreaAura } from '../../domain/spells/areaAuraMembership.ts';
|
|
210
|
+
import {
|
|
211
|
+
createGroundPool, hasLaterPersistentAreaOrFarsight, isPersistentAreaEffect, upsertGroundPool,
|
|
212
|
+
} from '../game/dynamicObject.ts';
|
|
213
|
+
import { spellAoeFromCaster } from './combatGeometry.ts';
|
|
214
|
+
import { isCastInterruptible, applySchoolLockout, interruptEffectMechanicId } from '../spells/interrupt.ts';
|
|
215
|
+
import { isWarriorExecuteSpell } from '../spells/interpreter/warrior-druid-kit.ts';
|
|
216
|
+
import { mechanicResistBonus } from '../../domain/creature/creatureAiConstants.ts';
|
|
217
|
+
import { clearCombatFlag } from './combatFlag.ts';
|
|
218
|
+
import { isHunterTrapTriggerSpellId } from '../spells/interpreter/hunter-kit.ts';
|
|
219
|
+
import { trapObjectPoolFor } from '../spells/trapObjects.ts';
|
|
220
|
+
import { emitSimEffect } from './simEffects.ts';
|
|
221
|
+
|
|
222
|
+
/** One clock per SpellAuraHolder. CC owns it when present so Blind's snare does not. Cite: Spell.cpp:1483-1489. */
|
|
223
|
+
function heartbeatSlotOf(spell: SpellDef): 'cc' | 'root' | 'speed' | null {
|
|
224
|
+
if (!hasAttr(spell, SPELL_ATTR.HEARTBEAT_RESIST)) return null;
|
|
225
|
+
let cc = false;
|
|
226
|
+
let root = false;
|
|
227
|
+
let speed = false;
|
|
228
|
+
for (const e of spell.effects) {
|
|
229
|
+
if (e.kind === 'cc') cc = true;
|
|
230
|
+
else if (e.kind === 'root') root = true;
|
|
231
|
+
else if (e.kind === 'speed') speed = true;
|
|
232
|
+
}
|
|
233
|
+
if (cc) return 'cc';
|
|
234
|
+
if (root) return 'root';
|
|
235
|
+
if (speed) return 'speed';
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type installApplySpellDeps = {
|
|
240
|
+
dead: (...args: any[]) => any;
|
|
241
|
+
sanitizeHp: (...args: any[]) => any;
|
|
242
|
+
spend: (...args: any[]) => any;
|
|
243
|
+
gainRage: (...args: any[]) => any;
|
|
244
|
+
addComboPoints: (...args: any[]) => any;
|
|
245
|
+
clearComboPoints: (...args: any[]) => any;
|
|
246
|
+
comboOn: (...args: any[]) => any;
|
|
247
|
+
scaleDurationByCp: (...args: any[]) => any;
|
|
248
|
+
spellCost: (...args: any[]) => any;
|
|
249
|
+
canAffordSpell: (...args: any[]) => any;
|
|
250
|
+
leaveForm: (...args: any[]) => any;
|
|
251
|
+
enterForm: (...args: any[]) => any;
|
|
252
|
+
applyStealth: (...args: any[]) => any;
|
|
253
|
+
breakStealth: (...args: any[]) => any;
|
|
254
|
+
maybeBreakStealthOn: (...args: any[]) => any;
|
|
255
|
+
behindOk: (...args: any[]) => any;
|
|
256
|
+
foeOf: (...args: any[]) => any;
|
|
257
|
+
ensurePlayerFocus: (...args: any[]) => any;
|
|
258
|
+
lowestHpFriendly: (...args: any[]) => any;
|
|
259
|
+
isControlled: (...args: any[]) => any;
|
|
260
|
+
isControlledHard: (...args: any[]) => any;
|
|
261
|
+
isWeaponAction: (...args: any[]) => any;
|
|
262
|
+
isBreaker: (...args: any[]) => any;
|
|
263
|
+
spellTemplateImmune: (...args: any[]) => any;
|
|
264
|
+
ccImmune: (...args: any[]) => any;
|
|
265
|
+
applyDr: (...args: any[]) => any;
|
|
266
|
+
applyControl: (...args: any[]) => any;
|
|
267
|
+
breakControl: (...args: any[]) => any;
|
|
268
|
+
onDamaged: (...args: any[]) => any;
|
|
269
|
+
rollHeal: (...args: any[]) => any;
|
|
270
|
+
unsummonTotem: (...args: any[]) => any;
|
|
271
|
+
checkDeaths: (...args: any[]) => any;
|
|
272
|
+
finalize: (...args: any[]) => any;
|
|
273
|
+
seedDamageShields: (...args: any[]) => any;
|
|
274
|
+
seedProcTriggers: (...args: any[]) => any;
|
|
275
|
+
seedSchoolAbsorbs: (...args: any[]) => any;
|
|
276
|
+
seedManaShields: (...args: any[]) => any;
|
|
277
|
+
seedRestoreHealth: (...args: any[]) => any;
|
|
278
|
+
seedDisplayAuras: (...args: any[]) => any;
|
|
279
|
+
sealRighteousnessSwing: (...args: any[]) => any;
|
|
280
|
+
materializeSpecForCombat: (...args: any[]) => any;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export function installApplySpell(ctx: CombatContext, deps: installApplySpellDeps): void {
|
|
284
|
+
const push = (e: any) => ctx.push(e);
|
|
285
|
+
const player = ctx.player;
|
|
286
|
+
const pet = ctx.pet;
|
|
287
|
+
const effects = ctx.effects;
|
|
288
|
+
const groundPools = ctx.groundPools;
|
|
289
|
+
const touchEffect = (e: ActiveEffect): void => { ctx.touchEffect(e); };
|
|
290
|
+
const rng = ctx.rng;
|
|
291
|
+
const tickMs = ctx.tickMs;
|
|
292
|
+
const cfg = ctx.cfg;
|
|
293
|
+
const threatOf = ctx.threatOf;
|
|
294
|
+
const livingEnemies = ctx.livingEnemies;
|
|
295
|
+
const livingFriendlies = ctx.livingFriendlies;
|
|
296
|
+
const friendlyPoolOf = ctx.friendlyPoolOf;
|
|
297
|
+
const lowestHpFriendlyOf = ctx.lowestHpFriendlyOf;
|
|
298
|
+
const livingHostilesFor = ctx.livingHostilesFor;
|
|
299
|
+
const distanceBetween = ctx.distanceBetween;
|
|
300
|
+
const fightersOrder = ctx.fightersOrder;
|
|
301
|
+
const isCharmed = ctx.isCharmed;
|
|
302
|
+
const bySide = ctx.bySide;
|
|
303
|
+
const registerAreaAuraSource = (...args: any[]) => (ctx.registerAreaAuraSource as Function)(...args);
|
|
304
|
+
/** Same-tick bounce guard for Gothik 29317 scriptValue=1. Cite: boss_gothik.cpp:520. */
|
|
305
|
+
const gothikShadowboltBounceAt = new WeakMap<Fighter, number>();
|
|
306
|
+
/** Initial-cast fan for Renataki 24649. Cite: boss_renataki.cpp:98 m_IsTriggeredSpell. */
|
|
307
|
+
const renatakiBladesFanAt = new WeakMap<Fighter, number>();
|
|
308
|
+
/** First mana player hit by this 29107 tick. Cite: boss_razuvious.cpp:90 m_oneHit. */
|
|
309
|
+
const razuviousShoutAt = new WeakMap<Fighter, number>();
|
|
310
|
+
/** 28673 picks 3 skip-tank players once per cast. Cite: boss_maexxna.cpp:211-223. */
|
|
311
|
+
const maexxnaWrapAt = new WeakMap<Fighter, number>();
|
|
312
|
+
/** One chow per 28236 tick. Cite: boss_gluth.cpp:92-99 m_zombieChow. */
|
|
313
|
+
const gluthChowAt = new WeakMap<Fighter, number>();
|
|
314
|
+
/** Per-player stacks of 28832-35. Cite: boss_four_horsemen.cpp:388-411 GetStackAmount. */
|
|
315
|
+
const horsemenMarkStacks = new WeakMap<Fighter, Map<number, number>>();
|
|
316
|
+
const reverseActiveEffect = (unit: Fighter, e: ActiveEffect) => (ctx.reverseActiveEffect as Function)(unit, e);
|
|
317
|
+
const applyFlatThreat = (...args: any[]) => (ctx.applyFlatThreat as Function)(...args);
|
|
318
|
+
const applySpellThreatBonus = (...args: any[]) => (ctx.applySpellThreatBonus as Function)(...args);
|
|
319
|
+
const engageOnHostileSpell = (...args: any[]) => (ctx.engageOnHostileSpell as Function)(...args);
|
|
320
|
+
const applyHealThreat = (...args: any[]) => (ctx.applyHealThreat as Function)(...args);
|
|
321
|
+
const grantImmunityAura = (...args: any[]) => (ctx.grantImmunityAura as Function)(...args);
|
|
322
|
+
const dispelTypeOfSpell = (...args: any[]) => (ctx.dispelTypeOfSpell as Function)(...args);
|
|
323
|
+
const dispellableAurasOn = (...args: any[]) => (ctx.dispellableAurasOn as Function)(...args);
|
|
324
|
+
const reflectChanceFor = (...args: any[]) => (ctx.reflectChanceFor as Function)(...args);
|
|
325
|
+
const stripAuraOnDispel = (...args: any[]) => (ctx.stripAuraOnDispel as Function)(...args);
|
|
326
|
+
const resolveDispelTarget = (...args: any[]) => (ctx.resolveDispelTarget as Function)(...args);
|
|
327
|
+
const dealDamage = (...args: any[]) => (ctx.dealDamage as Function)(...args);
|
|
328
|
+
const startOrCast = (...args: any[]) => (ctx.startOrCast as Function)(...args);
|
|
329
|
+
const step = (...args: any[]) => (ctx.step as Function)(...args);
|
|
330
|
+
const triggerProcs = (...args: any[]) => (ctx.triggerProcs as Function)(...args);
|
|
331
|
+
const applyProc = (...args: any[]) => (ctx.applyProc as Function)(...args);
|
|
332
|
+
const rollGearProcs = (...args: any[]) => (ctx.rollGearProcs as Function)(...args);
|
|
333
|
+
const fireDamageShields = (...args: any[]) => (ctx.fireDamageShields as Function)(...args);
|
|
334
|
+
const fireDealMeleeAura42 = (...args: any[]) => (ctx.fireDealMeleeAura42 as Function)(...args);
|
|
335
|
+
const powerOfType = (...args: any[]) => (ctx.powerOfType as Function)(...args);
|
|
336
|
+
const maxPowerOfType = (...args: any[]) => (ctx.maxPowerOfType as Function)(...args);
|
|
337
|
+
const setPowerOfType = (...args: any[]) => (ctx.setPowerOfType as Function)(...args);
|
|
338
|
+
const casterPowerTypeId = (...args: any[]) => (ctx.casterPowerTypeId as Function)(...args);
|
|
339
|
+
const powerTypeName = (...args: any[]) => (ctx.powerTypeName as Function)(...args);
|
|
340
|
+
// Ground-pool id counter lives in createSession (ctx.nextGroundPoolId bumps it).
|
|
341
|
+
const nextGroundPoolId = () => (ctx.nextGroundPoolId as () => number)();
|
|
342
|
+
const dead = deps.dead;
|
|
343
|
+
const sanitizeHp = deps.sanitizeHp;
|
|
344
|
+
const spend = deps.spend;
|
|
345
|
+
const gainRage = deps.gainRage;
|
|
346
|
+
const addComboPoints = deps.addComboPoints;
|
|
347
|
+
const clearComboPoints = deps.clearComboPoints;
|
|
348
|
+
const comboOn = deps.comboOn;
|
|
349
|
+
const scaleDurationByCp = deps.scaleDurationByCp;
|
|
350
|
+
const spellCost = deps.spellCost;
|
|
351
|
+
const canAffordSpell = deps.canAffordSpell;
|
|
352
|
+
const leaveForm = deps.leaveForm;
|
|
353
|
+
const enterForm = deps.enterForm;
|
|
354
|
+
const applyStealth = deps.applyStealth;
|
|
355
|
+
const breakStealth = deps.breakStealth;
|
|
356
|
+
const maybeBreakStealthOn = deps.maybeBreakStealthOn;
|
|
357
|
+
const behindOk = deps.behindOk;
|
|
358
|
+
const foeOf = deps.foeOf;
|
|
359
|
+
const ensurePlayerFocus = deps.ensurePlayerFocus;
|
|
360
|
+
const lowestHpFriendly = deps.lowestHpFriendly;
|
|
361
|
+
const isControlled = deps.isControlled;
|
|
362
|
+
const isControlledHard = deps.isControlledHard;
|
|
363
|
+
const isWeaponAction = deps.isWeaponAction;
|
|
364
|
+
const isBreaker = deps.isBreaker;
|
|
365
|
+
const spellTemplateImmune = deps.spellTemplateImmune;
|
|
366
|
+
const ccImmune = deps.ccImmune;
|
|
367
|
+
const applyDr = deps.applyDr;
|
|
368
|
+
const applyControl = deps.applyControl;
|
|
369
|
+
const breakControl = deps.breakControl;
|
|
370
|
+
const onDamaged = deps.onDamaged;
|
|
371
|
+
const rollHeal = deps.rollHeal;
|
|
372
|
+
const unsummonTotem = deps.unsummonTotem;
|
|
373
|
+
const checkDeaths = deps.checkDeaths;
|
|
374
|
+
const finalize = deps.finalize;
|
|
375
|
+
const seedDamageShields = deps.seedDamageShields;
|
|
376
|
+
const seedProcTriggers = deps.seedProcTriggers;
|
|
377
|
+
const seedSchoolAbsorbs = deps.seedSchoolAbsorbs;
|
|
378
|
+
const seedManaShields = deps.seedManaShields;
|
|
379
|
+
const seedRestoreHealth = deps.seedRestoreHealth;
|
|
380
|
+
const seedDisplayAuras = deps.seedDisplayAuras;
|
|
381
|
+
const sealRighteousnessSwing = deps.sealRighteousnessSwing;
|
|
382
|
+
const materializeSpecForCombat = deps.materializeSpecForCombat;
|
|
383
|
+
|
|
384
|
+
const L: Record<string, any> = {};
|
|
385
|
+
const applySpell = (src: Fighter, tgtIn: Fighter, spellIn: SpellDef, reflected = false, hitCount = 1): void => {
|
|
386
|
+
const t = ctx.t;
|
|
387
|
+
// SPELLFAMILY_GENERIC: else if (Mechanic == MECHANIC_INVULNERABILITY) AddPrecastSpell(25771);
|
|
388
|
+
// SPELLFAMILY_PALADIN: if (Mechanic == MECHANIC_INVULNERABILITY && Id != 25771) AddPrecastSpell(25771);
|
|
389
|
+
// Cite: Spell.cpp:3116-3117, 3144-3145 at de8f72993b9e1faa1bf92dd5505f3daec5cea3ce.
|
|
390
|
+
if (spellIn.mechanic === 'invulnerability' && (spellIn.id | 0) !== 25771) {
|
|
391
|
+
let forbearance: SpellDef | undefined = src.spec.spells.find((s) => (s.id | 0) === 25771);
|
|
392
|
+
if (!forbearance) {
|
|
393
|
+
for (const f of ctx.fightersOrder()) {
|
|
394
|
+
forbearance = f.spec.spells.find((s) => (s.id | 0) === 25771);
|
|
395
|
+
if (forbearance) break;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
forbearance = forbearance ?? ctx.spellOf?.(25771);
|
|
399
|
+
if (forbearance) applySpell(src, tgtIn, forbearance);
|
|
400
|
+
}
|
|
401
|
+
// DO_NOT_DISPLAY: no aura icon. The session view folds in the roster's kits, but a triggered
|
|
402
|
+
// payload sits in no kit, so stamp it here where the SpellDef is in hand.
|
|
403
|
+
if (spellIn.hiddenClientside) ctx.hiddenAuraSpellIds.add(spellIn.id);
|
|
404
|
+
// Capture Spirit 7914 CheckCast entry allowlist. Cite: Spell.cpp:7914–7920.
|
|
405
|
+
if ((spellIn.id | 0) === 7914 && !captureSpiritAllowsEntry((tgtIn.spec.creatureEntry ?? 0) | 0)) return;
|
|
406
|
+
if ((spellIn.id | 0) === SPIRIT_OF_REDEMPTION_AURA_SPELL_ID) {
|
|
407
|
+
tgtIn.castingSpell = null;
|
|
408
|
+
tgtIn.castFinishAt = 0;
|
|
409
|
+
tgtIn.castStartedAt = 0;
|
|
410
|
+
// SpellDefines.h:367, MiscHandler.cpp:413
|
|
411
|
+
ctx.removeAurasWithInterruptFlags?.(tgtIn, AURA_INTERRUPT.ANIM_CANCELS, 'anim cancels');
|
|
412
|
+
ctx.push({ t, kind: 'stand_state', actor: tgtIn.side, standState: 0 });
|
|
413
|
+
if (tgtIn.channeling) {
|
|
414
|
+
interruptChannel(tgtIn, channelInterruptCtx(ctx), { quiet: true });
|
|
415
|
+
}
|
|
416
|
+
if (tgtIn.spec.powerType === 'mana') {
|
|
417
|
+
tgtIn.power = tgtIn.spec.maxPower;
|
|
418
|
+
}
|
|
419
|
+
tgtIn.hp = tgtIn.spec.maxHealth;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Cite: boss_thaddius.cpp:410-418 ThaddiusChargeDamage OnCheckTarget.
|
|
423
|
+
if (polarityDamageSkipped(spellIn.id | 0, tgtIn)) return;
|
|
424
|
+
// PI and DI CheckCast the friendly recipient. Hostile focus remaps to self.
|
|
425
|
+
// Cite: Priest.cpp:36-40 GetAuraCount(12042); Paladin.cpp:144-151 HasAura(23333/23335).
|
|
426
|
+
const ccSid = spellIn.id | 0;
|
|
427
|
+
const ccOnRecipient = ccSid === 10060 || ccSid === 19752 || ccSid === 19754;
|
|
428
|
+
// Bandage CheckCast / 11196 land on the heal recipient, not the hostile focus.
|
|
429
|
+
// Cite: Spell.cpp:3114 AddPrecastSpell(11196); Spell.cpp:2298-2306 TARGET_UNIT_FRIEND.
|
|
430
|
+
const bandageLockTgt = spellIn.mechanic === 'bandage' && ccSid !== RECENTLY_BANDAGED_SPELL_ID
|
|
431
|
+
? selectedOrSelfFriendly(
|
|
432
|
+
src, tgtIn, src.friendlyPin ? bySide(src.friendlyPin) : null, dead, isEnemySide,
|
|
433
|
+
)
|
|
434
|
+
: null;
|
|
435
|
+
const checkTgt = bandageLockTgt ?? ((ccOnRecipient && isEnemySide(tgtIn.side)) ? src : tgtIn);
|
|
436
|
+
if (refuseCmangosCheckCast(spellIn, {
|
|
437
|
+
flags: checkTgt.spec.flags,
|
|
438
|
+
creatureEntry: checkTgt.spec.creatureEntry,
|
|
439
|
+
powerType: checkTgt.spec.powerType,
|
|
440
|
+
classId: checkTgt.spec.classId,
|
|
441
|
+
auraSpellIds: checkTgt.buffActive,
|
|
442
|
+
isPlayer: isPlayerCharacterTarget(checkTgt),
|
|
443
|
+
}, {
|
|
444
|
+
// AQ40 mounts: caster map. Cite: Spell.cpp:5762 GetMapId().
|
|
445
|
+
mapId: src.spec.mapId,
|
|
446
|
+
hasActivePet: !!ctx.livingPetOf?.(src) || ctx.isCharmed(src),
|
|
447
|
+
})) return;
|
|
448
|
+
// Precast after CheckCast so this bandage is not refused by the aura it just applied.
|
|
449
|
+
if (bandageLockTgt) {
|
|
450
|
+
const recently = ctx.spellOf?.(RECENTLY_BANDAGED_SPELL_ID)
|
|
451
|
+
?? src.spec.spells.find((s) => (s.id | 0) === RECENTLY_BANDAGED_SPELL_ID);
|
|
452
|
+
if (recently) applySpell(bandageLockTgt, bandageLockTgt, recently);
|
|
453
|
+
}
|
|
454
|
+
// Loatheb Corrupted Mind / Nefarian class-call scripts. Cite: Spell.cpp:4655–4686.
|
|
455
|
+
if (refuseOverrideClassScriptCheckCast(spellIn, src.buffActive)) return;
|
|
456
|
+
// spell_script_target type 2 (DEAD): the spell explodes/eats a CORPSE, so with none in the
|
|
457
|
+
// fight CheckScriptTargeting returns SPELL_FAILED_BAD_TARGETS. Cite: Spell.cpp:2599-2615.
|
|
458
|
+
if (refuseForMissingCorpse(spellIn.id | 0, spellIn.rawEffects, (entry) =>
|
|
459
|
+
ctx.fightersOrder().some((f) => (f.spec.creatureEntry ?? 0) === entry && f.hp <= 0),
|
|
460
|
+
)) return;
|
|
461
|
+
// Swiftmend CheckCast + HoT snapshot read the heal recipient, not the hostile focus.
|
|
462
|
+
// Cite: Druid.cpp:26-30; applyHealEffect triage; PI remap 7b26759d5.
|
|
463
|
+
let auraTgt = tgtIn;
|
|
464
|
+
// A friendly-only spell that resolved onto a body HOSTILE TO ITS CASTER lands on the caster's
|
|
465
|
+
// own side. In 1.12 that cast does not land at all, so landing it for 0 is the bug: side B's
|
|
466
|
+
// priest cast Power Word: Fortitude onto `player` at 0.0/1.5/3.0/4.5 s in a bracket-2 mirror,
|
|
467
|
+
// it was never HELD by any friendly, and the keep-up row re-fired all fight. Faction-relative
|
|
468
|
+
// on purpose — the Swiftmend clause below reads `isEnemySide`, so it only ever fired for a
|
|
469
|
+
// player-side caster. AFTER the row's own target resolution, so a self keep-up is untouched.
|
|
470
|
+
// Cite: Spell.cpp:5947-5951 IsPositiveSpell -> CanAssistSpell -> SPELL_FAILED_BAD_TARGETS.
|
|
471
|
+
if (
|
|
472
|
+
// PLAYER-SHAPED casters only. `isHostileSpellDef` does not catch every harmful creature
|
|
473
|
+
// spell, so judging a boss's kit by this predicate redirected real casts back at the boss:
|
|
474
|
+
// Jin'do's wards stopped standing, Thaddius's adds stopped reviving, Tharil'zun's payload
|
|
475
|
+
// never fired and the drakonid constructs found no corpses (smoke:slow, measured).
|
|
476
|
+
// The defect this fixes is a player-shaped SEAT's keep-up buff, which is what it now covers.
|
|
477
|
+
src.spec.classId != null
|
|
478
|
+
&& spellWantsFriendlyTarget(spellIn)
|
|
479
|
+
&& ctx.factionOfFighter(src) !== ctx.factionOfFighter(tgtIn)
|
|
480
|
+
) {
|
|
481
|
+
// The CASTER, not the lowest-HP mate: a triage row already carries `{lowestHp:true}` from
|
|
482
|
+
// `spellActionWithTriageAim` and arrives with a friendly target, so this clause only ever
|
|
483
|
+
// sees a row that named nobody — and TARGET_UNIT_FRIEND with no selection is self.
|
|
484
|
+
// Cite: Spell.cpp:2298-2306; src/dev/smoke/hotKeepUpCloses.mjs is the closure gate.
|
|
485
|
+
auraTgt = src;
|
|
486
|
+
}
|
|
487
|
+
if ((spellIn.id | 0) === SWIFTMEND_SPELL_ID) {
|
|
488
|
+
if (isEnemySide(tgtIn.side)) auraTgt = lowestHpFriendlyOf(src) ?? src;
|
|
489
|
+
const hots = targetAurasFromLive(src, auraTgt, ctx.effectsOn(auraTgt.side));
|
|
490
|
+
if (!hasSwiftmendHot(hots)) return;
|
|
491
|
+
}
|
|
492
|
+
// Divine Intervention and Ice Block are self school-immunity plus a self lock, never a hostile
|
|
493
|
+
// stun. Resolve rebuilds effects from raw, so the role stamps are re-normalized after.
|
|
494
|
+
let spell = expandHakkarBloodSiphonFunnel(normalizeIceBlock(normalizeDivineIntervention(resolveCombatSpell(spellIn, {
|
|
495
|
+
attackPower: src.attackPower,
|
|
496
|
+
energy: src.spec.powerType === 'energy' ? src.power : undefined,
|
|
497
|
+
// The target's own bucket, not the whole session array: this scanned every live effect in the
|
|
498
|
+
// fight per application. It is version-exact and keeps array order, so the summary is identical.
|
|
499
|
+
targetAuras: targetAurasFromLive(src, auraTgt, ctx.effectsOn(auraTgt.side)),
|
|
500
|
+
casterAuras: targetAurasFromLive(src, src, ctx.effectsOn(src.side)),
|
|
501
|
+
// Display rage 0–100 for Execute (Warrior.cpp stored*10 * DmgMultiplier).
|
|
502
|
+
rage: src.spec.powerType === 'rage' ? src.power : undefined,
|
|
503
|
+
comboPoints: src.comboPoints,
|
|
504
|
+
casterLevel: src.spec.level,
|
|
505
|
+
casterRunSpeedRate: (() => {
|
|
506
|
+
let speedEffects = [];
|
|
507
|
+
for (const p of src.spec.permanentSpeedAuras ?? []) speedEffects.push({ aura: p.aura, pct: p.pct });
|
|
508
|
+
for (const e of ctx.effectsOn(src.side)) {
|
|
509
|
+
if (e.kind === 'speed' && e.aura != null) speedEffects.push({ aura: e.aura, pct: e.pct ?? 0 });
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const speed = speedEffects.length === 0 ? BASE_RUN_SPEED : effectiveSpeeds({ effects: speedEffects, mounted: false }).run;
|
|
513
|
+
return speed / BASE_RUN_SPEED;
|
|
514
|
+
})(),
|
|
515
|
+
casterGender: src.spec.gender,
|
|
516
|
+
// Player GetSpellRank is skill (level x 5); creature GetSpellRank is GetLevel, then /5.
|
|
517
|
+
// Cite: Unit.cpp:11855; Player.cpp:5874; Object.cpp:2928.
|
|
518
|
+
casterIsPlayer: isPlayerCharacterSide(src.side),
|
|
519
|
+
// The per-level term is clamped into the spell's own [baseLevel, maxLevel] band before spellLevel is
|
|
520
|
+
// subtracted; without these two a rank-1 nuke kept scaling to 60. Cite: Object.cpp:2926-2934.
|
|
521
|
+
...(spellIn.baseLevel != null ? { spellBaseLevel: spellIn.baseLevel } : {}),
|
|
522
|
+
...(spellIn.maxLevel != null ? { spellMaxLevel: spellIn.maxLevel } : {}),
|
|
523
|
+
// SPELL_ATTR_SCALES_WITH_CREATURE_LEVEL. C++ also requires a real spellLevel
|
|
524
|
+
// (the CLS ratio denominator). Cite: Object.cpp:2969-2971.
|
|
525
|
+
...(!isPlayerCharacterSide(src.side)
|
|
526
|
+
&& hasAttr(spellIn, SPELL_ATTR.SCALES_WITH_CREATURE_LEVEL)
|
|
527
|
+
&& (spellIn.spellLevel ?? 0) > 0
|
|
528
|
+
&& ctx.creatureBaseDamageAt
|
|
529
|
+
? {
|
|
530
|
+
creatureLevelScaling: {
|
|
531
|
+
unitClass: src.spec.unitClass ?? 1,
|
|
532
|
+
baseDamageAt: ctx.creatureBaseDamageAt,
|
|
533
|
+
},
|
|
534
|
+
}
|
|
535
|
+
: {}),
|
|
536
|
+
shieldBlock: src.spec.blockValue,
|
|
537
|
+
// Berserking (icon 1661) haste_mod from health %. Cite: SpellEffects.cpp:1270–1275; KI-125.
|
|
538
|
+
casterHealthPercent: (src.hp / Math.max(1, src.spec.maxHealth)) * 100,
|
|
539
|
+
casterMaxHealth: src.spec.maxHealth,
|
|
540
|
+
// % of target max health (Frost Blast 26%, PERIODIC_DAMAGE_PERCENT). Cite: boss_kelthuzad.cpp:604.
|
|
541
|
+
targetMaxHealth: auraTgt.spec.maxHealth,
|
|
542
|
+
targetMaxMana: auraTgt.spec.powerType === 'mana' ? auraTgt.spec.maxPower : undefined,
|
|
543
|
+
// Flametongue dummy needs weapon delay. Cite: SpellEffects.cpp:1472–1504.
|
|
544
|
+
weaponSpeedMs: src.spec.weapon.speedMs,
|
|
545
|
+
castItemPresent: true,
|
|
546
|
+
// Enrage 5229: −16% armor in Dire Bear vs −27% Bear. Cite: SpellEffects.cpp:1373 HasAura(9634).
|
|
547
|
+
hasDireBearForm:
|
|
548
|
+
src.formSpellId === DIRE_BEAR_FORM_SPELL_ID || src.buffActive.has(DIRE_BEAR_FORM_SPELL_ID),
|
|
549
|
+
// Only 22276 draws; passing rng on every resolve would roll chance specials that
|
|
550
|
+
// currently always-fire when rng is omitted. Cite: SpellEffects.cpp:827 urand(0,4).
|
|
551
|
+
...((spellIn.id | 0) === ELEMENTAL_SHIELD_DUMMY_SPELL_ID
|
|
552
|
+
? { rng: () => rng.next() }
|
|
553
|
+
: {}),
|
|
554
|
+
}))));
|
|
555
|
+
// Flametongue proc: the bake ships flat direct damage with no rawEffects dummy, so scale it here
|
|
556
|
+
// as trunc(damage * 0.01 * weaponSpeed). A flat 489 a swing was the bug.
|
|
557
|
+
{
|
|
558
|
+
const sid = spell.id | 0;
|
|
559
|
+
const isFtProc =
|
|
560
|
+
(FLAMETONGUE_TOTEM_PROC_RANKS as readonly number[]).includes(sid)
|
|
561
|
+
|| (FLAMETONGUE_WEAPON_PROC_RANKS as readonly number[]).includes(sid);
|
|
562
|
+
if (isFtProc) {
|
|
563
|
+
const speedSec = src.spec.weapon.speedMs / 1000;
|
|
564
|
+
spell = {
|
|
565
|
+
...spell,
|
|
566
|
+
effects: spell.effects.map((e) => {
|
|
567
|
+
if (e.kind !== 'direct_damage' || !(e.min > 0)) return e;
|
|
568
|
+
const scaled = flametongueSwingDamage(e.min, speedSec);
|
|
569
|
+
return { ...e, min: scaled, max: scaled };
|
|
570
|
+
}),
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// spell_threat flat and AP bonus, once per successful hostile hit and independent of damage. The
|
|
575
|
+
// damage branch owned the only call, so Sunder Armor and Demoralizing Shout made ZERO threat.
|
|
576
|
+
let hostileResistHitDone = false;
|
|
577
|
+
// SPELL_AURA_SPELL_MAGNET (96) — victim auras only, magic or none, and the caster must be able to
|
|
578
|
+
// attack the magnet. Cite: Unit.cpp SelectMagnetTarget :6896–6922.
|
|
579
|
+
let tgt = auraTgt;
|
|
580
|
+
if (isMagnetRedirectableSpell(spell) && tgt.side !== src.side) {
|
|
581
|
+
const magnetCandidates = magnetCandidatesOnVictim(
|
|
582
|
+
effects, tgt.side, t,
|
|
583
|
+
(side) => bySide(side),
|
|
584
|
+
(magnetSide) => ctx.factionOf(src.side) !== ctx.factionOf(magnetSide),
|
|
585
|
+
);
|
|
586
|
+
if (magnetCandidates.length > 0) {
|
|
587
|
+
const mag = selectMagnetTarget(magnetCandidates, true);
|
|
588
|
+
if (mag.redirected && mag.magnetUnitId) {
|
|
589
|
+
const magnetF = bySide(mag.magnetUnitId);
|
|
590
|
+
if (magnetF) {
|
|
591
|
+
tgt = magnetF;
|
|
592
|
+
if (isTotemSide(magnetF.side)) unsummonTotem(magnetF.side, 'expire');
|
|
593
|
+
push({
|
|
594
|
+
t, kind: 'flavor',
|
|
595
|
+
text: `${spell.name} is redirected to ${magnetF.spec.name}.`,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// The friendly this cast is AIMED at, or null. Two records, only one ever set: the PIN a macro or
|
|
603
|
+
// Pre-buff order leaves, and `tgt` itself for the ally queue. Same-faction only.
|
|
604
|
+
const aimedFriendlyOf = (caster: Fighter): Fighter | null => {
|
|
605
|
+
const sameSide = (u: Fighter): boolean =>
|
|
606
|
+
isEnemySide(u.side) === isEnemySide(caster.side) && !dead(u);
|
|
607
|
+
const pinned = caster.friendlyPin ? bySide(caster.friendlyPin) : null;
|
|
608
|
+
if (pinned && sameSide(pinned)) return pinned;
|
|
609
|
+
return sameSide(tgt) ? tgt : null;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
// EVENT_T_SPELLHIT pulse — fires for whichever unit the spell was delivered to, hit or miss, so it
|
|
613
|
+
// is stamped before the immunity gates. Cite: Spell.cpp:1345 unit->AI()->SpellHit.
|
|
614
|
+
tgt.spellHitId = spell.id;
|
|
615
|
+
tgt.spellHitSchoolMask = schoolMaskFromSchoolName(spell.school) || schoolMaskFromSchool(spell.school);
|
|
616
|
+
tgt.spellHitAt = t;
|
|
617
|
+
// The same hook on the scripted side, running on the delivered unit before the immunity gates,
|
|
618
|
+
// exactly where the EVENT_T stamp above sits.
|
|
619
|
+
ctx.runScriptDevSpellHit?.(tgt, src, spell.id);
|
|
620
|
+
|
|
621
|
+
// Cite: boss_thaddius.cpp:363-400 PolarityShift OnEffectExecute.
|
|
622
|
+
if (isPolarityShift(spell.id) && !isEnemySide(tgt.side) && tgt.hp > 0) {
|
|
623
|
+
const coin = (rng.int(0, 1) === 0 ? 0 : 1) as 0 | 1;
|
|
624
|
+
const chargeId = pickPolarityCharge(t, coin);
|
|
625
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(tgt, SPELL_POSITIVE_CHARGE);
|
|
626
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(tgt, SPELL_NEGATIVE_CHARGE);
|
|
627
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(tgt, SPELL_POSITIVE_CHARGE_BUFF);
|
|
628
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(tgt, SPELL_NEGATIVE_CHARGE_BUFF);
|
|
629
|
+
let charge: SpellDef | undefined = src.spec.spells.find((s) => (s.id | 0) === chargeId);
|
|
630
|
+
if (!charge) {
|
|
631
|
+
for (const f of fightersOrder()) {
|
|
632
|
+
charge = f.spec.spells.find((s) => (s.id | 0) === chargeId);
|
|
633
|
+
if (charge) break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (charge) applySpell(tgt, tgt, charge);
|
|
637
|
+
}
|
|
638
|
+
if (
|
|
639
|
+
((spell.id | 0) === 28062 || (spell.id | 0) === 28085)
|
|
640
|
+
&& (src.buffActive.has(SPELL_POSITIVE_CHARGE) || src.buffActive.has(SPELL_NEGATIVE_CHARGE))
|
|
641
|
+
&& polarityStackDue(t, src.side)
|
|
642
|
+
) {
|
|
643
|
+
const n = countSameChargeNear(src, fightersOrder(), (a, b) => ctx.distanceBetween(a, b));
|
|
644
|
+
const buffId = polarityBuffForCharge(
|
|
645
|
+
src.buffActive.has(SPELL_POSITIVE_CHARGE) ? SPELL_POSITIVE_CHARGE : SPELL_NEGATIVE_CHARGE,
|
|
646
|
+
);
|
|
647
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(src, buffId);
|
|
648
|
+
if (n > 0) {
|
|
649
|
+
let base: SpellDef | undefined = src.spec.spells.find((s) => (s.id | 0) === buffId);
|
|
650
|
+
if (!base) {
|
|
651
|
+
for (const f of fightersOrder()) {
|
|
652
|
+
base = f.spec.spells.find((s) => (s.id | 0) === buffId);
|
|
653
|
+
if (base) break;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (base) applySpell(src, src, polarityStackSpell(base, n));
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Template school/mechanic immune (Creature::IsImmuneToSpell) — skip hostile apply.
|
|
661
|
+
// Self-buffs/heals still land (castOnSelf path in C++ skips mask).
|
|
662
|
+
const hostileSpell = isHostileSpellDef(spell);
|
|
663
|
+
// Unit.cpp:2902 positive spells with EX3_IGNORE_CASTER_AND_TARGET_RESTRICTIONS roll miss/immune.
|
|
664
|
+
const bypassPositiveAutoHit = hasAttrEx3(spell, SPELL_ATTR_EX3.IGNORE_CASTER_AND_TARGET_RESTRICTIONS);
|
|
665
|
+
const hostileSpellOrBypass = hostileSpell || bypassPositiveAutoHit;
|
|
666
|
+
// Spell reflection. SpellHitResult checks reflect BEFORE immunity, and only magic reflects, so a
|
|
667
|
+
// warrior ability never does. A reflected spell cannot re-reflect.
|
|
668
|
+
if (!reflected && hostileSpell && tgt.side !== src.side) {
|
|
669
|
+
const dmgClass = typeof spell.dmgClass === 'number'
|
|
670
|
+
? spell.dmgClass
|
|
671
|
+
: (spell.school === 'physical' ? SPELL_DAMAGE_CLASS.MELEE : SPELL_DAMAGE_CLASS.MAGIC);
|
|
672
|
+
const reflectable = isReflectableSpell({
|
|
673
|
+
dmgClass,
|
|
674
|
+
passive: spell.passive,
|
|
675
|
+
positive: false,
|
|
676
|
+
noReflection: hasAttrEx(spell, SPELL_ATTR_EX.NO_REFLECTION),
|
|
677
|
+
});
|
|
678
|
+
if (reflectable) {
|
|
679
|
+
const chance = reflectChanceFor(tgt, spell.school);
|
|
680
|
+
// Draw the RNG only when a reflect aura is actually present — otherwise a fight with no
|
|
681
|
+
// reflect would consume a draw here and shift every downstream roll (golden-log determinism).
|
|
682
|
+
if (chance > 0 && rollSpellReflect(true, chance, rng.next())) {
|
|
683
|
+
push({ t, kind: 'flavor', text: `${tgt.spec.name} reflects ${spell.name} back at ${src.spec.name}.` });
|
|
684
|
+
// SPELL_MISS_REFLECT is still a failed hostile hit on the reflector (Spell.cpp:1226).
|
|
685
|
+
engageOnHostileSpell(src, tgt, spell, false);
|
|
686
|
+
applySpell(tgt, src, spellIn, true); // caster becomes the target; guard blocks a re-reflect
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// CheckTargetCreatureType — the whole-spell valid-target gate. A cc spell reports cc_immune to
|
|
692
|
+
// match the prior gate and the UI; anything else reads as a flat miss, a bad target not immunity.
|
|
693
|
+
if (hostileSpell && !spellTargetTypeAllowed(spell, tgt.creatureType)) {
|
|
694
|
+
const ccEff = spell.effects.find((e) => e.kind === 'cc');
|
|
695
|
+
if (ccEff && ccEff.kind === 'cc') {
|
|
696
|
+
push({ t, kind: 'cc_immune', target: tgt.side, control: ccEff.control, name: spell.name });
|
|
697
|
+
} else {
|
|
698
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: 'miss', amount: 0, school: spell.school });
|
|
699
|
+
}
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
// Template school/mechanic + aura SCHOOL_IMMUNITY → combat log "Immune" (not miss).
|
|
703
|
+
// Cite: Unit.cpp IsImmuneToSpell; KI-695 school-immunity-logged-as-miss.
|
|
704
|
+
if (hostileSpell && spellTemplateImmune(tgt, spell)) {
|
|
705
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: 'immune', amount: 0, school: spell.school });
|
|
706
|
+
// SPELL_MISS_IMMUNE is not SPELL_MISS_EVADE — the core still counts it as an attack.
|
|
707
|
+
engageOnHostileSpell(src, tgt, spell, false);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
// creature_immunities set 0: every populated effect slot immune means nothing can land. A partly
|
|
711
|
+
// immune spell still resolves, with the per-effect skips at the apply sites.
|
|
712
|
+
if (hostileSpell && isEveryEffectSlotImmune(
|
|
713
|
+
tgt.immunity,
|
|
714
|
+
spell.rawEffects,
|
|
715
|
+
{
|
|
716
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
717
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
718
|
+
immunities: tgt.spec.immunities,
|
|
719
|
+
},
|
|
720
|
+
hasAttr(spell, SPELL_ATTR.NO_IMMUNITIES),
|
|
721
|
+
hasAttrEx4(spell, SPELL_ATTR_EX4.NO_PARTIAL_IMMUNITY),
|
|
722
|
+
)) {
|
|
723
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: 'immune', amount: 0, school: spell.school });
|
|
724
|
+
engageOnHostileSpell(src, tgt, spell, false);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
// Aura school immunity blocks harmful auras/spells (not positive self-buffs).
|
|
728
|
+
if (hostileSpell && isImmuneToHarmfulAura(tgt.immunity, {
|
|
729
|
+
schoolMask: schoolMaskFromSchoolName(spell.school) || schoolMaskFromSchool(spell.school),
|
|
730
|
+
positive: false,
|
|
731
|
+
noSchoolImmunities: spellPiercesSchoolImmunity(spell),
|
|
732
|
+
}, {
|
|
733
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
734
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
735
|
+
})) {
|
|
736
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: 'immune', amount: 0, school: spell.school });
|
|
737
|
+
engageOnHostileSpell(src, tgt, spell, false);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Combo points spent by this cast (finishers read them before clear; builders add after a hit).
|
|
742
|
+
// Self-buff finishers (Slice and Dice) still use the caster's current CP regardless of target.
|
|
743
|
+
const cp = src.comboPoints;
|
|
744
|
+
const isFinisher = !!spell.requiresComboPoints;
|
|
745
|
+
let finisherHit = false; // any hostile effect that landed (for CP clear; miss retains CP)
|
|
746
|
+
// One DR advance and one control land per target, control and mechanic per cast, so a multi-aura
|
|
747
|
+
// remap like Polymorph does not double-step DR.
|
|
748
|
+
const appliedCcThisCast = new Set<string>();
|
|
749
|
+
// One SpellHitResult per target gates ALL effects, damage and root and aura alike — a Frost Nova
|
|
750
|
+
// damage miss must not still apply the root.
|
|
751
|
+
let hostileLanded: boolean | null = null;
|
|
752
|
+
// Does this cast carry damage at all? Decides whether a land failure logs as a spell miss or as
|
|
753
|
+
// the control resisting.
|
|
754
|
+
const spellHasDamagePayload = spell.effects.some(
|
|
755
|
+
(e) => e.kind === 'direct_damage' || e.kind === 'dot' || e.kind === 'health_leech'
|
|
756
|
+
|| e.kind === 'weapon_percent_damage' || e.kind === 'weapon_damage' || e.kind === 'weapon_strike',
|
|
757
|
+
);
|
|
758
|
+
// The miss and full-resist side of the land die, in percentage points, memorised when the die was
|
|
759
|
+
// built: heartbeat resist must roll the SAME number, not a recomputation.
|
|
760
|
+
let landMissPlusResistPct: number | null = null;
|
|
761
|
+
const rollHostileLand = (school: School, ccControl?: ControlKind): boolean => {
|
|
762
|
+
if (hostileLanded !== null) return hostileLanded;
|
|
763
|
+
const binary = resolveSpellBinary(spell);
|
|
764
|
+
const dmgClass = typeof spell.dmgClass === 'number' ? spell.dmgClass : SPELL_DAMAGE_CLASS.MAGIC;
|
|
765
|
+
// Unit.cpp:2952 — with NO_AVOIDANCE the caster never enters a hit-result die, so there is
|
|
766
|
+
// nothing to roll and the cast lands whatever the resistances say.
|
|
767
|
+
if (hasAttrEx3(spell, SPELL_ATTR_EX3.NO_AVOIDANCE)) {
|
|
768
|
+
hostileLanded = true;
|
|
769
|
+
landMissPlusResistPct = 0;
|
|
770
|
+
return true;
|
|
771
|
+
}
|
|
772
|
+
const mechanicResistPercent = mechanicResistBonus(
|
|
773
|
+
tgt.spec.mechanicResistPct,
|
|
774
|
+
[spell.mechanic, ...spell.effects.map((e) => (e as { mechanic?: string }).mechanic)],
|
|
775
|
+
);
|
|
776
|
+
// A melee-class spell rolls MeleeSpellHitResult, never the magic die: Bash lands like a strike.
|
|
777
|
+
// Cite: Unit.cpp:2957-2958 SpellHitResult; :2816 MeleeSpellHitResult.
|
|
778
|
+
let failResult: HitResult | 'resist' = 'miss';
|
|
779
|
+
let landed: boolean;
|
|
780
|
+
const meleeDie = dmgClass === SPELL_DAMAGE_CLASS.MELEE && ctx.meleeSpellHitResult;
|
|
781
|
+
if (meleeDie) {
|
|
782
|
+
const r = ctx.meleeSpellHitResult!(src, tgt, spell, mechanicResistPercent);
|
|
783
|
+
landMissPlusResistPct = r.failPct;
|
|
784
|
+
failResult = r.result;
|
|
785
|
+
landed = r.result === 'hit' || r.result === 'crit';
|
|
786
|
+
} else {
|
|
787
|
+
const land = spellDamageLandChance({
|
|
788
|
+
atkLevel: src.spec.level,
|
|
789
|
+
defLevel: tgt.spec.level,
|
|
790
|
+
school,
|
|
791
|
+
resistance: tgt.resistances[school] ?? 0,
|
|
792
|
+
binary,
|
|
793
|
+
resistable: isResistableDmgClass(dmgClass) && dmgClass === SPELL_DAMAGE_CLASS.MAGIC,
|
|
794
|
+
spellHitBonusPercent: src.spellHitBonusPercent,
|
|
795
|
+
// Victim aura 186 for this school. Cite: Unit.cpp:3874.
|
|
796
|
+
attackerSpellHitPercent: tgt.attackerSpellHitBySchool[school] ?? 0,
|
|
797
|
+
aoeAvoidancePercent: spell.aoe ? (tgt.aoeAvoidancePercent ?? 0) : 0,
|
|
798
|
+
attackerPlayerControlled: src.side === 'player' || src.side === 'pet' || isAllySide(src.side),
|
|
799
|
+
victimIsNpc: isEnemySide(tgt.side),
|
|
800
|
+
victimPlayerControlled: tgt.side === 'player' || tgt.side === 'pet' || isAllySide(tgt.side),
|
|
801
|
+
// ALWAYS_HIT and a totem victim zero the MISS component only — resistance and mechanic resist
|
|
802
|
+
// still add. NO_AVOIDANCE skips the switch entirely and is handled as a straight land below.
|
|
803
|
+
alwaysHit: hasAttrEx3(spell, SPELL_ATTR_EX3.ALWAYS_HIT),
|
|
804
|
+
victimIsTotem: isTotemCreatureType(tgt.spec.creatureType) || isTotemSide(tgt.side),
|
|
805
|
+
mechanicResistPercent,
|
|
806
|
+
});
|
|
807
|
+
landMissPlusResistPct = Math.max(0, Math.min(100, (1 - land) * 100));
|
|
808
|
+
landed = rng.chance(land);
|
|
809
|
+
}
|
|
810
|
+
if (!landed) {
|
|
811
|
+
hostileLanded = false;
|
|
812
|
+
// DISCOUNT_POWER_ON_MISS: 80% of the cost back on a miss. Cite: Spell.cpp:3647-3663.
|
|
813
|
+
ctx.refundPowerOnMiss?.(src, spell, failResult === 'resist' ? 'miss' : failResult);
|
|
814
|
+
|
|
815
|
+
// SPELL_ATTR_EX_FAILURE_BREAKS_STEALTH: remove stealth auras when the spell land fails.
|
|
816
|
+
// Cite: Unit.cpp:6012 bypassStealthAndEndIt.
|
|
817
|
+
if (hasAttrEx(spell, SPELL_ATTR_EX.FAILURE_BREAKS_STEALTH)) {
|
|
818
|
+
ctx.breakStealth?.(src, 'miss');
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// One SpellHitResult gates every effect, so the log carries ONE line for the cast. A damage
|
|
822
|
+
// spell with a control rider (Frostbolt's snare) reads as the spell missing; cc_resist is for
|
|
823
|
+
// a cast whose only payload is the control. Logging the rider hid every Frostbolt miss from
|
|
824
|
+
// the meter. Cite: Spell.cpp:1227 SPELL_MISS_MISS.
|
|
825
|
+
const logResult: HitResult = failResult === 'resist' ? 'miss' : failResult;
|
|
826
|
+
if (ccControl && !spellHasDamagePayload) {
|
|
827
|
+
push({ t, kind: 'cc_resist', target: tgt.side, control: ccControl, name: spell.name, ...(meleeDie ? { result: logResult } : {}) });
|
|
828
|
+
} else {
|
|
829
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: logResult, amount: 0, school });
|
|
830
|
+
}
|
|
831
|
+
// Merged miss+resist land die is not SPELL_MISS_RESIST; do not stamp RESIST (procEx 0x18).
|
|
832
|
+
|
|
833
|
+
// Magic Absorption: a magic-die fail restores pct% max mana; a melee-die fail is a TAKEN_MELEE_SPELL
|
|
834
|
+
// proc, not the 0x20000 flag 29441 carries. Cite: UnitAuraProcHandler.cpp:938–947 icon 459 → 29442.
|
|
835
|
+
if (
|
|
836
|
+
!meleeDie
|
|
837
|
+
&& tgt.magicAbsorptionPct > 0
|
|
838
|
+
&& tgt.spec.powerType === 'mana'
|
|
839
|
+
&& !dead(tgt)
|
|
840
|
+
) {
|
|
841
|
+
const gain = magicAbsorptionManaAmount(tgt.spec.maxPower, tgt.magicAbsorptionPct);
|
|
842
|
+
if (gain > 0) {
|
|
843
|
+
const before = tgt.power;
|
|
844
|
+
tgt.power = Math.min(tgt.spec.maxPower, tgt.power + gain);
|
|
845
|
+
if (tgt.power !== before) {
|
|
846
|
+
push({
|
|
847
|
+
t,
|
|
848
|
+
kind: 'resource',
|
|
849
|
+
actor: tgt.side,
|
|
850
|
+
power: 'mana',
|
|
851
|
+
value: tgt.power,
|
|
852
|
+
delta: tgt.power - before,
|
|
853
|
+
});
|
|
854
|
+
push({
|
|
855
|
+
t,
|
|
856
|
+
kind: 'spell_hit',
|
|
857
|
+
actor: tgt.side,
|
|
858
|
+
target: tgt.side,
|
|
859
|
+
spellId: MAGIC_ABSORPTION_TRIGGER_SPELL_ID,
|
|
860
|
+
name: 'Magic Absorption',
|
|
861
|
+
result: 'hit',
|
|
862
|
+
amount: tgt.power - before,
|
|
863
|
+
school: 'arcane',
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
hostileLanded = true;
|
|
871
|
+
return true;
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
// Interpreter-off fallback: consume_aura effect is the live path.
|
|
875
|
+
// Cite: SpellEffects.cpp:1048–1052 / :3809–3810 / :3989–3991.
|
|
876
|
+
if (!spell.effects.some((e) => e.kind === 'consume_aura')) {
|
|
877
|
+
if ((spell.id | 0) === HOURGLASS_SAND_SPELL_ID) {
|
|
878
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(
|
|
879
|
+
src,
|
|
880
|
+
BROOD_AFFLICTION_BRONZE_SPELL_ID,
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
if ((spell.id | 0) === BRITTLE_ARMOR_PEEL_SPELL_ID) {
|
|
884
|
+
(ctx.stripAuraOnDispel as ((u: Fighter, id: number, n: number) => void) | undefined)?.(
|
|
885
|
+
tgt,
|
|
886
|
+
BRITTLE_ARMOR_STACK_SPELL_ID,
|
|
887
|
+
1,
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
// Widow's Embrace: strip Faerlina's Enrage, arm the lockout that keeps it off for the Embrace's
|
|
891
|
+
// own 30 s window, and the caster dies for it. Cite: boss_faerlina.cpp:89-109.
|
|
892
|
+
if ((spell.id | 0) === 28157 && tgt.side !== src.side && tgt.hp > 0) {
|
|
893
|
+
// SpellHitTarget Slime Spray: one Fallout Slime per player. Cite: boss_grobbulus.cpp:75-80.
|
|
894
|
+
if (isPlayerCharacterSide(tgt.side) || isAllySide(tgt.side)) {
|
|
895
|
+
const pos = ctx.posOf?.(tgt.side);
|
|
896
|
+
ctx.injectSummonForCaster?.(
|
|
897
|
+
src,
|
|
898
|
+
16290,
|
|
899
|
+
0,
|
|
900
|
+
pos ? { x: pos.x, y: pos.y } : undefined,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// EntangleFankriss: caster casts the matching hatchling dest-spell; first fire fills the
|
|
905
|
+
// other two alcoves. Cite: boss_fankriss.cpp:186-203 OnEffectExecute; :173-178.
|
|
906
|
+
if (((src.spec.creatureEntry ?? 0) | 0) === 15510 && tgt.side !== src.side && tgt.hp > 0) {
|
|
907
|
+
const hatchByEntangle: Record<number, number> = { 720: 26630, 731: 26631, 1121: 26632 };
|
|
908
|
+
const hatchId = hatchByEntangle[spell.id | 0];
|
|
909
|
+
if (hatchId) {
|
|
910
|
+
const hatch = src.spec.spells.find((s) => (s.id | 0) === hatchId);
|
|
911
|
+
if (hatch) runCastSummons(ctx, src, hatch);
|
|
912
|
+
const sd = isEnemySide(src.side)
|
|
913
|
+
? ctx.enemyCastBySide.get(src.side)?.scriptDev
|
|
914
|
+
: undefined;
|
|
915
|
+
const tags = sd?.oneshotTags;
|
|
916
|
+
if (tags && !tags.has('fankriss_init_entangle')) {
|
|
917
|
+
tags.add('fankriss_init_entangle');
|
|
918
|
+
for (const otherId of Object.values(hatchByEntangle)) {
|
|
919
|
+
if (otherId === hatchId) continue;
|
|
920
|
+
const other = src.spec.spells.find((s) => (s.id | 0) === otherId);
|
|
921
|
+
if (other) runCastSummons(ctx, src, other);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if ((spell.id | 0) === 28236 && ((src.spec.creatureEntry ?? 0) | 0) === 15932
|
|
927
|
+
&& gluthChowAt.get(src) !== t) {
|
|
928
|
+
// 28236 hits a live Zombie Chow 16360 → 28239. Cite: boss_gluth.cpp:203-220,151-156.
|
|
929
|
+
const chow = ctx.livingEnemies().find((e) => ((e.spec.creatureEntry ?? 0) | 0) === 16360);
|
|
930
|
+
if (chow) {
|
|
931
|
+
gluthChowAt.set(src, t);
|
|
932
|
+
const kill = src.spec.spells.find((s) => (s.id | 0) === 28239);
|
|
933
|
+
if (kill) applySpell(src, chow, kill);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if ((spell.id | 0) === 28239 && ((src.spec.creatureEntry ?? 0) | 0) === 15932 && tgt.hp > 0) {
|
|
937
|
+
// Chow casts 28238: Gluth +5% max HP. Cite: boss_gluth.cpp:234-242,227-231.
|
|
938
|
+
const gain = Math.floor(src.spec.maxHealth * 0.05);
|
|
939
|
+
if (gain > 0) src.hp = Math.min(src.spec.maxHealth, src.hp + gain);
|
|
940
|
+
ctx.applyScriptDevCombatActions?.(src, [{
|
|
941
|
+
kind: 'yell',
|
|
942
|
+
textId: 12242,
|
|
943
|
+
cite: 'boss_gluth.cpp:97 DoBroadcastText(EMOTE_DEVOURS_ZOMBIE_CHOW 12242)',
|
|
944
|
+
}]);
|
|
945
|
+
}
|
|
946
|
+
if (
|
|
947
|
+
[28832, 28833, 28834, 28835].includes(spell.id | 0)
|
|
948
|
+
&& [16062, 16063, 16064, 16065].includes((src.spec.creatureEntry ?? 0) | 0)
|
|
949
|
+
) {
|
|
950
|
+
// HorsemenMark OnApply: stack 1 is silent; 2+ cast 28836. Cite: boss_four_horsemen.cpp:388-411.
|
|
951
|
+
const markTargets = (isPlayerCharacterSide(tgt.side) || isAllySide(tgt.side))
|
|
952
|
+
? [tgt]
|
|
953
|
+
: livingHostilesFor(src).filter((h) => (
|
|
954
|
+
h.hp > 0 && (isPlayerCharacterSide(h.side) || isAllySide(h.side))
|
|
955
|
+
));
|
|
956
|
+
const bolt = src.spec.spells.find((s) => (s.id | 0) === 28836);
|
|
957
|
+
for (const p of markTargets) {
|
|
958
|
+
if (p.hp <= 0) continue;
|
|
959
|
+
let stacks = horsemenMarkStacks.get(p);
|
|
960
|
+
if (!stacks) {
|
|
961
|
+
stacks = new Map();
|
|
962
|
+
horsemenMarkStacks.set(p, stacks);
|
|
963
|
+
}
|
|
964
|
+
const sid = spell.id | 0;
|
|
965
|
+
const n = Math.min(100, (stacks.get(sid) ?? 0) + 1);
|
|
966
|
+
stacks.set(sid, n);
|
|
967
|
+
if (n < 2 || !bolt) continue;
|
|
968
|
+
const dmg = n === 2 ? 250 : n === 3 ? 1000 : n === 4 ? 3000 : 1000 * n;
|
|
969
|
+
applySpell(src, p, {
|
|
970
|
+
...bolt,
|
|
971
|
+
effects: bolt.effects.map((e) => (
|
|
972
|
+
e.kind === 'direct_damage' ? { ...e, min: dmg, max: dmg } : e
|
|
973
|
+
)),
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if ((spell.id | 0) === 28035 && ((src.spec.creatureEntry ?? 0) | 0) === 16060) {
|
|
978
|
+
// Reset Gothik Event: strip balcony periodics. Cite: boss_gothik.cpp:501-512.
|
|
979
|
+
for (const id of [28007, 28009, 28011]) {
|
|
980
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, spellId: number) => number) | undefined)?.(src, id);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if ((spell.id | 0) === 29230 && ((src.spec.creatureEntry ?? 0) | 0) === 16060 && tgt.side === src.side) {
|
|
984
|
+
// 29230 effects[] is empty; raw aura 39 misc 127. Cite: boss_gothik.cpp:53,169-171.
|
|
985
|
+
grantImmunityAura(src, {
|
|
986
|
+
spellId: 29230,
|
|
987
|
+
auraType: AURA_TYPE.SCHOOL_IMMUNITY,
|
|
988
|
+
schoolMask: SPELL_SCHOOL_MASK_ALL,
|
|
989
|
+
positive: true,
|
|
990
|
+
immunityPurgesEffect: true,
|
|
991
|
+
durationMs: 274_000,
|
|
992
|
+
}, t + 274_000, spell.name, src.side);
|
|
993
|
+
}
|
|
994
|
+
if ((spell.id | 0) === 29252 && ((src.spec.creatureEntry ?? 0) | 0) === 15954) {
|
|
995
|
+
// OnPeriodicTrigger disables the empty tick and casts 29247/29248/29249.
|
|
996
|
+
// Cite: boss_noth.cpp:321-331 SummonSkeletonsNoth.
|
|
997
|
+
for (const id of [29247, 29248, 29249]) {
|
|
998
|
+
const skel = src.spec.spells.find((s) => (s.id | 0) === id);
|
|
999
|
+
if (skel) runCastSummons(ctx, src, skel);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
if ((spell.id | 0) === 28673 && ((src.spec.creatureEntry ?? 0) | 0) === 15952
|
|
1003
|
+
&& maexxnaWrapAt.get(src) !== t) {
|
|
1004
|
+
// 3 skip-tank players get 28622 (triggered by 28618-21). Cite: boss_maexxna.cpp:207-246.
|
|
1005
|
+
maexxnaWrapAt.set(src, t);
|
|
1006
|
+
const tankId = threatOf(src)?.getCurrentVictimId() ?? null;
|
|
1007
|
+
const pool = livingHostilesFor(src).filter((h) => (
|
|
1008
|
+
h.hp > 0
|
|
1009
|
+
&& h.side !== tankId
|
|
1010
|
+
&& (isPlayerCharacterSide(h.side) || isAllySide(h.side))
|
|
1011
|
+
));
|
|
1012
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
1013
|
+
const j = rng.int(0, i);
|
|
1014
|
+
const tmp = pool[i]!;
|
|
1015
|
+
pool[i] = pool[j]!;
|
|
1016
|
+
pool[j] = tmp;
|
|
1017
|
+
}
|
|
1018
|
+
const wrap = src.spec.spells.find((s) => (s.id | 0) === 28622);
|
|
1019
|
+
const n = Math.min(3, pool.length);
|
|
1020
|
+
if (wrap) {
|
|
1021
|
+
for (let i = 0; i < n; i++) applySpell(src, pool[i]!, wrap);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if ((spell.id | 0) === 799 && ((src.spec.creatureEntry ?? 0) | 0) === 15276) {
|
|
1025
|
+
// SpellHit 799: swap with Veknilash, DoResetThreat, stun 800. Cite: boss_twinemperors.cpp:332-356.
|
|
1026
|
+
const partner = ctx.livingEnemies().find((e) => ((e.spec.creatureEntry ?? 0) | 0) === 15275);
|
|
1027
|
+
if (partner) {
|
|
1028
|
+
const a = ctx.posOf?.(src.side);
|
|
1029
|
+
const b = ctx.posOf?.(partner.side);
|
|
1030
|
+
if (a && b && ctx.setPos) {
|
|
1031
|
+
ctx.setPos(src.side, { x: b.x, y: b.y, z: b.z });
|
|
1032
|
+
ctx.setPos(partner.side, { x: a.x, y: a.y, z: a.z });
|
|
1033
|
+
}
|
|
1034
|
+
const srcTm = threatOf(src);
|
|
1035
|
+
const partnerTm = threatOf(partner);
|
|
1036
|
+
if (srcTm) srcTm.modifyAllThreatPercent(-100);
|
|
1037
|
+
if (partnerTm) partnerTm.modifyAllThreatPercent(-100);
|
|
1038
|
+
push({ t, kind: 'threat_all_pct', actor: src.side, percent: -100 });
|
|
1039
|
+
push({ t, kind: 'threat_all_pct', actor: partner.side, percent: -100 });
|
|
1040
|
+
const stun = src.spec.spells.find((s) => (s.id | 0) === 800);
|
|
1041
|
+
if (stun) {
|
|
1042
|
+
applySpell(src, src, stun);
|
|
1043
|
+
applySpell(partner, partner, stun);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if ((spell.id | 0) === 29107 && ((src.spec.creatureEntry ?? 0) | 0) === 16061
|
|
1048
|
+
&& tgt.hp > 0 && isPlayerCharacterSide(tgt.side)
|
|
1049
|
+
&& tgt.spec.powerType === 'mana' && razuviousShoutAt.get(src) !== t) {
|
|
1050
|
+
// First mana player of this Disrupting Shout. Cite: boss_razuvious.cpp:85-102.
|
|
1051
|
+
razuviousShoutAt.set(src, t);
|
|
1052
|
+
ctx.applyScriptDevCombatActions?.(src, [{
|
|
1053
|
+
kind: 'yell',
|
|
1054
|
+
textId: 13082,
|
|
1055
|
+
cite: 'boss_razuvious.cpp:92 DoBroadcastText(EMOTE_TRIUMPHANT_SHOOT 13082)',
|
|
1056
|
+
}]);
|
|
1057
|
+
}
|
|
1058
|
+
if ((spell.id | 0) === 24649 && ((src.spec.creatureEntry ?? 0) | 0) === 15084
|
|
1059
|
+
&& tgt.hp > 0 && renatakiBladesFanAt.get(src) !== t) {
|
|
1060
|
+
// Non-triggered initial cast fans to 9 other players. Cite: boss_renataki.cpp:93-114.
|
|
1061
|
+
renatakiBladesFanAt.set(src, t);
|
|
1062
|
+
const pool = livingHostilesFor(src).filter((h) => (
|
|
1063
|
+
h.hp > 0 && h !== tgt && (isPlayerCharacterSide(h.side) || isAllySide(h.side))
|
|
1064
|
+
));
|
|
1065
|
+
for (let i = pool.length - 1; i > 0; i--) {
|
|
1066
|
+
const j = rng.int(0, i);
|
|
1067
|
+
const tmp = pool[i]!;
|
|
1068
|
+
pool[i] = pool[j]!;
|
|
1069
|
+
pool[j] = tmp;
|
|
1070
|
+
}
|
|
1071
|
+
const n = Math.min(9, pool.length);
|
|
1072
|
+
for (let i = 0; i < n; i++) applySpell(src, pool[i]!, spell);
|
|
1073
|
+
}
|
|
1074
|
+
if ((spell.id | 0) === 28374 && ((src.spec.creatureEntry ?? 0) | 0) === 15932 && tgt.hp > 0) {
|
|
1075
|
+
// Players, pets, Zombie Chow 16360 drop to 5% via 28375. Cite: boss_gluth.cpp:162-182.
|
|
1076
|
+
const playerish = isPlayerCharacterSide(tgt.side) || isAllySide(tgt.side);
|
|
1077
|
+
const chow = ((tgt.spec.creatureEntry ?? 0) | 0) === 16360;
|
|
1078
|
+
if (playerish || chow) {
|
|
1079
|
+
const floor = Math.max(1, Math.floor(tgt.spec.maxHealth * 0.05));
|
|
1080
|
+
const dmg = tgt.hp - floor;
|
|
1081
|
+
if (dmg > 0) {
|
|
1082
|
+
const bolt = src.spec.spells.find((s) => (s.id | 0) === 28375);
|
|
1083
|
+
if (bolt) {
|
|
1084
|
+
const scaled = {
|
|
1085
|
+
...bolt,
|
|
1086
|
+
effects: bolt.effects.map((e) => (
|
|
1087
|
+
e.kind === 'direct_damage' ? { ...e, min: dmg, max: dmg } : e
|
|
1088
|
+
)),
|
|
1089
|
+
};
|
|
1090
|
+
applySpell(src, tgt, scaled);
|
|
1091
|
+
} else {
|
|
1092
|
+
tgt.hp = floor;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
if ((spell.id | 0) === 29317 && ((src.spec.creatureEntry ?? 0) | 0) === 16060
|
|
1098
|
+
&& tgt.side !== src.side && tgt.hp > 0 && gothikShadowboltBounceAt.get(src) !== t) {
|
|
1099
|
+
// EFFECT_INDEX_1 SCRIPT 50% recast at a random player. Cite: boss_gothik.cpp:516-535.
|
|
1100
|
+
if (rng.int(0, 1) === 1) {
|
|
1101
|
+
const pool = livingHostilesFor(src).filter((h) => (
|
|
1102
|
+
h.hp > 0 && (isPlayerCharacterSide(h.side) || isAllySide(h.side))
|
|
1103
|
+
));
|
|
1104
|
+
if (pool.length > 0) {
|
|
1105
|
+
gothikShadowboltBounceAt.set(src, t);
|
|
1106
|
+
applySpell(src, pool[rng.int(0, pool.length - 1)]!, spell);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if ((spell.id | 0) === HAKKAR_BLOOD_SIPHON_SPELL_ID && tgt.side !== src.side && tgt.hp > 0) {
|
|
1111
|
+
// target->CastSpell(caster, HasAura(24321) ? 24323 : 24322). Cite: boss_hakkar.cpp:126.
|
|
1112
|
+
const siphonId = tgt.buffActive.has(POISONOUS_BLOOD_CLOUD_SPELL_ID)
|
|
1113
|
+
? HAKKAR_BLOOD_SIPHON_POISON_SPELL_ID
|
|
1114
|
+
: HAKKAR_BLOOD_SIPHON_FUNNEL_SPELL_ID;
|
|
1115
|
+
const siphon = src.spec.spells.find((s) => (s.id | 0) === siphonId);
|
|
1116
|
+
if (siphon) applySpell(tgt, src, siphon);
|
|
1117
|
+
}
|
|
1118
|
+
if ((spell.id | 0) === CHROMAGGUS_BREATH_SELECTION_SPELL_ID && tgt.side === src.side) {
|
|
1119
|
+
// Ctor DoCast 23195 once; instance stores the pair for the lifetime.
|
|
1120
|
+
// Cite: boss_chromaggus.cpp:60; SpellEffects.cpp:993-1010; blackwing_lair.cpp:644-652.
|
|
1121
|
+
const store = ctx.instanceData;
|
|
1122
|
+
if ((store.get(TYPE_CHROMA_LBREATH) ?? 0) === 0) {
|
|
1123
|
+
const pair = pickChromaggusBreathPair(rng.next(), rng.next());
|
|
1124
|
+
store.set(TYPE_CHROMA_LBREATH, pair.left);
|
|
1125
|
+
store.set(TYPE_CHROMA_RBREATH, pair.right);
|
|
1126
|
+
emitSimEffect(ctx, { kind: 'instanceData', store: new Map(store), openDoors: [] });
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if ((spell.id | 0) === CORRUPTED_MIND_PRIMER_SPELL_ID && tgt.side === src.side) {
|
|
1130
|
+
// Dummy 3 PBAoE; OnEffectExecute class-branch. Cite: boss_loatheb.cpp:89-117.
|
|
1131
|
+
const radius = spell.radiusYd ?? 0;
|
|
1132
|
+
const lockoutMs = durMs(3) ?? 0;
|
|
1133
|
+
if (lockoutMs > 0) {
|
|
1134
|
+
for (const h of livingHostilesFor(src)) {
|
|
1135
|
+
if (h.hp <= 0) continue;
|
|
1136
|
+
const lockoutId = corruptedMindLockoutSpellId(h.spec.classId ?? 0);
|
|
1137
|
+
if (lockoutId == null) continue;
|
|
1138
|
+
if (radius > 0) {
|
|
1139
|
+
const d = distanceBetween(src, h);
|
|
1140
|
+
if (d != null && d > radius) continue;
|
|
1141
|
+
}
|
|
1142
|
+
h.buffActive.add(lockoutId);
|
|
1143
|
+
effects.push({
|
|
1144
|
+
kind: 'buff',
|
|
1145
|
+
source: src.side,
|
|
1146
|
+
target: h.side,
|
|
1147
|
+
spellId: lockoutId,
|
|
1148
|
+
name: 'Corrupted Mind',
|
|
1149
|
+
expiresAt: t + lockoutMs,
|
|
1150
|
+
});
|
|
1151
|
+
push({ t, kind: 'aura', actor: h.side, name: 'Corrupted Mind', applied: true, spellId: lockoutId });
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if ((spell.id | 0) === WIDOWS_EMBRACE_SPELL_ID) {
|
|
1156
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, id: number) => number) | undefined)?.(
|
|
1157
|
+
tgt,
|
|
1158
|
+
FAERLINA_ENRAGE_SPELL_ID,
|
|
1159
|
+
);
|
|
1160
|
+
const lockoutMs = widowsEmbraceLockoutMs(
|
|
1161
|
+
spell.effects.flatMap((e) => ('durationMs' in e && e.durationMs ? [e.durationMs] : []))[0],
|
|
1162
|
+
);
|
|
1163
|
+
if (lockoutMs > 0) {
|
|
1164
|
+
tgt.buffActive.add(WIDOWS_EMBRACE_CD_SPELL_ID);
|
|
1165
|
+
effects.push({
|
|
1166
|
+
kind: 'buff',
|
|
1167
|
+
source: src.side,
|
|
1168
|
+
target: tgt.side,
|
|
1169
|
+
spellId: WIDOWS_EMBRACE_CD_SPELL_ID,
|
|
1170
|
+
name: "Widow's Embrace",
|
|
1171
|
+
expiresAt: t + lockoutMs,
|
|
1172
|
+
});
|
|
1173
|
+
push({ t, kind: 'aura', actor: tgt.side, name: "Widow's Embrace", applied: true, spellId: WIDOWS_EMBRACE_CD_SPELL_ID });
|
|
1174
|
+
}
|
|
1175
|
+
// The add spends its life on the cast (effect index 1 → 28748 on the CASTER). Only a creature body
|
|
1176
|
+
// dies: the player character is never suicided by this, whatever it is controlling.
|
|
1177
|
+
if (src.side !== 'player' && src.spec.creatureEntry != null && src.hp > 0) {
|
|
1178
|
+
src.hp = 0;
|
|
1179
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: src.side, spellId: WIDOWS_EMBRACE_INSTAKILL_SPELL_ID, name: 'Instakill Self', result: 'hit', amount: 0, school: spell.school });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
if ((spell.id | 0) === MERCURIAL_SHIELD_PEEL_SPELL_ID) {
|
|
1183
|
+
(ctx.stripAuraOnDispel as ((u: Fighter, id: number, n: number) => void) | undefined)?.(
|
|
1184
|
+
src,
|
|
1185
|
+
MERCURIAL_SHIELD_STACK_SPELL_ID,
|
|
1186
|
+
1,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Caster-side script riders (Improved Scorpid Sting stamina). Cite: SpellAuras.cpp:3453-3473.
|
|
1192
|
+
const castEffects = effectsWithScriptRiders(
|
|
1193
|
+
spell,
|
|
1194
|
+
(scriptId) => unitHasOverrideScript(src, scriptId),
|
|
1195
|
+
) as typeof spell.effects;
|
|
1196
|
+
// IsImmuneToSpellEffect is per effect INDEX, so a half-immune spell still runs its other half:
|
|
1197
|
+
// Thunder Clap keeps its damage and drops the attack slow. An effect with no slot is never gated.
|
|
1198
|
+
const immuneSlot = (eff: { effectSlot?: number }): boolean => {
|
|
1199
|
+
if (!hostileSpell || eff.effectSlot == null) return false;
|
|
1200
|
+
const raw = spell.rawEffects?.[eff.effectSlot];
|
|
1201
|
+
if (!raw) return false;
|
|
1202
|
+
return isImmuneToSpellEffectSlot(
|
|
1203
|
+
tgt.immunity,
|
|
1204
|
+
raw,
|
|
1205
|
+
{
|
|
1206
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
1207
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
1208
|
+
immunities: tgt.spec.immunities,
|
|
1209
|
+
},
|
|
1210
|
+
hasAttr(spell, SPELL_ATTR.NO_IMMUNITIES),
|
|
1211
|
+
);
|
|
1212
|
+
};
|
|
1213
|
+
// Multirank identity for this cast, mirrored onto every buff/resist row it leaves so a later
|
|
1214
|
+
// sibling in the same class spell family can strip it with no data-store lookup.
|
|
1215
|
+
const stackIdent = auraStackIdentity(spell);
|
|
1216
|
+
const stackFields = stackIdent ? { stackIdent } : {};
|
|
1217
|
+
// A row this cast must replace: same exclusive spell_group, or the same family+aura pair.
|
|
1218
|
+
const isStackSibling = (e: ActiveEffect, group: number | undefined): boolean =>
|
|
1219
|
+
(group != null && group !== 0 && e.exclusionGroup === group)
|
|
1220
|
+
|| stackIdentitiesExclude(stackIdent, e.stackIdent);
|
|
1221
|
+
// A stronger multirank sibling already on the unit refuses this apply outright, so Gift of the
|
|
1222
|
+
// Wild rank 1 cannot downgrade Mark of the Wild rank 7. Cite: Unit.cpp:5013-5015.
|
|
1223
|
+
const strongerSiblingOn = (side: Side): boolean =>
|
|
1224
|
+
stackIdent != null
|
|
1225
|
+
&& ctx.effectsOn(side).some(
|
|
1226
|
+
(e) =>
|
|
1227
|
+
e.spellId !== spell.id
|
|
1228
|
+
&& stackIdentitiesExclude(stackIdent, e.stackIdent)
|
|
1229
|
+
&& existingStackIsStronger(stackIdent, e.stackIdent),
|
|
1230
|
+
);
|
|
1231
|
+
// One paladin aura per caster: a new area aura in an exclusive group removes the holder's previous
|
|
1232
|
+
// one from the caster and so from every carrier. Cite: Unit.cpp:4907; SpellAuras.cpp:597.
|
|
1233
|
+
if (isPartyAreaAura(spell) && spell.exclusionGroup) {
|
|
1234
|
+
stripHolderRowsInGroup(
|
|
1235
|
+
effects, src.side, spell.id, spell.exclusionGroup,
|
|
1236
|
+
(id) => ctx.spellOf?.(id)?.exclusionGroup, bySide, reverseActiveEffect,
|
|
1237
|
+
ctx.effectsFrom(src.side),
|
|
1238
|
+
);
|
|
1239
|
+
}
|
|
1240
|
+
for (const eff of castEffects) {
|
|
1241
|
+
if (immuneSlot(eff)) continue;
|
|
1242
|
+
// Per-effect CP value add (Object.cpp CalculateSpellEffectValue).
|
|
1243
|
+
const ppc = ('pointsPerComboPoint' in eff && eff.pointsPerComboPoint && hasAttrEx(spell, SPELL_ATTR_EX.FINISHING_MOVE_DAMAGE)) ? eff.pointsPerComboPoint * cp : 0;
|
|
1244
|
+
// Duration finishers / Rupture: scale base duration toward durationMaxMs by CP/5.
|
|
1245
|
+
const durFor = (base: number): number => {
|
|
1246
|
+
const scaled = scaleDurationByCp(base, spell.durationMaxMs, isFinisher || spell.durationMaxMs != null ? cp : 0);
|
|
1247
|
+
const withDur = applyOwnedSpellMod(src, spell, SPELLMOD_DURATION, scaled);
|
|
1248
|
+
return applyOwnedSpellMod(src, spell, SPELLMOD_ALL_EFFECTS, withDur);
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
if (eff.kind === 'direct_damage') {
|
|
1252
|
+
// hostile:false is TARGET_UNIT_CASTER — the row burns whoever cast it, and lands only on a
|
|
1253
|
+
// self-cast. 40 src_spell_template rows are that shape (every backfire, Sacrifice, Dark
|
|
1254
|
+
// Rune). Everything else keeps the friendly-fire guard that stops Frost Nova hitting its
|
|
1255
|
+
// own caster. Cite: effects-core.ts selfHit; SpellEffects.cpp EffectSchoolDMG.
|
|
1256
|
+
if (eff.hostile === false) {
|
|
1257
|
+
if (tgt !== src) continue;
|
|
1258
|
+
} else if (isEnemySide(src.side) === isEnemySide(tgt.side)) continue;
|
|
1259
|
+
// Hit, miss or full resist, shared with the root and other effects through rollHostileLand.
|
|
1260
|
+
// The binary and dmgClass inputs come from the ETL or spell_template when present.
|
|
1261
|
+
const binary = resolveSpellBinary(spell);
|
|
1262
|
+
if (!rollHostileLand(eff.school)) continue;
|
|
1263
|
+
let spellCrit = applyOwnedSpellMod(
|
|
1264
|
+
src, spell, SPELLMOD_CRITICAL_CHANCE,
|
|
1265
|
+
fighterSpellCrit(src) * 100 + ((spell ? (spell as { _talentCritBonus?: number })._talentCritBonus : 0) || 0),
|
|
1266
|
+
) / 100 + (src.castCritAdd ?? 0);
|
|
1267
|
+
// SPELL_AURA_MOD_ATTACKER_SPELL_CRIT_CHANCE on victim. Cite: Unit.cpp:3530–3531.
|
|
1268
|
+
spellCrit += (tgt.attackerSpellCritBySchool[eff.school] ?? 0) / 100;
|
|
1269
|
+
// Shatter: +10..50 crit vs frozen. Cite: Mage.cpp:58–70 ShatterMage.
|
|
1270
|
+
{
|
|
1271
|
+
const known = new Set<number>([
|
|
1272
|
+
...src.spec.spells.map((s) => s.id),
|
|
1273
|
+
...src.buffActive,
|
|
1274
|
+
]);
|
|
1275
|
+
const shatterPts = shatterFrozenCritBonus(known);
|
|
1276
|
+
if (shatterPts > 0 && targetIsFrozenForShatter({ auras: effects, targetSide: tgt.side, now: t })) {
|
|
1277
|
+
spellCrit += shatterPts / 100;
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
// EX2_CANT_CRIT plus the IsSpellAbleToCrit effect filter — a spell with no crittable
|
|
1281
|
+
// effect never rolls. Cite: SpellMgr.h:307-339; Unit.cpp:3878 RollSpellCritOutcome.
|
|
1282
|
+
const crit = spellCanCrit(spell)
|
|
1283
|
+
&& rng.chance(spellCritChance(spellCrit, src.spec.level, tgt.spec.level, spell.dmgClass));
|
|
1284
|
+
let raw = rollBetween(eff.min + ppc, eff.max + ppc, rng.next());
|
|
1285
|
+
raw = applyOwnedSpellMod(src, spell, SPELLMOD_DAMAGE, raw);
|
|
1286
|
+
raw = applyOwnedSpellMod(src, spell, SPELLMOD_ALL_EFFECTS, raw);
|
|
1287
|
+
if (src.castEffectPct) raw *= (1 + src.castEffectPct / 100); // Amplify Curse (empowered cast)
|
|
1288
|
+
// Eviscerate AP term — CMaNGOS SpellEffects.cpp:330: damage += AP * combo * 0.03f
|
|
1289
|
+
if (spell.comboApCoeff && cp > 0 && hasAttrEx(spell, SPELL_ATTR_EX.FINISHING_MOVE_DAMAGE)) raw += src.attackPower * cp * spell.comboApCoeff;
|
|
1290
|
+
// Runtime SpellDamageBonusDone when coeffs present; else baked min/max unchanged.
|
|
1291
|
+
raw = applySpellDamageBonusIfPresent(raw, spell, eff.effectSlot ?? 0, {
|
|
1292
|
+
spellPower: src.spec.spellPower,
|
|
1293
|
+
spellDamageBySchool: src.spec.spellDamageBySchool,
|
|
1294
|
+
school: eff.school,
|
|
1295
|
+
attackPower: src.attackPower,
|
|
1296
|
+
// school % still applied below for talent path when no coeff; with coeff,
|
|
1297
|
+
// spellBonus folds damagePct — avoid double-applying when coeffs present.
|
|
1298
|
+
damagePct: spell.effectBonusCoefficient?.some((c) => c > 0) || spell.effectBonusCoefficientFromAP?.some((c) => c > 0)
|
|
1299
|
+
? (src.damagePctBySchool[eff.school] ?? 0)
|
|
1300
|
+
: 0,
|
|
1301
|
+
});
|
|
1302
|
+
const ignoreCasterModifiers = hasAttrEx3(spell, SPELL_ATTR_EX3.IGNORE_CASTER_MODIFIERS);
|
|
1303
|
+
if (!ignoreCasterModifiers) {
|
|
1304
|
+
// MOD_FLAT_SPELL_DAMAGE_VERSUS (180). Cite: Unit.cpp:7055 DoneTotal += …
|
|
1305
|
+
raw += sumVersusForCreatureType(src.spellDamageVersus, tgt.creatureType);
|
|
1306
|
+
// MOD_DAMAGE_DONE_CREATURE (59). Cite: Unit.cpp:7061 DoneTotal += …
|
|
1307
|
+
raw += sumVersusForCreatureType(src.damageDoneCreature, tgt.creatureType);
|
|
1308
|
+
}
|
|
1309
|
+
// Aura 169 raises the crit multiplier vs a creature type. Cite: Unit.cpp:3561.
|
|
1310
|
+
if (crit && !ignoreCasterModifiers) raw *= critMultiplierWithVersus(TUNING.spellCritMult, src.critDamageVersusPct, tgt.creatureType);
|
|
1311
|
+
const dmgPct = src.damagePctBySchool[eff.school] ?? 0;
|
|
1312
|
+
const usedCoeffBonus = !!(spell.effectBonusCoefficient?.some((c) => (c ?? 0) > 0)
|
|
1313
|
+
|| spell.effectBonusCoefficientFromAP?.some((c) => (c ?? 0) > 0));
|
|
1314
|
+
if (!ignoreCasterModifiers && dmgPct && !usedCoeffBonus) raw *= (1 + dmgPct / 100);
|
|
1315
|
+
// MOD_DAMAGE_DONE_VERSUS (168) multiplier. Cite: Unit.cpp:7058 DoneTotalMod *= …
|
|
1316
|
+
const versusPct = sumVersusForCreatureType(src.damageDoneVersusPct, tgt.creatureType);
|
|
1317
|
+
if (!ignoreCasterModifiers && versusPct) raw *= (1 + versusPct / 100);
|
|
1318
|
+
// JoC: unstunned target takes half. Cite: Paladin.cpp:160-161 SetDamage(/2).
|
|
1319
|
+
if (JUDGEMENT_OF_COMMAND_DAMAGE_IDS.has(spell.id | 0) && !(tgt.stunnedUntil > t)) {
|
|
1320
|
+
raw = Math.trunc(raw / 2);
|
|
1321
|
+
}
|
|
1322
|
+
const amount = dealDamage(tgt, raw, eff.school, src.spec.level, {
|
|
1323
|
+
binary, damageType: 'spell', attacker: src, spell, crit,
|
|
1324
|
+
});
|
|
1325
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: crit ? 'crit' : 'hit', amount, school: eff.school });
|
|
1326
|
+
finisherHit = true;
|
|
1327
|
+
// Warrior Execute dumps the remaining rage on a successful hit, since it is already folded
|
|
1328
|
+
// into the damage. A miss keeps the leftover rage after the cast cost. Cite: Warrior.cpp:32.
|
|
1329
|
+
if (isWarriorExecuteSpell(spell) && src.spec.powerType === 'rage' && src.power > 0) {
|
|
1330
|
+
const before = src.power;
|
|
1331
|
+
src.power = 0;
|
|
1332
|
+
push({
|
|
1333
|
+
t, kind: 'resource', actor: src.side, power: 'rage', value: 0, delta: -before,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
if (amount > 0) {
|
|
1337
|
+
applySpellThreatBonus(src, spell, tgt && isEnemySide(tgt.side) ? tgt : undefined, hitCount);
|
|
1338
|
+
triggerProcs(src, tgt, !!crit, 'spell', spell);
|
|
1339
|
+
rollGearProcs(src, tgt, crit ? 'crit' : 'hit', spell);
|
|
1340
|
+
// Execute / Bloodthirst are melee-class school damage and never reach weaponStrike.
|
|
1341
|
+
// Cite: UnitAuraProcHandler.cpp:810-841 PROC_FLAG_DEAL_MELEE_ABILITY.
|
|
1342
|
+
if ((spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.MELEE) {
|
|
1343
|
+
const extra = ctx.trySweepingStrikesExtra;
|
|
1344
|
+
if (typeof extra === 'function') extra(src, tgt, amount, spell, ignoreCasterModifiers);
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
} else if (
|
|
1348
|
+
eff.kind === 'persistent_area'
|
|
1349
|
+
|| (eff.kind === 'dot' && isPersistentAreaEffect(spell, eff))
|
|
1350
|
+
) {
|
|
1351
|
+
// PERSISTENT_AREA_AURA (27) is a session ground pool, not a unit-applied DoT — an explicit
|
|
1352
|
+
// kind or the equivalent bake. One pool per caster and spell; a recast refreshes it.
|
|
1353
|
+
const paaSchool = eff.school;
|
|
1354
|
+
const paaMin = eff.min;
|
|
1355
|
+
const paaMax = eff.max;
|
|
1356
|
+
const paaDur = eff.durationMs;
|
|
1357
|
+
const paaTick = eff.tickMs;
|
|
1358
|
+
// Radius: effect.radius (explicit PAA) or SpellDef.radiusYd (SpellRadius.dbc bake).
|
|
1359
|
+
// Cite: dynamicObject.persistentAreaFromSpell; game-data Consecration 26573 radiusYd=8.
|
|
1360
|
+
const paaRadius =
|
|
1361
|
+
(eff.kind === 'persistent_area' && eff.radius != null && eff.radius > 0
|
|
1362
|
+
? eff.radius
|
|
1363
|
+
: undefined)
|
|
1364
|
+
?? (spell.radiusYd != null && spell.radiusYd > 0 ? spell.radiusYd : undefined);
|
|
1365
|
+
const dur = durFor(paaDur);
|
|
1366
|
+
let dmin = paaMin + ppc, dmax = paaMax + ppc;
|
|
1367
|
+
if (src.castEffectPct) {
|
|
1368
|
+
const f2 = 1 + src.castEffectPct / 100;
|
|
1369
|
+
dmin *= f2;
|
|
1370
|
+
dmax *= f2;
|
|
1371
|
+
}
|
|
1372
|
+
dmin = applySpellDamageBonusIfPresent(dmin, spell, eff.effectSlot ?? 0, {
|
|
1373
|
+
spellPower: src.spec.spellPower,
|
|
1374
|
+
spellDamageBySchool: src.spec.spellDamageBySchool,
|
|
1375
|
+
school: paaSchool,
|
|
1376
|
+
attackPower: src.attackPower,
|
|
1377
|
+
damagePct: src.damagePctBySchool[paaSchool] ?? 0,
|
|
1378
|
+
});
|
|
1379
|
+
dmax = applySpellDamageBonusIfPresent(dmax, spell, eff.effectSlot ?? 0, {
|
|
1380
|
+
spellPower: src.spec.spellPower,
|
|
1381
|
+
spellDamageBySchool: src.spec.spellDamageBySchool,
|
|
1382
|
+
school: paaSchool,
|
|
1383
|
+
attackPower: src.attackPower,
|
|
1384
|
+
damagePct: src.damagePctBySchool[paaSchool] ?? 0,
|
|
1385
|
+
});
|
|
1386
|
+
const ignoreCasterModifiers = hasAttrEx3(spell, SPELL_ATTR_EX3.IGNORE_CASTER_MODIFIERS);
|
|
1387
|
+
const dmgPct = src.damagePctBySchool[paaSchool] ?? 0;
|
|
1388
|
+
const usedCoeffBonus = !!(spell.effectBonusCoefficient?.some((c) => (c ?? 0) > 0)
|
|
1389
|
+
|| spell.effectBonusCoefficientFromAP?.some((c) => (c ?? 0) > 0));
|
|
1390
|
+
if (!ignoreCasterModifiers && dmgPct && !usedCoeffBonus) {
|
|
1391
|
+
dmin = Math.round(dmin * (1 + dmgPct / 100));
|
|
1392
|
+
dmax = Math.round(dmax * (1 + dmgPct / 100));
|
|
1393
|
+
} else {
|
|
1394
|
+
dmin = Math.round(dmin);
|
|
1395
|
+
dmax = Math.round(dmax);
|
|
1396
|
+
}
|
|
1397
|
+
// The pool origin is the cast-time position, NOT the live caster: a dest-bearing player cast
|
|
1398
|
+
// uses castDest, and otherwise targetA decides. Snapshot, so the pool stays put as bodies walk.
|
|
1399
|
+
const posOf = ctx.posOf;
|
|
1400
|
+
const destPos = src.castDest;
|
|
1401
|
+
const originSide = spellAoeFromCaster(spell) ? src.side : tgt.side;
|
|
1402
|
+
const originPos = destPos ?? posOf?.(originSide);
|
|
1403
|
+
const poolOrigin =
|
|
1404
|
+
originPos != null
|
|
1405
|
+
? {
|
|
1406
|
+
x: originPos.x,
|
|
1407
|
+
y: originPos.y,
|
|
1408
|
+
...(originPos.z != null && Number.isFinite(originPos.z) ? { z: originPos.z } : {}),
|
|
1409
|
+
}
|
|
1410
|
+
: undefined;
|
|
1411
|
+
const pool = createGroundPool({
|
|
1412
|
+
id: nextGroundPoolId(),
|
|
1413
|
+
spellId: spell.id,
|
|
1414
|
+
name: spell.name,
|
|
1415
|
+
source: src.side,
|
|
1416
|
+
school: paaSchool,
|
|
1417
|
+
min: dmin,
|
|
1418
|
+
max: dmax,
|
|
1419
|
+
durationMs: dur,
|
|
1420
|
+
tickMs: paaTick,
|
|
1421
|
+
nowMs: t,
|
|
1422
|
+
radius: paaRadius,
|
|
1423
|
+
origin: poolOrigin,
|
|
1424
|
+
binary: resolveSpellBinary(spell),
|
|
1425
|
+
effIndex: eff.effectSlot ?? 0,
|
|
1426
|
+
laterPersistentEffect: hasLaterPersistentAreaOrFarsight(spell, eff.effectSlot ?? 0),
|
|
1427
|
+
onlyOnPlayer: hasAttrEx3(spell, SPELL_ATTR_EX3.ONLY_ON_PLAYER),
|
|
1428
|
+
});
|
|
1429
|
+
upsertGroundPool(groundPools, pool);
|
|
1430
|
+
ctx.touchGroundPool(pool);
|
|
1431
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true, spellId: spell.id });
|
|
1432
|
+
finisherHit = true;
|
|
1433
|
+
} else if (eff.kind === 'activate_object') {
|
|
1434
|
+
// The floor half of ACTIVATE_OBJECT: a named type-6 trap fires its payload where it stands.
|
|
1435
|
+
// Nothing else in the effect list moves, and a spell whose objects are doors lays nothing.
|
|
1436
|
+
const laid = laySpellTrapSites(ctx, cfg, spell.id, eff.effectSlot ?? 0, src.side);
|
|
1437
|
+
if (laid > 0) {
|
|
1438
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true, spellId: spell.id });
|
|
1439
|
+
}
|
|
1440
|
+
} else if (eff.kind === 'summon_object_wild') {
|
|
1441
|
+
// A trap GO whose spell damages IS a ground pool: it stands where the cast landed, casts on
|
|
1442
|
+
// whoever is inside and re-arms on its cooldown. Every number is read off the trap spell bake.
|
|
1443
|
+
const trapPool = trapObjectPoolFor(eff.goEntry | 0);
|
|
1444
|
+
const trapKit = trapPool
|
|
1445
|
+
? src.spec.spells.find((s) => (s.id | 0) === trapPool.spellId)
|
|
1446
|
+
: undefined;
|
|
1447
|
+
const trapSpell = trapKit ? resolveCombatSpell(trapKit) : null;
|
|
1448
|
+
// A dot payload (Conflagration) ticks on its own amplitude; a one-shot (Blaze) on the re-arm.
|
|
1449
|
+
const trapHit = trapSpell?.effects.find(
|
|
1450
|
+
(te): te is Extract<SpellEffect, { kind: 'dot' | 'direct_damage' }> =>
|
|
1451
|
+
te.kind === 'dot' || te.kind === 'direct_damage',
|
|
1452
|
+
);
|
|
1453
|
+
const trapRadius = trapSpell?.radiusYd != null && trapSpell.radiusYd > 0
|
|
1454
|
+
? trapSpell.radiusYd
|
|
1455
|
+
: undefined;
|
|
1456
|
+
if (trapPool && trapSpell && trapHit && trapRadius != null) {
|
|
1457
|
+
const trapOrigin = src.castDest ?? ctx.posOf?.(tgt.side);
|
|
1458
|
+
const trapGround = createGroundPool({
|
|
1459
|
+
id: nextGroundPoolId(),
|
|
1460
|
+
spellId: spell.id,
|
|
1461
|
+
name: trapSpell.name,
|
|
1462
|
+
source: src.side,
|
|
1463
|
+
school: trapHit.school,
|
|
1464
|
+
min: trapHit.min,
|
|
1465
|
+
max: trapHit.max,
|
|
1466
|
+
// The GO's own life, off the summon effect — not the payload's aura duration.
|
|
1467
|
+
durationMs: eff.durationMs | 0,
|
|
1468
|
+
tickMs: trapHit.kind === 'dot' && trapHit.tickMs > 0 ? trapHit.tickMs : trapPool.cooldownMs,
|
|
1469
|
+
nowMs: t,
|
|
1470
|
+
radius: trapRadius,
|
|
1471
|
+
...(trapOrigin != null
|
|
1472
|
+
? {
|
|
1473
|
+
origin: {
|
|
1474
|
+
x: trapOrigin.x,
|
|
1475
|
+
y: trapOrigin.y,
|
|
1476
|
+
...(trapOrigin.z != null && Number.isFinite(trapOrigin.z) ? { z: trapOrigin.z } : {}),
|
|
1477
|
+
},
|
|
1478
|
+
}
|
|
1479
|
+
: {}),
|
|
1480
|
+
binary: resolveSpellBinary(trapSpell),
|
|
1481
|
+
effIndex: eff.effectSlot ?? 0,
|
|
1482
|
+
laterPersistentEffect: false,
|
|
1483
|
+
onlyOnPlayer: hasAttrEx3(trapSpell, SPELL_ATTR_EX3.ONLY_ON_PLAYER),
|
|
1484
|
+
});
|
|
1485
|
+
upsertGroundPool(groundPools, trapGround);
|
|
1486
|
+
ctx.touchGroundPool(trapGround);
|
|
1487
|
+
push({ t, kind: 'aura', actor: src.side, name: trapSpell.name, applied: true, spellId: trapSpell.id });
|
|
1488
|
+
finisherHit = true;
|
|
1489
|
+
}
|
|
1490
|
+
} else if (eff.kind === 'dot') {
|
|
1491
|
+
// Totem.cpp:282–287 — negative auras including PERIODIC_DAMAGE never land.
|
|
1492
|
+
if (!isSelfTargetedDot(spell, eff)
|
|
1493
|
+
&& (isTotemCreatureType(tgt.creatureType) || isTotemSide(tgt.side))) {
|
|
1494
|
+
push({ t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name, result: 'immune', amount: 0, school: spell.school });
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
// Self-cost PERIODIC_DAMAGE (Hellfire tgtA=1) lands on the caster, not the foe.
|
|
1498
|
+
// Cite: Spell.dbc 1949 EffectImplicitTargetA; isSelfTargetedDot; KI-878.
|
|
1499
|
+
const selfDot = isSelfTargetedDot(spell, eff);
|
|
1500
|
+
const dotTgt = selfDot ? src : tgt;
|
|
1501
|
+
// One SpellHitResult gates the aura too: a resisted Holy Fire lands no DoT. Self-cost dots
|
|
1502
|
+
// skip the foe hit roll, since a unit always hits itself.
|
|
1503
|
+
if (!selfDot && (hostileSpell || (bypassPositiveAutoHit && tgt.side !== src.side)) && !rollHostileLand(eff.school)) continue;
|
|
1504
|
+
// Shadow Flame SCRIPT_EFFECT: skip DoT 22682 when target has Onyxia Scale Cloak dummy.
|
|
1505
|
+
// Cite: SpellEffects.cpp:3756–3760 GetDummyAura(22683).
|
|
1506
|
+
if ((spell.id | 0) === SHADOW_FLAME_SPELL_ID) {
|
|
1507
|
+
const hasCloak = ctx.effectsOn(dotTgt.side).some(
|
|
1508
|
+
(e) => e.spellId === ONYXIA_SCALE_CLOAK_SPELL_ID && e.expiresAt > t,
|
|
1509
|
+
) || dotTgt.buffActive.has(ONYXIA_SCALE_CLOAK_SPELL_ID);
|
|
1510
|
+
if (!shadowFlameAppliesDot(hasCloak)) {
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// Recasting a DoT REFRESHES the running instance rather than stacking a second copy: one per
|
|
1515
|
+
// caster, spell and target. A stackAmount above 1 may stack; absent is refresh-only.
|
|
1516
|
+
const dur = durFor(eff.durationMs);
|
|
1517
|
+
let dmin = eff.min + ppc, dmax = eff.max + ppc;
|
|
1518
|
+
dmin = applyOwnedSpellMod(src, spell, SPELLMOD_DOT, dmin);
|
|
1519
|
+
dmax = applyOwnedSpellMod(src, spell, SPELLMOD_DOT, dmax);
|
|
1520
|
+
dmin = applyOwnedSpellMod(src, spell, SPELLMOD_ALL_EFFECTS, dmin);
|
|
1521
|
+
dmax = applyOwnedSpellMod(src, spell, SPELLMOD_ALL_EFFECTS, dmax);
|
|
1522
|
+
if (src.castEffectPct) { const f2 = 1 + src.castEffectPct / 100; dmin *= f2; dmax *= f2; } // Amplify Curse
|
|
1523
|
+
dmin = applySpellDamageBonusIfPresent(dmin, spell, eff.effectSlot ?? 0, {
|
|
1524
|
+
spellPower: src.spec.spellPower,
|
|
1525
|
+
spellDamageBySchool: src.spec.spellDamageBySchool,
|
|
1526
|
+
school: eff.school,
|
|
1527
|
+
attackPower: src.attackPower,
|
|
1528
|
+
damagePct: src.damagePctBySchool[eff.school] ?? 0,
|
|
1529
|
+
});
|
|
1530
|
+
dmax = applySpellDamageBonusIfPresent(dmax, spell, eff.effectSlot ?? 0, {
|
|
1531
|
+
spellPower: src.spec.spellPower,
|
|
1532
|
+
spellDamageBySchool: src.spec.spellDamageBySchool,
|
|
1533
|
+
school: eff.school,
|
|
1534
|
+
attackPower: src.attackPower,
|
|
1535
|
+
damagePct: src.damagePctBySchool[eff.school] ?? 0,
|
|
1536
|
+
});
|
|
1537
|
+
const ignoreCasterModifiers = hasAttrEx3(spell, SPELL_ATTR_EX3.IGNORE_CASTER_MODIFIERS);
|
|
1538
|
+
const dmgPct = src.damagePctBySchool[eff.school] ?? 0;
|
|
1539
|
+
const usedCoeffBonus = !!(spell.effectBonusCoefficient?.some((c) => (c ?? 0) > 0)
|
|
1540
|
+
|| spell.effectBonusCoefficientFromAP?.some((c) => (c ?? 0) > 0));
|
|
1541
|
+
if (!ignoreCasterModifiers && dmgPct && !usedCoeffBonus) {
|
|
1542
|
+
dmin = Math.round(dmin * (1 + dmgPct / 100));
|
|
1543
|
+
dmax = Math.round(dmax * (1 + dmgPct / 100));
|
|
1544
|
+
} else {
|
|
1545
|
+
dmin = Math.round(dmin);
|
|
1546
|
+
dmax = Math.round(dmax);
|
|
1547
|
+
}
|
|
1548
|
+
const binary = resolveSpellBinary(spell);
|
|
1549
|
+
const liveDot = ctx.effectsOn(dotTgt.side).find(
|
|
1550
|
+
(e) => e.kind === 'dot' && e.spellId === spell.id && e.source === src.side,
|
|
1551
|
+
);
|
|
1552
|
+
const plan = planAuraApply(
|
|
1553
|
+
liveDot
|
|
1554
|
+
? [{ spellId: liveDot.spellId, source: liveDot.source, target: liveDot.target, kind: 'dot', stacks: liveDot.stacks, charges: liveDot.charges }]
|
|
1555
|
+
: [],
|
|
1556
|
+
{ spellId: spell.id, source: src.side, target: dotTgt.side, kind: 'dot' },
|
|
1557
|
+
{
|
|
1558
|
+
stackAmount: spell.stackAmount,
|
|
1559
|
+
procCharges: spell.procCharges,
|
|
1560
|
+
matchAnyCaster: hasAttrEx3(spell, SPELL_ATTR_EX3.DOT_STACKING_RULE) ? false : undefined,
|
|
1561
|
+
},
|
|
1562
|
+
);
|
|
1563
|
+
// PERIODIC_LEECH transfers heal = dealt × EffectMultipleValue; HEALTH_FUNNEL is the reverse,
|
|
1564
|
+
// damaging the caster to heal the aura target. Cite: SpellAuras.cpp PeriodicTick :4645.
|
|
1565
|
+
const leechMultiple =
|
|
1566
|
+
typeof eff.leechMultiple === 'number' && eff.leechMultiple > 0
|
|
1567
|
+
? eff.leechMultiple
|
|
1568
|
+
: undefined;
|
|
1569
|
+
const healthFunnel = !!eff.healthFunnel;
|
|
1570
|
+
// durationMs 0 = until-cancelled channel (no DurationIndex) → fight-long like buffs.
|
|
1571
|
+
// Cite: applySpell buff branch expAt; Blood Funnel 24617 has no durationIndex.
|
|
1572
|
+
const expAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
1573
|
+
if (plan.action === 'blocked') {
|
|
1574
|
+
// no-op
|
|
1575
|
+
} else if (plan.action === 'add' || !liveDot) {
|
|
1576
|
+
effects.push({
|
|
1577
|
+
kind: 'dot', source: src.side, target: dotTgt.side, spellId: spell.id, name: spell.name,
|
|
1578
|
+
school: eff.school, min: dmin, max: dmax, nextTickAt: t + eff.tickMs, tickMs: eff.tickMs,
|
|
1579
|
+
expiresAt: expAt, binary, stacks: plan.stacks, charges: plan.charges,
|
|
1580
|
+
// Prefer baked flag; isDrainSoulSpell also covers twin name fallback.
|
|
1581
|
+
grantsSoulShardOnKill: isDrainSoulSpell(spell) || undefined,
|
|
1582
|
+
// Family flags for Pyroclasm (Hellfire/RoF) on DoT ticks.
|
|
1583
|
+
// Cite: UnitAuraProcHandler.cpp:1355–1362.
|
|
1584
|
+
...(spell.spellFamilyFlags != null ? { spellFamilyFlags: spell.spellFamilyFlags } : {}),
|
|
1585
|
+
...(leechMultiple != null ? { leechMultiple } : {}),
|
|
1586
|
+
...(healthFunnel ? { healthFunnel: true } : {}),
|
|
1587
|
+
});
|
|
1588
|
+
// Health funnel damages the caster; aura row still shows on the heal target.
|
|
1589
|
+
// Self-cost dots (Hellfire) show on the caster.
|
|
1590
|
+
push({ t, kind: 'aura', actor: healthFunnel || selfDot ? src.side : dotTgt.side, name: spell.name, applied: true, spellId: spell.id });
|
|
1591
|
+
} else {
|
|
1592
|
+
liveDot.school = eff.school;
|
|
1593
|
+
liveDot.min = dmin; liveDot.max = dmax;
|
|
1594
|
+
liveDot.tickMs = eff.tickMs; liveDot.nextTickAt = t + eff.tickMs; liveDot.expiresAt = expAt;
|
|
1595
|
+
liveDot.binary = binary;
|
|
1596
|
+
touchEffect(liveDot);
|
|
1597
|
+
liveDot.stacks = plan.stacks;
|
|
1598
|
+
if (plan.charges != null) liveDot.charges = plan.charges;
|
|
1599
|
+
if (isDrainSoulSpell(spell)) liveDot.grantsSoulShardOnKill = true;
|
|
1600
|
+
if (spell.spellFamilyFlags != null) liveDot.spellFamilyFlags = spell.spellFamilyFlags;
|
|
1601
|
+
if (leechMultiple != null) liveDot.leechMultiple = leechMultiple;
|
|
1602
|
+
else delete liveDot.leechMultiple;
|
|
1603
|
+
if (healthFunnel) liveDot.healthFunnel = true;
|
|
1604
|
+
else delete liveDot.healthFunnel;
|
|
1605
|
+
}
|
|
1606
|
+
finisherHit = true;
|
|
1607
|
+
} else if (eff.kind === 'root') {
|
|
1608
|
+
// Hostile root never lands on the caster / same team (self-centered Nova primary).
|
|
1609
|
+
if (isEnemySide(src.side) === isEnemySide(tgt.side)) continue;
|
|
1610
|
+
if (isTotemCreatureType(tgt.creatureType) || isTotemSide(tgt.side)) {
|
|
1611
|
+
push({ t, kind: 'cc_immune', target: tgt.side, control: 'root', name: spell.name });
|
|
1612
|
+
continue;
|
|
1613
|
+
}
|
|
1614
|
+
// Free Action / template root immunity. Cite: Unit.cpp IsImmuneToSpellEffect.
|
|
1615
|
+
const rootMech = (eff as { mechanic?: string }).mechanic || spell.mechanic || 'root';
|
|
1616
|
+
if (isImmuneToMechanic(tgt.immunity, rootMech, {
|
|
1617
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
1618
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
1619
|
+
})) {
|
|
1620
|
+
ctx.consumeChargedImmunityOnMechanicBlock?.(tgt, rootMech);
|
|
1621
|
+
push({ t, kind: 'cc_immune', target: tgt.side, control: 'root', name: spell.name });
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
// Hostile root shares the spell's hit roll with damage (binary Frost Nova, etc.).
|
|
1625
|
+
// Cite: Spell.cpp:1211–1212; KI-805 miss must not root.
|
|
1626
|
+
if ((hostileSpell || (bypassPositiveAutoHit && tgt.side !== src.side)) && !rollHostileLand(spell.school)) continue;
|
|
1627
|
+
const dur = durFor(eff.durationMs);
|
|
1628
|
+
// Entangling Roots holds one target per druid. Cite: Unit.cpp:4774-4830; SpellMgr.h:2230.
|
|
1629
|
+
ctx.purgeTrackedAuraTargets?.(src, tgt, spell);
|
|
1630
|
+
tgt.rootedUntil = Math.max(tgt.rootedUntil, t + dur);
|
|
1631
|
+
// The school is the SPELL's, not the effect's: root effects bake without one, and the frozen
|
|
1632
|
+
// aura state and the immunity purge both read the spell's mask. Heartbeat is on the holder.
|
|
1633
|
+
const heartbeat = heartbeatSlotOf(spell) === 'root'
|
|
1634
|
+
? armHeartbeatResist({
|
|
1635
|
+
now: t,
|
|
1636
|
+
missPlusResistPercent: landMissPlusResistPct ?? 0,
|
|
1637
|
+
originalDurationMs: dur,
|
|
1638
|
+
drLevel: drStepOf(tgt, rootMech as Mechanic, t),
|
|
1639
|
+
})
|
|
1640
|
+
: null;
|
|
1641
|
+
effects.push({
|
|
1642
|
+
kind: 'root', source: src.side, target: tgt.side, spellId: spell.id, name: spell.name,
|
|
1643
|
+
school: spell.school, expiresAt: t + dur, appliedAt: t,
|
|
1644
|
+
...(heartbeat ? { heartbeat } : {}),
|
|
1645
|
+
});
|
|
1646
|
+
push({ t, kind: 'root', target: tgt.side, applied: true, spellId: spell.id, name: spell.name });
|
|
1647
|
+
finisherHit = true;
|
|
1648
|
+
} else if (eff.kind === 'cc') {
|
|
1649
|
+
// Self and friendly lockouts that must never land on the foe, skipping resist and DR. Ice Block
|
|
1650
|
+
// is silence only, and Divine Intervention's stun locks the protected member.
|
|
1651
|
+
if (isTotemCreatureType(tgt.creatureType) || isTotemSide(tgt.side)) {
|
|
1652
|
+
push({ t, kind: 'cc_immune', target: tgt.side, control: eff.control, name: spell.name });
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (isIceBlockSpell(spell) || isDivineInterventionSpell(spell)) {
|
|
1656
|
+
const durMs = durFor(eff.durationMs);
|
|
1657
|
+
if (durMs > 0 && (isDivineInterventionSpell(spell) || eff.control === 'silence')) {
|
|
1658
|
+
applyControl(src, src, spell, eff, durMs);
|
|
1659
|
+
}
|
|
1660
|
+
finisherHit = true;
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
// Self-only DBC targeting must never land CC on a foe, while a true self-cast still lands.
|
|
1664
|
+
// Cite: SpellTargetDefines.h TARGET_UNIT_CASTER=1; boss_jandice_barov.cpp:52-55; KI-1017.
|
|
1665
|
+
if (isSelfTargetedSpell(spell) && tgt !== src) continue;
|
|
1666
|
+
// Hostile CC never lands on an ally. Self-stun is allowed (same cite).
|
|
1667
|
+
if (tgt !== src && isEnemySide(src.side) === isEnemySide(tgt.side)) continue;
|
|
1668
|
+
// Immunity first, with no rng. Hostile CC shares the cast's one SpellHitResult — school plus
|
|
1669
|
+
// mechanic resist — rather than rolling a second chance of its own.
|
|
1670
|
+
if (ccImmune(tgt, eff.mechanic, eff.onlyTypes)) { push({ t, kind: 'cc_immune', target: tgt.side, control: eff.control, name: spell.name }); continue; }
|
|
1671
|
+
// Aura mechanic immunity (Free Action / Divine Shield class) — compose with template.
|
|
1672
|
+
// Cite: GAP.md G2 step 3; Unit.cpp IsImmuneToSpellEffect.
|
|
1673
|
+
if (isImmuneToMechanic(tgt.immunity, eff.mechanic, {
|
|
1674
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
1675
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
1676
|
+
})) {
|
|
1677
|
+
ctx.consumeChargedImmunityOnMechanicBlock?.(tgt, eff.mechanic);
|
|
1678
|
+
push({ t, kind: 'cc_immune', target: tgt.side, control: eff.control, name: spell.name });
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
// Creature-type targeting is gated whole-spell at the top now. The same control and mechanic
|
|
1682
|
+
// landing twice this cast must not re-roll resist or re-apply DR; breakOnDamage may upgrade.
|
|
1683
|
+
const ccLandKey = `${tgt.side}|${eff.control}|${eff.mechanic}`;
|
|
1684
|
+
if (appliedCcThisCast.has(ccLandKey)) {
|
|
1685
|
+
if (eff.breakOnDamage === true) {
|
|
1686
|
+
upgradeBreakOnDamageMirror(ctx.effectsOn(tgt.side), tgt.side, spell.id, eff.control, eff.mechanic);
|
|
1687
|
+
}
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
if ((hostileSpell || bypassPositiveAutoHit) && tgt.side !== src.side
|
|
1691
|
+
&& !rollHostileLand(eff.school ?? spell.school, eff.control)) continue;
|
|
1692
|
+
const scaled = durFor(eff.durationMs);
|
|
1693
|
+
const drLevelBefore = drStepOf(tgt, eff.mechanic, t);
|
|
1694
|
+
const durMs = applyDr(src, tgt, eff.mechanic, scaled);
|
|
1695
|
+
if (durMs <= 0) { push({ t, kind: 'cc_immune', target: tgt.side, control: eff.control, name: spell.name }); continue; }
|
|
1696
|
+
// HEARTBEAT_RESIST: arm the early-break clock off the ORIGINAL duration, before DR shortened
|
|
1697
|
+
// it, and off the die chance this cast already rolled. Cite: Spell.cpp:1483-1489.
|
|
1698
|
+
const heartbeat = heartbeatSlotOf(spell) === 'cc'
|
|
1699
|
+
? armHeartbeatResist({
|
|
1700
|
+
now: t,
|
|
1701
|
+
missPlusResistPercent: landMissPlusResistPct ?? 0,
|
|
1702
|
+
originalDurationMs: scaled,
|
|
1703
|
+
drLevel: drLevelBefore,
|
|
1704
|
+
})
|
|
1705
|
+
: null;
|
|
1706
|
+
applyControl(src, tgt, spell, eff, durMs, heartbeat);
|
|
1707
|
+
appliedCcThisCast.add(ccLandKey);
|
|
1708
|
+
// Sap strips MOD_STEALTH on hit (SUPPRESS_TARGET_PROCS would otherwise skip).
|
|
1709
|
+
// Cite: Rogue.cpp:96–99 SapRogue OnHit.
|
|
1710
|
+
if (SAP_SPELL_IDS.has(spell.id | 0) && tgt.stealthed) {
|
|
1711
|
+
breakStealth(tgt, 'sap');
|
|
1712
|
+
}
|
|
1713
|
+
// Banish also grants school-immunity + isolated (MECHANIC_BANISH). Driven by the baked
|
|
1714
|
+
// cc mechanic (Wave C5: Banish ranks bake mechanic:'banish'), not a spell-id allowlist.
|
|
1715
|
+
if (eff.mechanic === 'banish') {
|
|
1716
|
+
grantImmunityAura(tgt, {
|
|
1717
|
+
spellId: spell.id,
|
|
1718
|
+
auraType: AURA_TYPE.SCHOOL_IMMUNITY,
|
|
1719
|
+
schoolMask: SPELL_SCHOOL_MASK_ALL,
|
|
1720
|
+
spellMechanic: MECHANIC.BANISH,
|
|
1721
|
+
positive: false,
|
|
1722
|
+
immunityPurgesEffect: true,
|
|
1723
|
+
durationMs: durMs,
|
|
1724
|
+
}, t + durMs, spell.name, src.side);
|
|
1725
|
+
}
|
|
1726
|
+
finisherHit = true;
|
|
1727
|
+
} else if (eff.kind === 'interrupt_cast') {
|
|
1728
|
+
// INTERRUPT_CAST (68): cancel an interruptible cast and lock out its school for the interrupt
|
|
1729
|
+
// spell's own duration. No cast, or a non-interruptible one, means no lockout.
|
|
1730
|
+
const releaseInterruptClaim = (): void => {
|
|
1731
|
+
if (ctx.interruptClaim.get(tgt.side) === src.side) ctx.interruptClaim.delete(tgt.side);
|
|
1732
|
+
};
|
|
1733
|
+
// EffectInterruptCast walks every non-melee slot, so a CHANNEL is a legal kick target too —
|
|
1734
|
+
// reading only the hardcast slot made Mind Flay and every boss channel immune to Kick.
|
|
1735
|
+
const channelHit = tgt.castingSpell == null && tgt.channeling != null && t < tgt.channeling.endsAt;
|
|
1736
|
+
const casting = tgt.castingSpell ?? (channelHit ? tgt.channeling!.spell : null);
|
|
1737
|
+
// MECHANIC_INTERRUPT immunity, per effect: the strike still lands its damage and the kick just
|
|
1738
|
+
// does not stick. Say so only when a cast was there to lose, or every kick prints a line.
|
|
1739
|
+
if (isImmuneToMechanicId(tgt.immunity, interruptEffectMechanicId(spell), {
|
|
1740
|
+
schoolImmuneMask: tgt.spec.schoolImmuneMask,
|
|
1741
|
+
mechanicImmuneMask: tgt.spec.mechanicImmuneMask,
|
|
1742
|
+
})) {
|
|
1743
|
+
releaseInterruptClaim();
|
|
1744
|
+
if (casting && isCastInterruptible(casting, true)) {
|
|
1745
|
+
push({ t, kind: 'flavor', text: `${tgt.spec.name} is immune to interrupt.` });
|
|
1746
|
+
}
|
|
1747
|
+
continue;
|
|
1748
|
+
}
|
|
1749
|
+
if (!casting || !isCastInterruptible(casting, true)) {
|
|
1750
|
+
releaseInterruptClaim();
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1753
|
+
// Magic pure-interrupt (Counterspell): land/resist roll. Mixed damage+interrupt kits
|
|
1754
|
+
// (Kick/Earth Shock) apply interrupt when the ability resolves — damage has its own roll.
|
|
1755
|
+
const onlyInterrupt = spell.effects.every(
|
|
1756
|
+
(e) => e.kind === 'interrupt_cast' || e.kind === 'trigger_spell',
|
|
1757
|
+
);
|
|
1758
|
+
if (onlyInterrupt) {
|
|
1759
|
+
const binary = resolveSpellBinary(spell);
|
|
1760
|
+
const dmgClass = typeof spell.dmgClass === 'number' ? spell.dmgClass : SPELL_DAMAGE_CLASS.MAGIC;
|
|
1761
|
+
if (isResistableDmgClass(dmgClass) && dmgClass === SPELL_DAMAGE_CLASS.MAGIC) {
|
|
1762
|
+
const land = spellDamageLandChance({
|
|
1763
|
+
atkLevel: src.spec.level,
|
|
1764
|
+
defLevel: tgt.spec.level,
|
|
1765
|
+
school: spell.school,
|
|
1766
|
+
resistance: tgt.resistances[spell.school] ?? 0,
|
|
1767
|
+
binary,
|
|
1768
|
+
resistable: true,
|
|
1769
|
+
spellHitBonusPercent: src.spellHitBonusPercent,
|
|
1770
|
+
attackerSpellHitPercent: tgt.attackerSpellHitBySchool[spell.school] ?? 0,
|
|
1771
|
+
aoeAvoidancePercent: spell.aoe ? (tgt.aoeAvoidancePercent ?? 0) : 0,
|
|
1772
|
+
attackerPlayerControlled: src.side === 'player' || src.side === 'pet' || isAllySide(src.side),
|
|
1773
|
+
victimIsNpc: isEnemySide(tgt.side),
|
|
1774
|
+
victimPlayerControlled: tgt.side === 'player' || tgt.side === 'pet' || isAllySide(tgt.side),
|
|
1775
|
+
});
|
|
1776
|
+
if (!rng.chance(land)) {
|
|
1777
|
+
push({
|
|
1778
|
+
t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name,
|
|
1779
|
+
result: 'miss', amount: 0, school: spell.school,
|
|
1780
|
+
});
|
|
1781
|
+
releaseInterruptClaim();
|
|
1782
|
+
continue;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const lockSchool = casting.school;
|
|
1787
|
+
const lockoutMs = Math.max(0, eff.lockoutMs | 0);
|
|
1788
|
+
const interruptedId = casting.id;
|
|
1789
|
+
const interruptedName = casting.name;
|
|
1790
|
+
if (channelHit) {
|
|
1791
|
+
// Quiet: the interrupt + flavor rows below already name the spell and the lockout.
|
|
1792
|
+
interruptChannel(tgt, channelInterruptCtx(ctx), { quiet: true });
|
|
1793
|
+
} else {
|
|
1794
|
+
tgt.castingSpell = null;
|
|
1795
|
+
tgt.castStartedAt = 0;
|
|
1796
|
+
tgt.castFinishAt = 0;
|
|
1797
|
+
// Kicked before Spell::cast() ran — TakePower never happened, so the held cost is dropped.
|
|
1798
|
+
tgt.castCost = 0;
|
|
1799
|
+
tgt.castCostPower = undefined;
|
|
1800
|
+
}
|
|
1801
|
+
if (lockoutMs > 0) {
|
|
1802
|
+
applySchoolLockout(tgt.schoolLockoutUntil, lockSchool, t + lockoutMs);
|
|
1803
|
+
}
|
|
1804
|
+
releaseInterruptClaim();
|
|
1805
|
+
push({
|
|
1806
|
+
t,
|
|
1807
|
+
kind: 'interrupt',
|
|
1808
|
+
actor: src.side,
|
|
1809
|
+
target: tgt.side,
|
|
1810
|
+
school: lockSchool,
|
|
1811
|
+
lockoutMs,
|
|
1812
|
+
name: spell.name,
|
|
1813
|
+
interruptedSpellId: interruptedId,
|
|
1814
|
+
interruptedName,
|
|
1815
|
+
});
|
|
1816
|
+
finisherHit = true;
|
|
1817
|
+
// Improved Kick/CS/SB: talent procs fire on successful interrupt ability hit.
|
|
1818
|
+
// Only fire here when damage did not already trigger procs (mixed kits).
|
|
1819
|
+
if (onlyInterrupt) {
|
|
1820
|
+
triggerProcs(src, tgt, false, 'spell', spell);
|
|
1821
|
+
rollGearProcs(src, tgt, 'hit', spell);
|
|
1822
|
+
}
|
|
1823
|
+
} else if (eff.kind === 'trigger_spell') {
|
|
1824
|
+
// TRIGGER_SPELL (64): apply the triggered spell's effects, looking in the caster's kit, then
|
|
1825
|
+
// the target's, then the global store — a payload in no kit is real.
|
|
1826
|
+
const trig =
|
|
1827
|
+
src.spec.spells.find((s) => s.id === eff.spellId)
|
|
1828
|
+
?? tgt.spec.spells.find((s) => s.id === eff.spellId)
|
|
1829
|
+
?? ctx.spellOf?.(eff.spellId);
|
|
1830
|
+
if (trig) {
|
|
1831
|
+
// EffectDummy 13489: unitTarget->CastSpell(unitTarget, 14744).
|
|
1832
|
+
// Cite: SpellEffects.cpp:497-502; boss_ambassador_flamelash.cpp:76.
|
|
1833
|
+
if ((spell.id | 0) === 13489) {
|
|
1834
|
+
applySpell(tgt, tgt, trig);
|
|
1835
|
+
finisherHit = true;
|
|
1836
|
+
} else if ((spell.id | 0) === ELEMENTAL_SHIELD_DUMMY_SPELL_ID) {
|
|
1837
|
+
// m_caster->CastSpell(m_caster, elemshields[urand(0,4)], TRIGGERED).
|
|
1838
|
+
// Cite: SpellEffects.cpp:827; never the fight target.
|
|
1839
|
+
applyProc(src, src, trig);
|
|
1840
|
+
finisherHit = true;
|
|
1841
|
+
} else {
|
|
1842
|
+
// KI-175: DI parent triggers protect aura 19753 onto the friendly protect target
|
|
1843
|
+
// (solo → caster), never the hostile fight target.
|
|
1844
|
+
const procTgt = isDivineInterventionCast(spell) ? src : tgt;
|
|
1845
|
+
applyProc(src, procTgt, trig);
|
|
1846
|
+
finisherHit = true;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
} else if (eff.kind === 'cleanse') {
|
|
1850
|
+
// Self-cast control-breaker: strip the listed controls from the CASTER and (with immuneMs)
|
|
1851
|
+
// grant brief immunity to their categories. ControlKind names double as the DR-category key.
|
|
1852
|
+
for (const c of eff.controls) {
|
|
1853
|
+
breakControl(src, c);
|
|
1854
|
+
// Longest immunity wins. An unconditional set let a WEAKER cleanse shorten an immunity
|
|
1855
|
+
// already running, and a shorter re-application never shortens an aura in the core either.
|
|
1856
|
+
if (eff.immuneMs) {
|
|
1857
|
+
const until = t + eff.immuneMs;
|
|
1858
|
+
const cur = src.ccImmuneUntil.get(c) ?? 0;
|
|
1859
|
+
if (until > cur) src.ccImmuneUntil.set(c, until);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true, spellId: spell.id });
|
|
1863
|
+
} else if (eff.kind === 'damage_shield') {
|
|
1864
|
+
// Thorns / LShield-style. Cite: DAMAGE_SHIELD / PROC_TRIGGER_DAMAGE / LShield override.
|
|
1865
|
+
const dur = durFor(eff.durationMs);
|
|
1866
|
+
const charges = (eff.charges ?? spell.procCharges ?? 0) > 0
|
|
1867
|
+
? (eff.charges ?? spell.procCharges!)
|
|
1868
|
+
: undefined;
|
|
1869
|
+
const cd = eff.cooldownMs ?? spell.spellProcEvent?.cooldownMs;
|
|
1870
|
+
// WHO wears it: a self row is the caster, and a row naming a friendly wears on the unit the
|
|
1871
|
+
// cast SELECTED. An UNAIMED cast still keeps the caster — triage burned 44,595 raid mana.
|
|
1872
|
+
const shieldFan = (): Fighter[] => filterPartyFanByRadius(
|
|
1873
|
+
src, friendlyPoolOf(src), spell, 'party', distanceBetween,
|
|
1874
|
+
).filter((u) => !dead(u));
|
|
1875
|
+
// TARGET_UNIT_PARTY may not be the caster, so an unaimed cast takes a party member — the
|
|
1876
|
+
// master first, as the core does. Cite: Spell.cpp:2325-2330 (fire shield from imp).
|
|
1877
|
+
const excludesCaster = auraHolderExcludesCaster(spell);
|
|
1878
|
+
let shieldAim = auraHolderIsCastTarget(spell) ? aimedFriendlyOf(src) : null;
|
|
1879
|
+
if (excludesCaster && (shieldAim == null || shieldAim === src)) {
|
|
1880
|
+
const others = friendlyPoolOf(src).filter((u) => u !== src && !dead(u) && isPartyFanBody(u));
|
|
1881
|
+
shieldAim = (src === pet && player && others.includes(player) ? player : others[0]) ?? null;
|
|
1882
|
+
}
|
|
1883
|
+
const shieldHolders: Fighter[] = auraRadiatesToParty(spell)
|
|
1884
|
+
// APPLY_AREA_AURA_PARTY (Retribution Aura) radiates to the subgroup, never the caster
|
|
1885
|
+
// alone. Cite: SpellEffects.cpp EffectApplyAreaAura; SpellAuras.cpp AreaAura::Update.
|
|
1886
|
+
? shieldFan()
|
|
1887
|
+
: excludesCaster
|
|
1888
|
+
? (shieldAim ? [shieldAim] : [])
|
|
1889
|
+
: shieldAim == null
|
|
1890
|
+
? [src]
|
|
1891
|
+
: wantsRaidAndClass(spell)
|
|
1892
|
+
? filterSameClass(shieldAim, shieldFan())
|
|
1893
|
+
: [shieldAim];
|
|
1894
|
+
// durationMs 0 is permanent-until-cancelled, the same idiom the buff branch uses. Expiring on
|
|
1895
|
+
// the cast tick wiped buffActive, so the rotation recast it every GCD — 20 casts in 30 s.
|
|
1896
|
+
const shieldExpAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
1897
|
+
for (const holder of shieldHolders) {
|
|
1898
|
+
const live = ctx.effectsOn(holder.side).find(
|
|
1899
|
+
(e) => e.kind === 'damage_shield' && e.spellId === spell.id,
|
|
1900
|
+
);
|
|
1901
|
+
if (live) {
|
|
1902
|
+
live.expiresAt = shieldExpAt;
|
|
1903
|
+
if (charges != null) live.charges = charges;
|
|
1904
|
+
live.min = eff.min;
|
|
1905
|
+
live.max = eff.max;
|
|
1906
|
+
live.school = eff.school;
|
|
1907
|
+
live.cooldownMs = cd && cd > 0 ? cd : live.cooldownMs;
|
|
1908
|
+
live.nextProcAt = t;
|
|
1909
|
+
ctx.setEffectSource(live, src.side);
|
|
1910
|
+
if (spell.exclusionGroup != null) live.exclusionGroup = spell.exclusionGroup;
|
|
1911
|
+
touchEffect(live);
|
|
1912
|
+
} else {
|
|
1913
|
+
effects.push({
|
|
1914
|
+
kind: 'damage_shield',
|
|
1915
|
+
source: src.side,
|
|
1916
|
+
target: holder.side,
|
|
1917
|
+
spellId: spell.id,
|
|
1918
|
+
name: spell.name,
|
|
1919
|
+
// Mirrored so a sibling paladin aura can strip Retribution with no store lookup.
|
|
1920
|
+
...(spell.exclusionGroup != null ? { exclusionGroup: spell.exclusionGroup } : {}),
|
|
1921
|
+
school: eff.school,
|
|
1922
|
+
min: eff.min,
|
|
1923
|
+
max: eff.max,
|
|
1924
|
+
expiresAt: shieldExpAt,
|
|
1925
|
+
charges,
|
|
1926
|
+
nextProcAt: 0,
|
|
1927
|
+
cooldownMs: cd && cd > 0 ? cd : undefined,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
holder.buffActive.add(spell.id);
|
|
1931
|
+
push({ t, kind: 'aura', actor: holder.side, name: spell.name, applied: true, spellId: spell.id });
|
|
1932
|
+
}
|
|
1933
|
+
} else if (eff.kind === 'proc_trigger_spell') {
|
|
1934
|
+
// PROC_TRIGGER_SPELL (42) on hit, or PERIODIC_TRIGGER_SPELL (23) when there is a tick. A
|
|
1935
|
+
// periodic channel fires at the foe every amplitude; take-hit and deal-melee procs route apart.
|
|
1936
|
+
const dur = durFor(eff.durationMs);
|
|
1937
|
+
const charges = (eff.charges ?? spell.procCharges ?? 0) > 0
|
|
1938
|
+
? (eff.charges ?? spell.procCharges!)
|
|
1939
|
+
: undefined;
|
|
1940
|
+
const cd = eff.cooldownMs ?? spell.spellProcEvent?.cooldownMs;
|
|
1941
|
+
const tickMs = eff.tickMs && eff.tickMs > 0 ? eff.tickMs : undefined;
|
|
1942
|
+
// Proc match fields from bake — never invent ppm/chance.
|
|
1943
|
+
const procFlags = spell.procFlags ?? spell.spellProcEvent?.procFlags;
|
|
1944
|
+
const procPpm = spell.spellProcEvent?.ppmRate ?? null;
|
|
1945
|
+
let procChance: number | null = null;
|
|
1946
|
+
if (spell.spellProcEvent?.customChance != null && spell.spellProcEvent.customChance > 0) {
|
|
1947
|
+
const c = spell.spellProcEvent.customChance;
|
|
1948
|
+
procChance = c > 1 ? Math.min(1, c / 100) : Math.min(1, c);
|
|
1949
|
+
}
|
|
1950
|
+
// A scripted roll on the aura's own amount beats spell_proc_event: Improved Mend Pet is chance
|
|
1951
|
+
// 101, always, while the class script rolls 15% or 50%.
|
|
1952
|
+
if (eff.procChancePct != null && eff.procChancePct > 0) {
|
|
1953
|
+
procChance = Math.min(1, eff.procChancePct / 100);
|
|
1954
|
+
}
|
|
1955
|
+
// Whose aura is this? A channel holder stays on the caster; a periodic debuff names the victim.
|
|
1956
|
+
// 92 of 445 aura-23 slots name a non-caster and every one was landing on the caster.
|
|
1957
|
+
const holder = eff.auraOnTarget === true && tgt !== src ? tgt : src;
|
|
1958
|
+
// Hunter trap SLOT1: one GO — deactivate the previous arm. Snapshot yards at
|
|
1959
|
+
// caster feet. Cite: SpellEffects.cpp EffectSummonObject :4746–4764.
|
|
1960
|
+
const trapTrigger = isHunterTrapTriggerSpellId(eff.triggerSpellId);
|
|
1961
|
+
if (trapTrigger) {
|
|
1962
|
+
const mine = ctx.effectsOn(holder.side);
|
|
1963
|
+
for (let i = mine.length - 1; i >= 0; i--) {
|
|
1964
|
+
const old = mine[i]!;
|
|
1965
|
+
if (old.kind !== 'proc_trigger_spell') continue;
|
|
1966
|
+
if (!old.triggerSpellId || !isHunterTrapTriggerSpellId(old.triggerSpellId)) continue;
|
|
1967
|
+
holder.buffActive.delete(old.spellId);
|
|
1968
|
+
const idx = effects.lastIndexOf(old);
|
|
1969
|
+
if (idx >= 0) effects.splice(idx, 1);
|
|
1970
|
+
push({ t, kind: 'aura', actor: holder.side, name: old.name, applied: false });
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
const live = ctx.effectsOn(holder.side).find(
|
|
1974
|
+
(e) => e.kind === 'proc_trigger_spell' && e.spellId === spell.id
|
|
1975
|
+
// A spell can carry both a real proc and a removal holder — they are separate rows.
|
|
1976
|
+
&& !!e.onRemove === !!eff.onRemove,
|
|
1977
|
+
);
|
|
1978
|
+
if (live) {
|
|
1979
|
+
live.expiresAt = t + (dur > 0 ? dur : Number.POSITIVE_INFINITY);
|
|
1980
|
+
live.triggerSpellId = eff.triggerSpellId;
|
|
1981
|
+
if (charges != null) live.charges = charges;
|
|
1982
|
+
live.cooldownMs = cd && cd > 0 ? cd : live.cooldownMs;
|
|
1983
|
+
live.procFlags = procFlags;
|
|
1984
|
+
live.procPpm = procPpm;
|
|
1985
|
+
live.procChance = procChance;
|
|
1986
|
+
if (tickMs) {
|
|
1987
|
+
live.tickMs = tickMs;
|
|
1988
|
+
live.nextTickAt = t + tickMs;
|
|
1989
|
+
live.nextProcAt = undefined;
|
|
1990
|
+
live.tickChance = eff.tickChance;
|
|
1991
|
+
} else {
|
|
1992
|
+
live.tickMs = undefined;
|
|
1993
|
+
live.nextTickAt = undefined;
|
|
1994
|
+
live.nextProcAt = t;
|
|
1995
|
+
}
|
|
1996
|
+
touchEffect(live);
|
|
1997
|
+
} else {
|
|
1998
|
+
effects.push({
|
|
1999
|
+
kind: 'proc_trigger_spell',
|
|
2000
|
+
source: src.side,
|
|
2001
|
+
target: holder.side,
|
|
2002
|
+
spellId: spell.id,
|
|
2003
|
+
name: spell.name,
|
|
2004
|
+
triggerSpellId: eff.triggerSpellId,
|
|
2005
|
+
expiresAt: t + (dur > 0 ? dur : Number.POSITIVE_INFINITY),
|
|
2006
|
+
charges,
|
|
2007
|
+
nextProcAt: tickMs ? undefined : 0,
|
|
2008
|
+
cooldownMs: cd && cd > 0 ? cd : undefined,
|
|
2009
|
+
tickMs,
|
|
2010
|
+
nextTickAt: tickMs ? t + tickMs : undefined,
|
|
2011
|
+
tickChance: tickMs ? eff.tickChance : undefined,
|
|
2012
|
+
procFlags,
|
|
2013
|
+
procPpm,
|
|
2014
|
+
procChance,
|
|
2015
|
+
...(eff.onRemove ? { onRemove: eff.onRemove } : {}),
|
|
2016
|
+
...(trapTrigger && ctx.posOf?.(holder.side)
|
|
2017
|
+
? { trapPosYd: { ...ctx.posOf(holder.side)! } }
|
|
2018
|
+
: {}),
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
if (trapTrigger && live) {
|
|
2022
|
+
const p = ctx.posOf?.(holder.side);
|
|
2023
|
+
if (p) live.trapPosYd = { ...p };
|
|
2024
|
+
}
|
|
2025
|
+
// A removal-body holder is invisible: the aura it belongs to (Wyvern Sting's CC, the
|
|
2026
|
+
// injection's own row) already owns the icon, so adding a second one double-logs it.
|
|
2027
|
+
if (eff.onRemove) continue;
|
|
2028
|
+
holder.buffActive.add(spell.id);
|
|
2029
|
+
// Trap SLOT1 is a placed GO, not a unit aura chip. Keep buffActive so rotation
|
|
2030
|
+
// gates still see the arm; the floor chip is trapsView(). Cite: EffectSummonObject.
|
|
2031
|
+
if (trapTrigger) {
|
|
2032
|
+
push({ t, kind: 'flavor', text: `${src.spec.name} places ${spell.name}.` });
|
|
2033
|
+
} else {
|
|
2034
|
+
push({ t, kind: 'aura', actor: holder.side, name: spell.name, applied: true, spellId: spell.id });
|
|
2035
|
+
}
|
|
2036
|
+
} else if (eff.kind === 'resist') {
|
|
2037
|
+
// MOD_RESISTANCE (22) magic schools — Mage Armor is resist-only, with no buff row. The duration
|
|
2038
|
+
// comes from durationIndex, and a hostile minus-resist lands on the foe, not the caster.
|
|
2039
|
+
if ((eff.amount ?? 0) === 0 && !eff.pct) continue;
|
|
2040
|
+
const baseDur = durMs(spell.durationIndex) ?? 0;
|
|
2041
|
+
const dur = durFor(baseDur);
|
|
2042
|
+
const expAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
2043
|
+
const school = eff.school;
|
|
2044
|
+
// Prefer baked/interpreter `hostile`; legacy: amount < 0 = debuff (CoE −45 resist).
|
|
2045
|
+
// Positive self resists (Ice Armor frost) stay on caster.
|
|
2046
|
+
const resistHostile = isHostileBuffEffect(eff, eff.amount || eff.pct || 0);
|
|
2047
|
+
const resistTgt: Fighter | null = resistHostile
|
|
2048
|
+
? (tgt.side === src.side
|
|
2049
|
+
? (livingHostilesFor(src).find((h) => h.side !== src.side) ?? null)
|
|
2050
|
+
: tgt)
|
|
2051
|
+
: src;
|
|
2052
|
+
if (!resistTgt) continue;
|
|
2053
|
+
if (
|
|
2054
|
+
totemRefusesNegativeAuraApply(
|
|
2055
|
+
resistTgt.creatureType ?? resistTgt.spec.creatureType,
|
|
2056
|
+
resistHostile,
|
|
2057
|
+
)
|
|
2058
|
+
|| (resistHostile && isTotemSide(resistTgt.side))
|
|
2059
|
+
) {
|
|
2060
|
+
continue;
|
|
2061
|
+
}
|
|
2062
|
+
// Drop exclusive siblings on the actual resist target (Frost/Ice Armor group 13 self; curse
|
|
2063
|
+
// group 12 for CoE ranks; Prayer of Shadow Protection, which no group covers).
|
|
2064
|
+
{
|
|
2065
|
+
const group = spell.exclusionGroup;
|
|
2066
|
+
if (strongerSiblingOn(resistTgt.side)) continue;
|
|
2067
|
+
if ((group != null && group !== 0) || stackIdent != null) {
|
|
2068
|
+
const mine = ctx.effectsOn(resistTgt.side);
|
|
2069
|
+
for (let i = mine.length - 1; i >= 0; i--) {
|
|
2070
|
+
const e = mine[i]!;
|
|
2071
|
+
if (e.spellId === spell.id) continue;
|
|
2072
|
+
if (!isStackSibling(e, group)) continue;
|
|
2073
|
+
if (e.kind === 'buff') {
|
|
2074
|
+
reverseBuffMod(resistTgt, e.mod, e.amount ?? 0, e.school, {
|
|
2075
|
+
creatureTypeMask: e.creatureTypeMask,
|
|
2076
|
+
schoolMask: e.schoolMask,
|
|
2077
|
+
});
|
|
2078
|
+
} else if (e.kind === 'resist' && e.school) {
|
|
2079
|
+
resistTgt.resistances[e.school] = (resistTgt.resistances[e.school] ?? 0) - (e.amount ?? 0);
|
|
2080
|
+
} else {
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
resistTgt.buffActive.delete(e.spellId);
|
|
2084
|
+
const idx = effects.lastIndexOf(e);
|
|
2085
|
+
if (idx >= 0) effects.splice(idx, 1);
|
|
2086
|
+
push({ t, kind: 'aura', actor: resistTgt.side, name: e.name, applied: false });
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
const live = ctx.effectsOn(resistTgt.side).find(
|
|
2091
|
+
(e) =>
|
|
2092
|
+
e.kind === 'resist'
|
|
2093
|
+
&& e.spellId === spell.id
|
|
2094
|
+
&& e.school === school,
|
|
2095
|
+
);
|
|
2096
|
+
// BASE_PCT (aura 142 magic mask) resolves against the target's own resistance, minus what
|
|
2097
|
+
// this same aura already took, so a refresh does not compound. Cite: SpellAuras.cpp:3432-3435.
|
|
2098
|
+
const amount = eff.pct != null
|
|
2099
|
+
? Math.round(((resistTgt.resistances[school] ?? 0) - (live?.amount ?? 0)) * eff.pct / 100)
|
|
2100
|
+
: eff.amount;
|
|
2101
|
+
if (live) {
|
|
2102
|
+
live.expiresAt = expAt;
|
|
2103
|
+
if ((live.amount ?? 0) !== amount) {
|
|
2104
|
+
resistTgt.resistances[school] = (resistTgt.resistances[school] ?? 0) - (live.amount ?? 0) + amount;
|
|
2105
|
+
live.amount = amount;
|
|
2106
|
+
}
|
|
2107
|
+
if (spell.exclusionGroup != null) live.exclusionGroup = spell.exclusionGroup;
|
|
2108
|
+
touchEffect(live);
|
|
2109
|
+
} else {
|
|
2110
|
+
resistTgt.resistances[school] = (resistTgt.resistances[school] ?? 0) + amount;
|
|
2111
|
+
effects.push({
|
|
2112
|
+
kind: 'resist',
|
|
2113
|
+
source: src.side,
|
|
2114
|
+
target: resistTgt.side,
|
|
2115
|
+
spellId: spell.id,
|
|
2116
|
+
name: spell.name,
|
|
2117
|
+
school,
|
|
2118
|
+
amount,
|
|
2119
|
+
expiresAt: expAt,
|
|
2120
|
+
exclusionGroup: spell.exclusionGroup,
|
|
2121
|
+
...stackFields,
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
resistTgt.buffActive.add(spell.id);
|
|
2125
|
+
// One applied event per school row (Ice Armor frost resist is a single row).
|
|
2126
|
+
push({ t, kind: 'aura', actor: resistTgt.side, name: spell.name, applied: true, spellId: spell.id });
|
|
2127
|
+
if (resistHostile && resistTgt.side !== src.side && !hostileResistHitDone) {
|
|
2128
|
+
hostileResistHitDone = true;
|
|
2129
|
+
push({
|
|
2130
|
+
t, kind: 'spell_hit', actor: src.side, target: resistTgt.side,
|
|
2131
|
+
spellId: spell.id, name: spell.name, result: 'hit', amount: 0, school: spell.school,
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
2134
|
+
finisherHit = true;
|
|
2135
|
+
} else if (eff.kind === 'buff') {
|
|
2136
|
+
// Amplify Curse scales the empowered curse's debuff magnitude (Curse of Weakness −AP, etc).
|
|
2137
|
+
const baseAmount = src.castEffectPct
|
|
2138
|
+
? Math.round((eff.amount + ppc) * (1 + src.castEffectPct / 100))
|
|
2139
|
+
: eff.amount + ppc;
|
|
2140
|
+
const dur = durFor(eff.durationMs);
|
|
2141
|
+
// Friendly buffs route from raw implicit targets. Baked applyTo is the fallback.
|
|
2142
|
+
const buffAudience = spellBuffApplyToFromRaw(spell) ?? eff.applyTo;
|
|
2143
|
+
const friendPool = filterPartyFanByRadius(
|
|
2144
|
+
src,
|
|
2145
|
+
friendlyPoolOf(src),
|
|
2146
|
+
spell,
|
|
2147
|
+
buffAudience,
|
|
2148
|
+
distanceBetween,
|
|
2149
|
+
);
|
|
2150
|
+
const friendTriage = lowestHpFriendlyOf(src) ?? src;
|
|
2151
|
+
// TARGET_UNIT_FRIEND is the caster's SELECTED unit, and an untargeted keep-up selects the
|
|
2152
|
+
// caster: routing it through heal triage burned 44,595 raid mana in 15 s. The PIN comes first.
|
|
2153
|
+
const friendBuffTarget = (kind: string): Fighter =>
|
|
2154
|
+
(kind === 'buff' || kind === 'resist'
|
|
2155
|
+
? (aimedFriendlyOf(src) ?? (!isEnemySide(tgt.side) ? tgt : src))
|
|
2156
|
+
: friendTriage);
|
|
2157
|
+
const buffTargets: Fighter[] = isHostileBuffEffect(eff, baseAmount)
|
|
2158
|
+
? (tgt.side === src.side
|
|
2159
|
+
// Self-centered hostile AOE: SpellRadius when known. On the endless floor a missing radius
|
|
2160
|
+
// means free-fire only — failing open flat-threatened every passive in the 48 yd stream.
|
|
2161
|
+
? (() => {
|
|
2162
|
+
const r = spell.radiusYd;
|
|
2163
|
+
const all = livingHostilesFor(src).filter((h) => h.side !== src.side);
|
|
2164
|
+
if (r != null && r > 0) {
|
|
2165
|
+
return all.filter((h) => {
|
|
2166
|
+
const d = distanceBetween(src, h);
|
|
2167
|
+
// Geometry live: missing dist is not "in range".
|
|
2168
|
+
if (cfg.endless && d == null) return false;
|
|
2169
|
+
return d == null || d <= r;
|
|
2170
|
+
});
|
|
2171
|
+
}
|
|
2172
|
+
if (cfg.endless) {
|
|
2173
|
+
const combat =
|
|
2174
|
+
typeof ctx.livingCombatEnemies === 'function'
|
|
2175
|
+
? ctx.livingCombatEnemies()
|
|
2176
|
+
: all;
|
|
2177
|
+
return combat.filter((h) => h.side !== src.side);
|
|
2178
|
+
}
|
|
2179
|
+
return all;
|
|
2180
|
+
})()
|
|
2181
|
+
: [tgt])
|
|
2182
|
+
: buffAudience === 'pet' ? (pet && !dead(pet) ? [pet] : [])
|
|
2183
|
+
: buffAudience === 'friendly' ? [friendBuffTarget(eff.kind)]
|
|
2184
|
+
: buffAudience === 'party'
|
|
2185
|
+
? (wantsRaidAndClass(spell)
|
|
2186
|
+
// The PIN names the class the fan follows, as the shield branch does at :1876.
|
|
2187
|
+
// Reading `src` fanned an ally-aimed Greater over the CASTER's class.
|
|
2188
|
+
? filterSameClass(
|
|
2189
|
+
aimedFriendlyOf(src) ?? (!isEnemySide(tgt.side) ? tgt : src), friendPool,
|
|
2190
|
+
)
|
|
2191
|
+
: friendPool)
|
|
2192
|
+
: [src];
|
|
2193
|
+
// Applying one aura drops the target's other auras in the same exclusion group (Aspects, Mage
|
|
2194
|
+
// Armors, curses) or multirank family (Gift of the Wild), resist-only rows included.
|
|
2195
|
+
const dropExclusionGroupSiblings = (buffTgt: Fighter): boolean => {
|
|
2196
|
+
const group = spell.exclusionGroup;
|
|
2197
|
+
if ((group == null || group === 0) && stackIdent == null) return false;
|
|
2198
|
+
if (strongerSiblingOn(buffTgt.side)) return true;
|
|
2199
|
+
const mine = ctx.effectsOn(buffTgt.side);
|
|
2200
|
+
for (let i = mine.length - 1; i >= 0; i--) {
|
|
2201
|
+
const e = mine[i]!;
|
|
2202
|
+
if (e.spellId === spell.id) continue;
|
|
2203
|
+
if (!isStackSibling(e, group)) continue;
|
|
2204
|
+
if (e.kind === 'buff') {
|
|
2205
|
+
reverseBuffMod(buffTgt, e.mod, e.amount ?? 0, e.school, {
|
|
2206
|
+
creatureTypeMask: e.creatureTypeMask,
|
|
2207
|
+
schoolMask: e.schoolMask,
|
|
2208
|
+
});
|
|
2209
|
+
} else if (e.kind === 'resist' && e.school) {
|
|
2210
|
+
buffTgt.resistances[e.school] = (buffTgt.resistances[e.school] ?? 0) - (e.amount ?? 0);
|
|
2211
|
+
} else {
|
|
2212
|
+
continue;
|
|
2213
|
+
}
|
|
2214
|
+
buffTgt.buffActive.delete(e.spellId);
|
|
2215
|
+
const idx = effects.lastIndexOf(e);
|
|
2216
|
+
if (idx >= 0) effects.splice(idx, 1);
|
|
2217
|
+
push({ t, kind: 'aura', actor: buffTgt.side, name: e.name, applied: false });
|
|
2218
|
+
}
|
|
2219
|
+
return false;
|
|
2220
|
+
};
|
|
2221
|
+
// One-target apply, hoisted so the area-aura maintain pass can RE-apply it when an ally walks
|
|
2222
|
+
// back into radius. Idempotent: an existing row refreshes duration and never re-adds flat mods.
|
|
2223
|
+
const applyBuffToTarget = (buffTgt: Fighter): void => {
|
|
2224
|
+
// Totem.cpp:282-287: mixed-hit school damage still lands; the APPLY_AURA index does not.
|
|
2225
|
+
const hostileBuff = isHostileBuffEffect(eff, baseAmount);
|
|
2226
|
+
if (
|
|
2227
|
+
totemRefusesNegativeAuraApply(
|
|
2228
|
+
buffTgt.creatureType ?? buffTgt.spec.creatureType,
|
|
2229
|
+
hostileBuff,
|
|
2230
|
+
)
|
|
2231
|
+
|| (hostileBuff && isTotemSide(buffTgt.side))
|
|
2232
|
+
) return;
|
|
2233
|
+
// A debuff rider on a hostile cast shares the spell's one SpellHitResult, so a resisted
|
|
2234
|
+
// Thunder Clap slows nothing. Self and ally riders on the same cast are untouched.
|
|
2235
|
+
if ((hostileSpell || bypassPositiveAutoHit) && buffTgt === tgt && tgt.side !== src.side
|
|
2236
|
+
&& !rollHostileLand(eff.school ?? spell.school)) return;
|
|
2237
|
+
let amount = baseAmount;
|
|
2238
|
+
let mod: BuffMod | undefined = eff.mod;
|
|
2239
|
+
let school = eff.school;
|
|
2240
|
+
// Percent-of-current mods become a flat delta at apply, so expiry reverses the exact amount
|
|
2241
|
+
// rather than compounding. Signed, not floored at 1 — hostile auras go negative.
|
|
2242
|
+
if (eff.mod === 'max_health_pct') {
|
|
2243
|
+
amount = signedAtLeastOne((buffTgt.spec.maxHealth * amount) / 100);
|
|
2244
|
+
mod = 'max_health';
|
|
2245
|
+
} else if (eff.mod === 'max_mana_pct') {
|
|
2246
|
+
const baseMana = buffTgt.spec.powerType === 'mana' ? buffTgt.spec.maxPower : (buffTgt.maxMana ?? 0);
|
|
2247
|
+
amount = signedAtLeastOne((baseMana * amount) / 100);
|
|
2248
|
+
mod = 'max_mana';
|
|
2249
|
+
} else if (eff.mod === 'all_stats_pct') {
|
|
2250
|
+
amount = signedAtLeastOne((buffTgt.attackPower * amount) / 100);
|
|
2251
|
+
mod = 'attack_power';
|
|
2252
|
+
school = undefined;
|
|
2253
|
+
} else if (eff.mod === 'attack_power_pct') {
|
|
2254
|
+
// AP-only percent (aura 166, Unleashed Rage) — no max-health arm.
|
|
2255
|
+
amount = signedAtLeastOne((buffTgt.attackPower * amount) / 100);
|
|
2256
|
+
mod = 'attack_power';
|
|
2257
|
+
school = undefined;
|
|
2258
|
+
} else if (eff.mod === 'stamina_pct') {
|
|
2259
|
+
// Per-stat percent (auras 80/137 with a single stat). Stamina's live derivative is
|
|
2260
|
+
// max health — Demonic Embrace +15%. Cite: SpellAuras.cpp:3537-3568.
|
|
2261
|
+
amount = signedAtLeastOne((buffTgt.spec.maxHealth * amount) / 100);
|
|
2262
|
+
mod = 'max_health';
|
|
2263
|
+
school = undefined;
|
|
2264
|
+
} else if (eff.mod === 'intellect_pct' || eff.mod === 'spirit_pct') {
|
|
2265
|
+
// An int or spirit percent becomes a flat delta of that stat, so the whole live cascade
|
|
2266
|
+
// lands and reverses exactly. Cite: SpellAuras.cpp:3537-3568 HandleModTotalPercentStat.
|
|
2267
|
+
const isInt = eff.mod === 'intellect_pct';
|
|
2268
|
+
const stat = (isInt ? buffTgt.spec.intellect : buffTgt.spec.spirit) || 0;
|
|
2269
|
+
amount = signedAtLeastOne((stat * amount) / 100);
|
|
2270
|
+
mod = isInt ? 'intellect' : 'spirit';
|
|
2271
|
+
school = undefined;
|
|
2272
|
+
} else if (eff.mod === 'strength_pct' || eff.mod === 'agility_pct') {
|
|
2273
|
+
// str/agi's live derivative is attack power (same arm as all_stats_pct).
|
|
2274
|
+
amount = signedAtLeastOne((buffTgt.attackPower * amount) / 100);
|
|
2275
|
+
mod = 'attack_power';
|
|
2276
|
+
school = undefined;
|
|
2277
|
+
} else if (eff.mod === 'spell_power_of_spirit_pct') {
|
|
2278
|
+
amount = signedAtLeastOne(((buffTgt.spec.spirit || 0) * amount) / 100);
|
|
2279
|
+
mod = 'spell_power';
|
|
2280
|
+
school = undefined;
|
|
2281
|
+
} else if (eff.mod === 'healing_power_of_spirit_pct') {
|
|
2282
|
+
amount = signedAtLeastOne(((buffTgt.spec.spirit || 0) * amount) / 100);
|
|
2283
|
+
mod = 'healing_done';
|
|
2284
|
+
school = undefined;
|
|
2285
|
+
}
|
|
2286
|
+
if (dropExclusionGroupSiblings(buffTgt)) return;
|
|
2287
|
+
// Hunter's Mark is tracked too — aura 68 MOD_STALKED, one marked target per hunter.
|
|
2288
|
+
ctx.purgeTrackedAuraTargets?.(src, buffTgt, spell);
|
|
2289
|
+
// Match spellId and mod, plus school, so a multi-effect spell registers separate reversible
|
|
2290
|
+
// rows. Prefer the same caster, then any caster, then a display-only sheet-baked row.
|
|
2291
|
+
const onBuffTgt = ctx.effectsOn(buffTgt.side);
|
|
2292
|
+
const liveBuff =
|
|
2293
|
+
onBuffTgt.find(
|
|
2294
|
+
(e) =>
|
|
2295
|
+
e.kind === 'buff'
|
|
2296
|
+
&& e.spellId === spell.id
|
|
2297
|
+
&& e.source === src.side
|
|
2298
|
+
&& e.mod === mod
|
|
2299
|
+
&& e.school === school,
|
|
2300
|
+
)
|
|
2301
|
+
?? (spell.stacksPerCaster === true
|
|
2302
|
+
? undefined
|
|
2303
|
+
: onBuffTgt.find(
|
|
2304
|
+
(e) =>
|
|
2305
|
+
e.kind === 'buff'
|
|
2306
|
+
&& e.spellId === spell.id
|
|
2307
|
+
&& e.mod === mod
|
|
2308
|
+
&& e.school === school,
|
|
2309
|
+
))
|
|
2310
|
+
?? onBuffTgt.find(
|
|
2311
|
+
(e) =>
|
|
2312
|
+
e.kind === 'buff'
|
|
2313
|
+
&& e.spellId === spell.id
|
|
2314
|
+
&& e.mod == null,
|
|
2315
|
+
);
|
|
2316
|
+
// durationMs 0 = permanent until cancelled (DurationIndex 21 / -1 in DBC).
|
|
2317
|
+
const expAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
2318
|
+
// Sheet-baked OOC carry refreshes only (no re-applyBuffMod). Immunity-first multi-effect
|
|
2319
|
+
// (Stoneform) sets buffActive without a combat mod row — still live-apply. Cite: KI-436/789.
|
|
2320
|
+
const anyCombatModRow = onBuffTgt.some(
|
|
2321
|
+
(e) =>
|
|
2322
|
+
e.kind === 'buff'
|
|
2323
|
+
&& e.spellId === spell.id
|
|
2324
|
+
&& e.mod != null,
|
|
2325
|
+
);
|
|
2326
|
+
const hasImmunityRow = onBuffTgt.some(
|
|
2327
|
+
(e) =>
|
|
2328
|
+
e.kind === 'immunity'
|
|
2329
|
+
&& e.spellId === spell.id,
|
|
2330
|
+
);
|
|
2331
|
+
const sheetBaked =
|
|
2332
|
+
(!!liveBuff && liveBuff.mod == null)
|
|
2333
|
+
|| (!liveBuff && buffTgt.buffActive.has(spell.id) && !anyCombatModRow && !hasImmunityRow);
|
|
2334
|
+
// plan.action for rider: sheet-baked refresh always "succeeds"; blocked only from stack plan.
|
|
2335
|
+
let planAction: 'add' | 'refresh' | 'stack' | 'blocked' = 'refresh';
|
|
2336
|
+
if (sheetBaked) {
|
|
2337
|
+
if (liveBuff && liveBuff.mod == null) {
|
|
2338
|
+
liveBuff.expiresAt = expAt;
|
|
2339
|
+
liveBuff.name = spell.name;
|
|
2340
|
+
touchEffect(liveBuff);
|
|
2341
|
+
} else if (!liveBuff) {
|
|
2342
|
+
// Permanent chip had no effect row — materialize a display-only tracker so later
|
|
2343
|
+
// multi-effect / recasts hit this refresh path without double-dipping flat mods.
|
|
2344
|
+
effects.push({
|
|
2345
|
+
kind: 'buff',
|
|
2346
|
+
source: src.side,
|
|
2347
|
+
target: buffTgt.side,
|
|
2348
|
+
spellId: spell.id,
|
|
2349
|
+
name: spell.name,
|
|
2350
|
+
expiresAt: expAt,
|
|
2351
|
+
...stackFields,
|
|
2352
|
+
// No mod/amount — sheet owns the stats; expiry must not reverseBuffMod.
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
buffTgt.buffActive.add(spell.id);
|
|
2356
|
+
push({ t, kind: 'aura', actor: buffTgt.side, name: spell.name, applied: true, spellId: spell.id });
|
|
2357
|
+
if (buffTgt.side !== src.side) {
|
|
2358
|
+
push({
|
|
2359
|
+
t, kind: 'spell_hit', actor: src.side, target: buffTgt.side,
|
|
2360
|
+
spellId: spell.id, name: spell.name, result: 'hit', amount: 0, school: spell.school,
|
|
2361
|
+
});
|
|
2362
|
+
}
|
|
2363
|
+
} else {
|
|
2364
|
+
// Same-chain other ranks (rank-1 roots recovered). Live same-id stays in
|
|
2365
|
+
// `liveBuff` so the index lookup below still finds it. Cite: Unit.cpp:4988-5028.
|
|
2366
|
+
const chainOf = ctx.spellChainOf;
|
|
2367
|
+
const identityRows: ActiveEffect[] = [];
|
|
2368
|
+
if (chainOf) {
|
|
2369
|
+
for (const e of ctx.effectsOn(buffTgt.side)) {
|
|
2370
|
+
if (e.spellId === spell.id) continue;
|
|
2371
|
+
if (!resolveChainPair(spell.id, e.spellId, chainOf)) continue;
|
|
2372
|
+
identityRows.push(e);
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
// C++ m_amount is per-tick * stacks (SpellAuras.cpp:389); IsSimilarExistingAuraStronger
|
|
2376
|
+
// then divides by GetStackAmount (SpellMgr.h:1589). Live rows store per-stack amount.
|
|
2377
|
+
const stackedAmount = (e: { amount?: number; stacks?: number }): number | undefined =>
|
|
2378
|
+
e.amount != null ? e.amount * Math.max(1, e.stacks ?? 1) : undefined;
|
|
2379
|
+
const plan = planAuraApply(
|
|
2380
|
+
[
|
|
2381
|
+
...(liveBuff
|
|
2382
|
+
? [{ spellId: liveBuff.spellId, source: liveBuff.source, target: liveBuff.target, kind: 'buff', stacks: liveBuff.stacks, charges: liveBuff.charges, amount: stackedAmount(liveBuff), mod: liveBuff.mod }]
|
|
2383
|
+
: []),
|
|
2384
|
+
...identityRows.map((e) => ({
|
|
2385
|
+
spellId: e.spellId, source: e.source, target: e.target, kind: 'buff',
|
|
2386
|
+
stacks: e.stacks, charges: e.charges, amount: stackedAmount(e), mod: e.mod,
|
|
2387
|
+
})),
|
|
2388
|
+
],
|
|
2389
|
+
{ spellId: spell.id, source: src.side, target: buffTgt.side, kind: 'buff' },
|
|
2390
|
+
{
|
|
2391
|
+
stackAmount: spell.stackAmount,
|
|
2392
|
+
procCharges: auraHolderCharges(spell, eff) || buffProcChargesForSpell(spell.id, 0),
|
|
2393
|
+
// liveBuff above falls back to another caster's row on purpose; without this the plan
|
|
2394
|
+
// re-rejected on source, so four warriors left four 1-stack Sunders, not one 5-stack.
|
|
2395
|
+
matchAnyCaster: spell.stacksPerCaster !== true && !hasAttrEx3(spell, SPELL_ATTR_EX3.DOT_STACKING_RULE),
|
|
2396
|
+
identity: {
|
|
2397
|
+
...(chainOf ? { chainOf } : {}),
|
|
2398
|
+
auraUnique: hasAttrEx(spell, SPELL_ATTR_EX.AURA_UNIQUE),
|
|
2399
|
+
...(mod != null ? { incomingMod: mod, incomingAmount: amount } : {}),
|
|
2400
|
+
},
|
|
2401
|
+
},
|
|
2402
|
+
);
|
|
2403
|
+
// Same-chain lower rank: RemoveAurasDueToSpell drops the whole holder, not one effect row. The
|
|
2404
|
+
// offset is the liveBuff row, which is never in `identityRows`. Cite: Unit.cpp:5023-5024.
|
|
2405
|
+
if (plan.action === 'add' && plan.replaces?.length) {
|
|
2406
|
+
const offset = liveBuff ? 1 : 0;
|
|
2407
|
+
const dropIds = new Set<number>();
|
|
2408
|
+
for (const i of plan.replaces) {
|
|
2409
|
+
const row = identityRows[i - offset];
|
|
2410
|
+
if (row) dropIds.add(row.spellId);
|
|
2411
|
+
}
|
|
2412
|
+
if (dropIds.size) {
|
|
2413
|
+
for (let at = effects.length - 1; at >= 0; at--) {
|
|
2414
|
+
const row = effects[at]!;
|
|
2415
|
+
if (row.target !== buffTgt.side || !dropIds.has(row.spellId)) continue;
|
|
2416
|
+
if (row.kind === 'buff') {
|
|
2417
|
+
reverseBuffMod(buffTgt, row.mod, row.amount ?? 0, row.school, {
|
|
2418
|
+
creatureTypeMask: row.creatureTypeMask,
|
|
2419
|
+
schoolMask: row.schoolMask,
|
|
2420
|
+
});
|
|
2421
|
+
} else if (row.kind === 'resist' && row.school) {
|
|
2422
|
+
buffTgt.resistances[row.school] =
|
|
2423
|
+
(buffTgt.resistances[row.school] ?? 0) - (row.amount ?? 0);
|
|
2424
|
+
}
|
|
2425
|
+
buffTgt.buffActive.delete(row.spellId);
|
|
2426
|
+
effects.splice(at, 1);
|
|
2427
|
+
push({ t, kind: 'aura', actor: buffTgt.side, name: row.name, applied: false });
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
planAction = plan.action;
|
|
2432
|
+
// Apply live mods only on first add or when stacking increases the total.
|
|
2433
|
+
// Refresh (same stacks) re-applies duration without double-adding flat mods.
|
|
2434
|
+
const maskOpts = {
|
|
2435
|
+
creatureTypeMask: eff.creatureTypeMask,
|
|
2436
|
+
schoolMask: eff.schoolMask,
|
|
2437
|
+
};
|
|
2438
|
+
const applyMods = (delta: number): void =>
|
|
2439
|
+
applyBuffMod(buffTgt, mod as BuffMod, delta, school, maskOpts);
|
|
2440
|
+
if (plan.action === 'blocked') {
|
|
2441
|
+
// no-op
|
|
2442
|
+
} else if (plan.action === 'add' || !liveBuff) {
|
|
2443
|
+
applyMods(amount);
|
|
2444
|
+
buffTgt.buffActive.add(spell.id);
|
|
2445
|
+
effects.push({
|
|
2446
|
+
kind: 'buff', source: src.side, target: buffTgt.side, spellId: spell.id, name: spell.name,
|
|
2447
|
+
mod, amount, school, expiresAt: expAt,
|
|
2448
|
+
creatureTypeMask: eff.creatureTypeMask,
|
|
2449
|
+
schoolMask: eff.schoolMask,
|
|
2450
|
+
exclusionGroup: spell.exclusionGroup,
|
|
2451
|
+
...stackFields,
|
|
2452
|
+
fromTotem: isTotemSide(src.side) ? src.side : undefined,
|
|
2453
|
+
stacks: plan.stacks, charges: plan.charges,
|
|
2454
|
+
...((plan.charges != null && (spell.procFlags ?? 0) !== 0)
|
|
2455
|
+
? { procFlags: spell.procFlags }
|
|
2456
|
+
: {}),
|
|
2457
|
+
});
|
|
2458
|
+
// The aura row lives on the target's buff bar. Also emit a spell_hit for the caster so a
|
|
2459
|
+
// damageless hostile debuff shows in the fight log as a cast.
|
|
2460
|
+
push({ t, kind: 'aura', actor: buffTgt.side, name: spell.name, applied: true, spellId: spell.id });
|
|
2461
|
+
if (buffTgt.side !== src.side) {
|
|
2462
|
+
push({
|
|
2463
|
+
t, kind: 'spell_hit', actor: src.side, target: buffTgt.side,
|
|
2464
|
+
spellId: spell.id, name: spell.name, result: 'hit', amount: 0, school: spell.school,
|
|
2465
|
+
});
|
|
2466
|
+
}
|
|
2467
|
+
} else if (plan.action === 'stack') {
|
|
2468
|
+
applyMods(amount); // +1 stack worth of the flat amount
|
|
2469
|
+
liveBuff.amount = amount;
|
|
2470
|
+
liveBuff.expiresAt = expAt;
|
|
2471
|
+
liveBuff.stacks = plan.stacks;
|
|
2472
|
+
if (plan.charges != null) {
|
|
2473
|
+
liveBuff.charges = plan.charges;
|
|
2474
|
+
if ((spell.procFlags ?? 0) !== 0) liveBuff.procFlags = spell.procFlags;
|
|
2475
|
+
}
|
|
2476
|
+
if (spell.exclusionGroup != null) liveBuff.exclusionGroup = spell.exclusionGroup;
|
|
2477
|
+
touchEffect(liveBuff);
|
|
2478
|
+
} else {
|
|
2479
|
+
// refresh — duration only; do not re-add flat mods (would double-dip).
|
|
2480
|
+
liveBuff.expiresAt = expAt;
|
|
2481
|
+
liveBuff.stacks = plan.stacks;
|
|
2482
|
+
touchEffect(liveBuff);
|
|
2483
|
+
if (plan.charges != null) {
|
|
2484
|
+
liveBuff.charges = plan.charges;
|
|
2485
|
+
if ((spell.procFlags ?? 0) !== 0) liveBuff.procFlags = spell.procFlags;
|
|
2486
|
+
}
|
|
2487
|
+
if (spell.exclusionGroup != null) liveBuff.exclusionGroup = spell.exclusionGroup;
|
|
2488
|
+
if (liveBuff.amount != null && liveBuff.amount !== amount && liveBuff.mod) {
|
|
2489
|
+
applyMods(amount - liveBuff.amount);
|
|
2490
|
+
liveBuff.amount = amount;
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
// Divine Shield's immunity rides its baked school_immunity effects, so there is no id set
|
|
2495
|
+
// here. Bestial Wrath's CC-immunity rider is attached from the sourced mask for the duration.
|
|
2496
|
+
if (planAction !== 'blocked' && isEnemySide(buffTgt.side)) {
|
|
2497
|
+
applySpellThreatBonus(src, spell, buffTgt, hitCount);
|
|
2498
|
+
}
|
|
2499
|
+
if (planAction !== 'blocked') {
|
|
2500
|
+
const riderMask = ccImmunityRiderMaskForSpell(spell.id);
|
|
2501
|
+
if (riderMask) {
|
|
2502
|
+
grantImmunityAura(
|
|
2503
|
+
buffTgt,
|
|
2504
|
+
makeCcImmunityAura(spell.id, {
|
|
2505
|
+
mechanicMask: riderMask,
|
|
2506
|
+
durationMs: dur,
|
|
2507
|
+
expiresAt: expAt,
|
|
2508
|
+
}),
|
|
2509
|
+
expAt,
|
|
2510
|
+
spell.name,
|
|
2511
|
+
src.side,
|
|
2512
|
+
);
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
};
|
|
2516
|
+
for (const buffTgt of buffTargets) applyBuffToTarget(buffTgt);
|
|
2517
|
+
// ---- Party area aura (APPLY_AREA_AURA_PARTY, effect 35), timed or not: Trueshot Aura's
|
|
2518
|
+
// 30 min ride the rows and still leave with the hunter. A timed effect-6 buff is not this.
|
|
2519
|
+
if (
|
|
2520
|
+
buffAudience === 'party'
|
|
2521
|
+
&& !isHostileBuffEffect(eff, baseAmount)
|
|
2522
|
+
&& (!(eff.durationMs > 0) || isPartyAreaAura(spell))
|
|
2523
|
+
) {
|
|
2524
|
+
registerAreaAuraSource({
|
|
2525
|
+
holderSide: src.side,
|
|
2526
|
+
spellId: spell.id,
|
|
2527
|
+
name: spell.name,
|
|
2528
|
+
baseRadiusYd: spell.radiusYd,
|
|
2529
|
+
modOwnerSide: src.side,
|
|
2530
|
+
exclusionGroup: spell.exclusionGroup,
|
|
2531
|
+
// AreaAura::Update applies the MISSING rank only. An unguarded re-apply emitted 50 events
|
|
2532
|
+
// for one Blood Pact in a 2.5 s fight — the totem kit path guards the same way.
|
|
2533
|
+
apply: (m: Fighter) => {
|
|
2534
|
+
if (hasAreaAuraRow(ctx.effectsOn(m.side), m, spell.id, src.side)) return;
|
|
2535
|
+
applyBuffToTarget(m);
|
|
2536
|
+
},
|
|
2537
|
+
strip: (m: Fighter) => {
|
|
2538
|
+
stripAreaAuraRowsFrom(effects, m, spell.id, src.side, reverseActiveEffect, ctx.effectsOn(m.side));
|
|
2539
|
+
},
|
|
2540
|
+
// AreaAura::Update drops a target that is "not same subgroup" — the reason a
|
|
2541
|
+
// 40-man needs a paladin per group, not one Devotion Aura for the raid.
|
|
2542
|
+
members: () => friendlyPoolOf(src)
|
|
2543
|
+
.filter((m: Fighter) => sameSubgroup(src, m) && (m === src || isPartyFanBody(m))),
|
|
2544
|
+
// A unit that LEFT the subgroup falls out of members() and would never be
|
|
2545
|
+
// re-checked; the remove arm is per-target, so the sweep needs the carrier list.
|
|
2546
|
+
carriers: () => areaAuraCarrierSides(ctx.effectsFrom(src.side), spell.id, src.side),
|
|
2547
|
+
// `!caster->HasAura(originalRankSpellId)`: the holder row expiring or being cancelled on
|
|
2548
|
+
// the caster ends the aura for everyone. Cite: SpellAuras.cpp:597.
|
|
2549
|
+
alive: () => {
|
|
2550
|
+
const holder = bySide(src.side);
|
|
2551
|
+
return !!holder && !dead(holder) && hasAreaAuraRow(ctx.effectsOn(holder.side), holder, spell.id, src.side);
|
|
2552
|
+
},
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
// Self/friendly-buff finishers (SnD) always "hit"; hostile armor debuff finishers count too.
|
|
2556
|
+
finisherHit = true;
|
|
2557
|
+
} else if (
|
|
2558
|
+
eff.kind === 'school_immunity' || eff.kind === 'damage_immunity'
|
|
2559
|
+
|| eff.kind === 'mechanic_immunity' || eff.kind === 'dispel_immunity'
|
|
2560
|
+
|| eff.kind === 'state_immunity' || eff.kind === 'effect_immunity'
|
|
2561
|
+
) {
|
|
2562
|
+
// Aura-granted immunities — Divine Protection, Blessing of Protection, Free Action. Self and
|
|
2563
|
+
// friendly only. With a pet-targeted buff on the same spell, the immunity routes to the pet.
|
|
2564
|
+
const dur = durFor(eff.durationMs);
|
|
2565
|
+
// DurationIndex 21 bakes 0, permanent until cancelled, so adding it tore the row down a tick
|
|
2566
|
+
// later — and with it the FORM's buff chip. 22 spells carry a permanent immunity.
|
|
2567
|
+
const immExpiresAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
2568
|
+
const auraType = eff.kind === 'school_immunity' ? AURA_TYPE.SCHOOL_IMMUNITY
|
|
2569
|
+
: eff.kind === 'damage_immunity' ? AURA_TYPE.DAMAGE_IMMUNITY
|
|
2570
|
+
: eff.kind === 'dispel_immunity' ? AURA_TYPE.DISPEL_IMMUNITY
|
|
2571
|
+
: eff.kind === 'state_immunity' ? AURA_TYPE.STATE_IMMUNITY
|
|
2572
|
+
: eff.kind === 'effect_immunity' ? AURA_TYPE.EFFECT_IMMUNITY
|
|
2573
|
+
: AURA_TYPE.MECHANIC_IMMUNITY;
|
|
2574
|
+
const petBuff = spell.effects.some((e) => e.kind === 'buff' && e.applyTo === 'pet');
|
|
2575
|
+
// TARGET_UNIT_FRIEND immunities hold on the unit the cast NAMED, as the absorb branch
|
|
2576
|
+
// aims its holder (:1832). BoP on an ally was immunising the paladin instead.
|
|
2577
|
+
const immAim = auraHolderIsCastTarget(spell) ? aimedFriendlyOf(src) : null;
|
|
2578
|
+
const immOwner = petBuff && pet && !dead(pet) ? pet : (immAim ?? src);
|
|
2579
|
+
const immCharges = auraHolderCharges(spell, eff);
|
|
2580
|
+
grantImmunityAura(immOwner, {
|
|
2581
|
+
spellId: spell.id,
|
|
2582
|
+
auraType,
|
|
2583
|
+
schoolMask: (eff.kind === 'school_immunity' || eff.kind === 'damage_immunity')
|
|
2584
|
+
? (eff.schoolMask | 0)
|
|
2585
|
+
: undefined,
|
|
2586
|
+
mechanic: eff.kind === 'mechanic_immunity' ? (eff.mechanic | 0) : undefined,
|
|
2587
|
+
// The SPELL's own mechanic, which is what CanPierceImmuneAura excludes on. Without it
|
|
2588
|
+
// Divine Shield and Ice Block read as pierceable. Cite: SpellMgr.h:1306-1310.
|
|
2589
|
+
spellMechanic: mechanicIdFromName(spell.mechanic) || undefined,
|
|
2590
|
+
// Cite: Unit.cpp ApplySpellDispelImmunity ~7699 (41) / HandleAuraModStateImmunity ~2972 (38).
|
|
2591
|
+
dispelType: eff.kind === 'dispel_immunity' ? (eff.dispelType | 0) : undefined,
|
|
2592
|
+
stateAuraType: eff.kind === 'state_immunity' ? (eff.stateAuraType | 0) : undefined,
|
|
2593
|
+
effectId: eff.kind === 'effect_immunity' ? (eff.effectId | 0) : undefined,
|
|
2594
|
+
positive: eff.positive !== false,
|
|
2595
|
+
immunityPurgesEffect: true,
|
|
2596
|
+
durationMs: dur,
|
|
2597
|
+
...(immCharges > 0 ? { charges: immCharges } : {}),
|
|
2598
|
+
}, immExpiresAt, spell.name, src.side);
|
|
2599
|
+
// Scripted boost spells ride along on apply: Stoneform carries its disease and bleed immunity,
|
|
2600
|
+
// and Bestial Wrath its four. Cite: SpellAuras.cpp:5567 HandleSpellSpecificBoosts.
|
|
2601
|
+
for (const boost of immunityBoostAurasForSpell(spell.id, dur)) {
|
|
2602
|
+
grantImmunityAura(immOwner, boost, immExpiresAt, spell.name, src.side);
|
|
2603
|
+
}
|
|
2604
|
+
// Cite: SpellAuras.cpp:2968 if (apply && IsPositive()) RemoveAurasWithInterruptFlags(INVULNERABILITY_BUFF_CANCELS)
|
|
2605
|
+
if (eff.positive !== false) {
|
|
2606
|
+
ctx.removeAurasWithInterruptFlags?.(immOwner, AURA_INTERRUPT.INVULNERABILITY_BUFF_CANCELS, 'invulnerability');
|
|
2607
|
+
}
|
|
2608
|
+
finisherHit = true;
|
|
2609
|
+
} else if (eff.kind === 'pacify') {
|
|
2610
|
+
// MOD_PACIFY: cannot melee/ranged attack; can still cast. Holds on the unit the cast named
|
|
2611
|
+
// (BoP is TARGET_UNIT_FRIEND 57), else the caster. Cite: SpellAuras.cpp HandleAuraModPacify ~4382.
|
|
2612
|
+
const dur = durFor(eff.durationMs);
|
|
2613
|
+
const pacTgt = (auraHolderIsCastTarget(spell) ? aimedFriendlyOf(src) : null) ?? src;
|
|
2614
|
+
pacTgt.pacifiedUntil = Math.max(pacTgt.pacifiedUntil, t + dur);
|
|
2615
|
+
pacTgt.buffActive.add(spell.id);
|
|
2616
|
+
const live = ctx.effectsOn(pacTgt.side).find(
|
|
2617
|
+
(e) => e.kind === 'buff' && e.spellId === spell.id,
|
|
2618
|
+
);
|
|
2619
|
+
if (live) {
|
|
2620
|
+
live.expiresAt = Math.max(live.expiresAt, t + dur);
|
|
2621
|
+
touchEffect(live);
|
|
2622
|
+
} else {
|
|
2623
|
+
effects.push({
|
|
2624
|
+
kind: 'buff', source: src.side, target: pacTgt.side, spellId: spell.id, name: spell.name,
|
|
2625
|
+
expiresAt: t + dur,
|
|
2626
|
+
});
|
|
2627
|
+
push({ t, kind: 'aura', actor: pacTgt.side, name: spell.name, applied: true });
|
|
2628
|
+
}
|
|
2629
|
+
finisherHit = true;
|
|
2630
|
+
} else if (eff.kind === 'mod_unattackable') {
|
|
2631
|
+
// MOD_UNATTACKABLE (93): Phase Shift — untargetable plus a combat stop. DurationIndex 21 is
|
|
2632
|
+
// permanent until cancelled. Cite: SpellAuras.cpp:4454–4459; KI-732.
|
|
2633
|
+
const dur = durFor(eff.durationMs);
|
|
2634
|
+
const expAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
2635
|
+
src.unattackableUntil = Math.max(src.unattackableUntil, expAt);
|
|
2636
|
+
// Real && Apply → CombatStop(): drop combat timer + hold white swings (AIF ATTACKING
|
|
2637
|
+
// on 4511 cancels the aura if the imp attacks). Cite: :4456–4457; auraInterruptFlags 0x1000.
|
|
2638
|
+
if (eff.combatStopOnApply) {
|
|
2639
|
+
src.combatUntil = 0; src.combatEnteredAt = 0;
|
|
2640
|
+
src.autoAttackEnabled = false;
|
|
2641
|
+
src.pendingSwing = null;
|
|
2642
|
+
}
|
|
2643
|
+
grantImmunityAura(src, {
|
|
2644
|
+
spellId: spell.id,
|
|
2645
|
+
auraType: AURA_TYPE.MOD_UNATTACKABLE,
|
|
2646
|
+
positive: true,
|
|
2647
|
+
immunityPurgesEffect: false,
|
|
2648
|
+
durationMs: dur,
|
|
2649
|
+
}, expAt, spell.name, src.side);
|
|
2650
|
+
src.buffActive.add(spell.id);
|
|
2651
|
+
const live = ctx.effectsOn(src.side).find(
|
|
2652
|
+
(e) => e.kind === 'buff' && e.spellId === spell.id,
|
|
2653
|
+
);
|
|
2654
|
+
if (live) {
|
|
2655
|
+
live.expiresAt = Math.max(live.expiresAt, expAt);
|
|
2656
|
+
touchEffect(live);
|
|
2657
|
+
} else {
|
|
2658
|
+
effects.push({
|
|
2659
|
+
kind: 'buff', source: src.side, target: src.side, spellId: spell.id, name: spell.name,
|
|
2660
|
+
expiresAt: expAt,
|
|
2661
|
+
});
|
|
2662
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true });
|
|
2663
|
+
}
|
|
2664
|
+
finisherHit = true;
|
|
2665
|
+
} else if (eff.kind === 'school_absorb') {
|
|
2666
|
+
// SPELL_AURA_SCHOOL_ABSORB (69). Cite: Unit.cpp ~2391; applySchoolAbsorb.ts.
|
|
2667
|
+
if (applySchoolAbsorbEffect({
|
|
2668
|
+
src, tgt: aimedFriendlyOf(src) ?? src,
|
|
2669
|
+
player, spell, eff, effects, effectsOn: (side) => ctx.effectsOn(side), t, durFor, rngNext: () => rng.next(), push,
|
|
2670
|
+
touchEffect,
|
|
2671
|
+
})) {
|
|
2672
|
+
finisherHit = true;
|
|
2673
|
+
}
|
|
2674
|
+
} else if (eff.kind === 'instakill') {
|
|
2675
|
+
// SPELL_EFFECT_INSTAKILL (1). Cite: SpellEffects.cpp:205–221; applySchoolAbsorb.ts.
|
|
2676
|
+
applyInstakillEffect({ src, tgt, eff, dead, checkDeaths });
|
|
2677
|
+
finisherHit = true;
|
|
2678
|
+
} else if (eff.kind === 'mana_shield') {
|
|
2679
|
+
// SPELL_AURA_MANA_SHIELD (97) — pool + manaMultiplier from EffectMultipleValue.
|
|
2680
|
+
// Cite: Unit.cpp ~2457; Mana Shield ranks EffectMultipleValue = 2.
|
|
2681
|
+
const amount = Math.max(0, Math.round(rollBetween(eff.min, eff.max, rng.next())));
|
|
2682
|
+
const dur = durFor(eff.durationMs);
|
|
2683
|
+
src.manaShields = src.manaShields.filter((a) => a.id !== spell.id);
|
|
2684
|
+
src.manaShields.push({
|
|
2685
|
+
schoolMask: eff.schoolMask,
|
|
2686
|
+
amount,
|
|
2687
|
+
manaMultiplier: eff.manaMultiplier,
|
|
2688
|
+
id: spell.id,
|
|
2689
|
+
});
|
|
2690
|
+
const live = ctx.effectsOn(src.side).find(
|
|
2691
|
+
(e) => e.kind === 'mana_shield' && e.spellId === spell.id,
|
|
2692
|
+
);
|
|
2693
|
+
if (live) {
|
|
2694
|
+
live.expiresAt = t + dur;
|
|
2695
|
+
live.min = amount;
|
|
2696
|
+
live.max = amount;
|
|
2697
|
+
touchEffect(live);
|
|
2698
|
+
} else {
|
|
2699
|
+
effects.push({
|
|
2700
|
+
kind: 'mana_shield', source: src.side, target: src.side,
|
|
2701
|
+
spellId: spell.id, name: spell.name, expiresAt: t + dur, min: amount, max: amount,
|
|
2702
|
+
});
|
|
2703
|
+
}
|
|
2704
|
+
src.buffActive.add(spell.id);
|
|
2705
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true });
|
|
2706
|
+
finisherHit = true;
|
|
2707
|
+
} else if (eff.kind === 'heal_max_health') {
|
|
2708
|
+
// SPELL_EFFECT_HEAL_MAX_HEALTH (67) — heal = CASTER max health (Lay on Hands).
|
|
2709
|
+
// Cite: SpellEffects.cpp:3579–3589; effects.ts resolveHealMaxHealth.
|
|
2710
|
+
const { healAmount } = resolveHealMaxHealth({ casterMaxHealth: src.spec.maxHealth });
|
|
2711
|
+
if (!dead(tgt) && healAmount > 0) {
|
|
2712
|
+
const hpBefore = tgt.hp;
|
|
2713
|
+
tgt.hp = Math.min(tgt.spec.maxHealth, Math.round(tgt.hp + healAmount));
|
|
2714
|
+
push({
|
|
2715
|
+
t, kind: 'heal', actor: tgt.side, amount: healAmount,
|
|
2716
|
+
source: src.side, name: spell.name, spellId: spell.id,
|
|
2717
|
+
});
|
|
2718
|
+
// Effective gain only — overheal generates no threat (Unit.cpp ModifyHealth gain).
|
|
2719
|
+
applyHealThreat(src, tgt.hp - hpBefore, spell, tgt.side);
|
|
2720
|
+
}
|
|
2721
|
+
finisherHit = true;
|
|
2722
|
+
} else if (eff.kind === 'power_drain') {
|
|
2723
|
+
// SPELL_EFFECT_POWER_DRAIN (8) — cap at current power; caster gains × multiplier.
|
|
2724
|
+
// Cite: SpellEffects.cpp:1750–1792.
|
|
2725
|
+
if ((hostileSpell || (bypassPositiveAutoHit && tgt.side !== src.side)) && !rollHostileLand(spell.school)) continue;
|
|
2726
|
+
const amount = Math.round(rollBetween(eff.min, eff.max, rng.next()));
|
|
2727
|
+
const cur = powerOfType(tgt, eff.powerType);
|
|
2728
|
+
const match = casterPowerTypeId(src) === eff.powerType;
|
|
2729
|
+
const { drained, casterGain } = resolvePowerDrain({
|
|
2730
|
+
amount,
|
|
2731
|
+
targetCurrentPower: cur,
|
|
2732
|
+
multiplier: eff.multiplier,
|
|
2733
|
+
powerTypesMatch: match,
|
|
2734
|
+
isSelf: src === tgt,
|
|
2735
|
+
});
|
|
2736
|
+
if (drained > 0) {
|
|
2737
|
+
setPowerOfType(tgt, eff.powerType, cur - drained);
|
|
2738
|
+
if (casterGain > 0 && match) {
|
|
2739
|
+
const cCur = powerOfType(src, eff.powerType);
|
|
2740
|
+
setPowerOfType(src, eff.powerType, cCur + casterGain);
|
|
2741
|
+
// Cite: SpellEffects.cpp:1791 EnergizeBySpell triggers threatAssist
|
|
2742
|
+
applyHealThreat(src, casterGain, spell);
|
|
2743
|
+
}
|
|
2744
|
+
push({
|
|
2745
|
+
t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name,
|
|
2746
|
+
result: 'hit', amount: drained, school: spell.school,
|
|
2747
|
+
});
|
|
2748
|
+
finisherHit = true;
|
|
2749
|
+
}
|
|
2750
|
+
} else if (eff.kind === 'power_burn') {
|
|
2751
|
+
// SPELL_EFFECT_POWER_BURN (62) — burn power, deal burned × mult as school damage.
|
|
2752
|
+
// Cite: SpellEffects.cpp:1801–1829.
|
|
2753
|
+
if ((hostileSpell || (bypassPositiveAutoHit && tgt.side !== src.side)) && !rollHostileLand(eff.school)) continue;
|
|
2754
|
+
const amount = Math.round(rollBetween(eff.min, eff.max, rng.next()));
|
|
2755
|
+
const cur = powerOfType(tgt, eff.powerType);
|
|
2756
|
+
const { burned, damage } = resolvePowerBurn({
|
|
2757
|
+
amount,
|
|
2758
|
+
targetCurrentPower: cur,
|
|
2759
|
+
multiplier: eff.multiplier,
|
|
2760
|
+
});
|
|
2761
|
+
if (burned > 0) {
|
|
2762
|
+
setPowerOfType(tgt, eff.powerType, cur - burned);
|
|
2763
|
+
const dealt = dealDamage(tgt, damage, eff.school, src.spec.level, {
|
|
2764
|
+
damageType: 'spell', attacker: src, spell,
|
|
2765
|
+
});
|
|
2766
|
+
push({
|
|
2767
|
+
t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name,
|
|
2768
|
+
result: 'hit', amount: dealt, school: eff.school,
|
|
2769
|
+
});
|
|
2770
|
+
finisherHit = true;
|
|
2771
|
+
}
|
|
2772
|
+
} else if (eff.kind === 'health_leech') {
|
|
2773
|
+
// SPELL_EFFECT_HEALTH_LEECH (9) — damage capped at current HP; heal caster × mult.
|
|
2774
|
+
// Cite: SpellEffects.cpp:1905–1934.
|
|
2775
|
+
if ((hostileSpell || (bypassPositiveAutoHit && tgt.side !== src.side)) && !rollHostileLand(eff.school)) continue;
|
|
2776
|
+
const amount = Math.round(rollBetween(eff.min, eff.max, rng.next()));
|
|
2777
|
+
const { damage, heal } = resolveHealthLeech({
|
|
2778
|
+
amount,
|
|
2779
|
+
targetCurrentHealth: tgt.hp,
|
|
2780
|
+
multiplier: eff.multiplier,
|
|
2781
|
+
});
|
|
2782
|
+
if (damage > 0) {
|
|
2783
|
+
const dealt = dealDamage(tgt, damage, eff.school, src.spec.level, {
|
|
2784
|
+
damageType: 'spell', attacker: src, spell,
|
|
2785
|
+
});
|
|
2786
|
+
if (!dead(src) && heal > 0) {
|
|
2787
|
+
src.hp = Math.min(src.spec.maxHealth, Math.round(src.hp + heal));
|
|
2788
|
+
push({
|
|
2789
|
+
t, kind: 'heal', actor: src.side, amount: heal,
|
|
2790
|
+
source: src.side, name: spell.name, spellId: spell.id,
|
|
2791
|
+
});
|
|
2792
|
+
}
|
|
2793
|
+
push({
|
|
2794
|
+
t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name,
|
|
2795
|
+
result: 'hit', amount: dealt, school: eff.school,
|
|
2796
|
+
});
|
|
2797
|
+
finisherHit = true;
|
|
2798
|
+
}
|
|
2799
|
+
} else if (eff.kind === 'energize') {
|
|
2800
|
+
// SPELL_EFFECT_ENERGIZE (30) non-rage — rage stays on rageGenerated.
|
|
2801
|
+
// Cite: SpellEffects.cpp:2079–2124.
|
|
2802
|
+
const amount = Math.round(rollBetween(eff.min, eff.max, rng.next()));
|
|
2803
|
+
const cur = powerOfType(tgt, eff.powerType);
|
|
2804
|
+
const max = maxPowerOfType(tgt, eff.powerType);
|
|
2805
|
+
const { restored, skipped } = resolveEnergize({
|
|
2806
|
+
amount,
|
|
2807
|
+
powerType: eff.powerType,
|
|
2808
|
+
targetCurrentPower: cur,
|
|
2809
|
+
targetMaxPower: max,
|
|
2810
|
+
});
|
|
2811
|
+
if (!skipped && restored > 0) {
|
|
2812
|
+
setPowerOfType(tgt, eff.powerType, cur + restored);
|
|
2813
|
+
push({
|
|
2814
|
+
t, kind: 'resource', actor: tgt.side,
|
|
2815
|
+
power: powerTypeName(eff.powerType) ?? tgt.spec.powerType,
|
|
2816
|
+
value: Math.round(powerOfType(tgt, eff.powerType)),
|
|
2817
|
+
delta: restored,
|
|
2818
|
+
});
|
|
2819
|
+
// ENERGIZE threat lands on the caster, off the ENERGIZED unit's own ref list.
|
|
2820
|
+
// Cite: Unit.cpp:6961 EnergizeBySpell; SpellEffects.cpp:2123 EffectEnergize.
|
|
2821
|
+
applyHealThreat(src, restored, spell, tgt.side);
|
|
2822
|
+
}
|
|
2823
|
+
finisherHit = true;
|
|
2824
|
+
} else if (eff.kind === 'life_tap') {
|
|
2825
|
+
// Warlock Life Tap — a health cost plus a mana energize, self only. It fizzles when the health
|
|
2826
|
+
// cost exceeds current HP, costing neither mana nor health. Cite: Warlock.cpp:67–102.
|
|
2827
|
+
const cost = Math.max(0, Math.trunc(eff.healthCost));
|
|
2828
|
+
const mana = Math.max(0, Math.trunc(eff.manaGain));
|
|
2829
|
+
// script value > health → SPELL_FAILED_FIZZLE. Cite: Warlock.cpp:69–70.
|
|
2830
|
+
if (cost > 0 && cost > src.hp) {
|
|
2831
|
+
// Cast already selected by AI; silent fizzle (no HP, no mana).
|
|
2832
|
+
} else if (cost > 0 || mana > 0) {
|
|
2833
|
+
if (cost > 0) {
|
|
2834
|
+
src.hp = Math.max(0, src.hp - cost);
|
|
2835
|
+
}
|
|
2836
|
+
if (mana > 0) {
|
|
2837
|
+
const cur = powerOfType(src, POWER_MANA);
|
|
2838
|
+
const max = maxPowerOfType(src, POWER_MANA);
|
|
2839
|
+
const { restored, skipped } = resolveEnergize({
|
|
2840
|
+
amount: mana,
|
|
2841
|
+
powerType: POWER_MANA,
|
|
2842
|
+
targetCurrentPower: cur,
|
|
2843
|
+
targetMaxPower: max,
|
|
2844
|
+
});
|
|
2845
|
+
if (!skipped && restored > 0) {
|
|
2846
|
+
setPowerOfType(src, POWER_MANA, cur + restored);
|
|
2847
|
+
push({
|
|
2848
|
+
t, kind: 'resource', actor: src.side,
|
|
2849
|
+
power: 'mana',
|
|
2850
|
+
value: Math.round(powerOfType(src, POWER_MANA)),
|
|
2851
|
+
delta: restored,
|
|
2852
|
+
});
|
|
2853
|
+
// Life Tap energize triggers threatAssist (Unit.cpp EnergizeBySpell)
|
|
2854
|
+
applyHealThreat(src, restored, spell);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
finisherHit = true;
|
|
2858
|
+
}
|
|
2859
|
+
} else if (eff.kind === 'extra_attacks') {
|
|
2860
|
+
// SPELL_EFFECT_ADD_EXTRA_ATTACKS (19) — cap 5.
|
|
2861
|
+
// Cite: SpellEffects.cpp:4873–4882; effects.ts resolveAddExtraAttacks.
|
|
2862
|
+
src.extraAttacks = resolveAddExtraAttacks({ current: src.extraAttacks, add: eff.count });
|
|
2863
|
+
// Meter: drained extras must show as this spell (Windfury Totem), not white Melee.
|
|
2864
|
+
src.extraAttackSource = { spellId: spell.id, name: spell.name };
|
|
2865
|
+
finisherHit = true;
|
|
2866
|
+
} else if (eff.kind === 'reset_own_threat') {
|
|
2867
|
+
// DoResetThreat: everyone's hate on the CASTER to zero, so the tank has to re-grab.
|
|
2868
|
+
// Cite: UnitAI.cpp:773-781; boss_twinemperors.cpp:332-356 does the same by hand.
|
|
2869
|
+
threatOf(src)?.modifyAllThreatPercent(-100);
|
|
2870
|
+
push({ t, kind: 'threat_all_pct', actor: src.side, percent: -100 });
|
|
2871
|
+
finisherHit = true;
|
|
2872
|
+
} else if (eff.kind === 'modify_threat_pct') {
|
|
2873
|
+
// MODIFY_THREAT_PERCENT (125) on the target's threat list toward the caster. A self-cast like
|
|
2874
|
+
// Feign Death walks every living enemy's manager instead of one targeted unit.
|
|
2875
|
+
const pct = Math.trunc(eff.pct);
|
|
2876
|
+
if (isEnemySide(tgt.side)) {
|
|
2877
|
+
const tm = threatOf(tgt);
|
|
2878
|
+
if (tm) tm.modifyThreatPercent(src.side, pct);
|
|
2879
|
+
} else {
|
|
2880
|
+
for (const ef of livingEnemies()) {
|
|
2881
|
+
if (dead(ef)) continue;
|
|
2882
|
+
const tm = threatOf(ef);
|
|
2883
|
+
if (tm) tm.modifyThreatPercent(src.side, pct);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
finisherHit = true;
|
|
2887
|
+
} else if (eff.kind === 'feign_death') {
|
|
2888
|
+
// SPELL_AURA_FEIGN_DEATH (66): CombatStop so IsNonCombatSpell (traps) can fire.
|
|
2889
|
+
// Cite: Unit.cpp SetFeignDeath :10091–10103 CombatStop; Spell.cpp :4598.
|
|
2890
|
+
const dur = durFor(eff.durationMs);
|
|
2891
|
+
clearCombatFlag(src);
|
|
2892
|
+
src.autoAttackEnabled = false;
|
|
2893
|
+
src.pendingSwing = null;
|
|
2894
|
+
src.feigningUntil = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
2895
|
+
src.feignSpellId = spell.id;
|
|
2896
|
+
ctx.clearPullSpentFor?.(src.side);
|
|
2897
|
+
finisherHit = true;
|
|
2898
|
+
} else if (eff.kind === 'threat_temporary') {
|
|
2899
|
+
// MOD_TOTAL_THREAT (103) — Fade's temporary flat reverse-hate reduction, signed and capped per
|
|
2900
|
+
// list at minus the current value. A refresh extends the duration without re-applying it.
|
|
2901
|
+
const amount = Math.trunc(eff.amount);
|
|
2902
|
+
const dur = eff.durationMs ?? 0;
|
|
2903
|
+
const expAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
2904
|
+
const liveFade = ctx.effectsOn(src.side).find(
|
|
2905
|
+
(e) =>
|
|
2906
|
+
e.kind === 'buff'
|
|
2907
|
+
&& e.spellId === spell.id
|
|
2908
|
+
&& e.threatFadeDeltas != null,
|
|
2909
|
+
);
|
|
2910
|
+
if (liveFade) {
|
|
2911
|
+
liveFade.expiresAt = expAt;
|
|
2912
|
+
liveFade.name = spell.name;
|
|
2913
|
+
touchEffect(liveFade);
|
|
2914
|
+
src.buffActive.add(spell.id);
|
|
2915
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true, spellId: spell.id });
|
|
2916
|
+
} else {
|
|
2917
|
+
const deltas: Array<{ id: Side; delta: number }> = [];
|
|
2918
|
+
for (const ef of livingEnemies()) {
|
|
2919
|
+
if (dead(ef)) continue;
|
|
2920
|
+
const tm = threatOf(ef);
|
|
2921
|
+
if (!tm || !tm.hasThreat(src.side)) continue;
|
|
2922
|
+
const cur = tm.getThreat(src.side);
|
|
2923
|
+
// reducedThreat = max(-curThreat, amount). Cite: HostileRefManager.cpp:90–91.
|
|
2924
|
+
const reduced = Math.max(-cur, amount);
|
|
2925
|
+
if (reduced === 0) continue;
|
|
2926
|
+
tm.addThreatDirectly(src.side, reduced, false);
|
|
2927
|
+
deltas.push({ id: ef.side, delta: reduced });
|
|
2928
|
+
}
|
|
2929
|
+
src.buffActive.add(spell.id);
|
|
2930
|
+
effects.push({
|
|
2931
|
+
kind: 'buff',
|
|
2932
|
+
source: src.side,
|
|
2933
|
+
target: src.side,
|
|
2934
|
+
spellId: spell.id,
|
|
2935
|
+
name: spell.name,
|
|
2936
|
+
amount,
|
|
2937
|
+
expiresAt: expAt,
|
|
2938
|
+
threatFadeDeltas: deltas,
|
|
2939
|
+
});
|
|
2940
|
+
push({ t, kind: 'aura', actor: src.side, name: spell.name, applied: true, spellId: spell.id });
|
|
2941
|
+
}
|
|
2942
|
+
finisherHit = true;
|
|
2943
|
+
} else if (eff.kind === 'weapon_percent_damage') {
|
|
2944
|
+
// WEAPON_PERCENT_DAMAGE (31) — a percent of a weapon damage sample. A ranged dmgClass or shoot
|
|
2945
|
+
// role samples the ranged weapon, the safety net when impact lands via castOnTargets.
|
|
2946
|
+
const isRanged = (spell.dmgClass ?? -1) === SPELL_DAMAGE_CLASS.RANGED
|
|
2947
|
+
|| roleOf(spell) === 'shoot';
|
|
2948
|
+
const wpn = (isRanged && src.spec.ranged) ? src.spec.ranged : src.spec.weapon;
|
|
2949
|
+
// Hunter's Mark aura 127: victim grants attackers +RAP. Cite: Unit.cpp:7470; weapon.ts markRap.
|
|
2950
|
+
const markAp = tgt.attackerAttackPowerBonus || 0;
|
|
2951
|
+
const ap = (isRanged && src.spec.ranged)
|
|
2952
|
+
? (src.spec.rangedAttackPower ?? src.attackPower) + markAp
|
|
2953
|
+
: src.attackPower + markAp;
|
|
2954
|
+
const formOverride = !isRanged && isAttackSpeedOverriddenShapeShift(src.form);
|
|
2955
|
+
const wd = meleeBaseDamage(wpn.dmgMin, wpn.dmgMax, ap, wpn.speedMs, rng.next(), {
|
|
2956
|
+
formOverride,
|
|
2957
|
+
level: src.spec.level,
|
|
2958
|
+
});
|
|
2959
|
+
const { damage } = resolveWeaponPercentDamage({ weaponDamage: wd, pct: eff.pct });
|
|
2960
|
+
const school = (isRanged && src.spec.ranged?.school) ? src.spec.ranged.school : 'physical';
|
|
2961
|
+
const dealt = dealDamage(tgt, damage, school, src.spec.level, {
|
|
2962
|
+
damageType: 'weapon', attacker: src, spell,
|
|
2963
|
+
});
|
|
2964
|
+
push({
|
|
2965
|
+
t, kind: 'spell_hit', actor: src.side, target: tgt.side, spellId: spell.id, name: spell.name,
|
|
2966
|
+
result: 'hit', amount: dealt, school,
|
|
2967
|
+
});
|
|
2968
|
+
finisherHit = true;
|
|
2969
|
+
} else if (eff.kind === 'consume_aura') {
|
|
2970
|
+
applyConsumeAuraEffect(
|
|
2971
|
+
src,
|
|
2972
|
+
tgt,
|
|
2973
|
+
effects,
|
|
2974
|
+
eff,
|
|
2975
|
+
(who, id) => {
|
|
2976
|
+
(ctx.removeAurasDueToSpell as ((u: Fighter, spellId: number) => number) | undefined)?.(who, id);
|
|
2977
|
+
},
|
|
2978
|
+
(who, id, n) => {
|
|
2979
|
+
(ctx.stripAuraOnDispel as ((u: Fighter, spellId: number, stacks: number) => void) | undefined)?.(who, id, n);
|
|
2980
|
+
},
|
|
2981
|
+
);
|
|
2982
|
+
} else if (eff.kind === 'resurrect' || eff.kind === 'resurrect_pct') {
|
|
2983
|
+
// Battle rez (druid Rebirth) re-picks its own target: the cast resolved against the FOE, and
|
|
2984
|
+
// the corpse is on our side. Claim-aware, so two druids never stand up the same body.
|
|
2985
|
+
const corpse = isEnemySide(src.side)
|
|
2986
|
+
? null
|
|
2987
|
+
: pickBattleRezTarget(ctx.partyUnits(), src, (c) => ctx.rezClaim.get(c.side));
|
|
2988
|
+
for (const [corpseId, claimerId] of [...ctx.rezClaim.entries()]) {
|
|
2989
|
+
if (claimerId === src.side) ctx.rezClaim.delete(corpseId);
|
|
2990
|
+
}
|
|
2991
|
+
if (corpse) {
|
|
2992
|
+
const payload = battleRezPayload(eff, corpse.spec.maxHealth, corpse.spec.maxPower);
|
|
2993
|
+
reviveInCombat(corpse, payload.health, payload.mana, t);
|
|
2994
|
+
ctx.deathEmitted.delete(corpse.side);
|
|
2995
|
+
ctx.bumpPool?.();
|
|
2996
|
+
ctx.invalidateViewMemo?.();
|
|
2997
|
+
push({
|
|
2998
|
+
t, kind: 'spell_hit', actor: src.side, target: corpse.side, spellId: spell.id,
|
|
2999
|
+
name: spell.name, result: 'hit', amount: payload.health, school: spell.school || 'holy',
|
|
3000
|
+
});
|
|
3001
|
+
push({
|
|
3002
|
+
t, kind: 'flavor',
|
|
3003
|
+
text: `${src.spec.name} brings ${corpse.spec.name} back to life with ${spell.name}.`,
|
|
3004
|
+
});
|
|
3005
|
+
finisherHit = true;
|
|
3006
|
+
}
|
|
3007
|
+
} else if (eff.kind === 'zero_energy') {
|
|
3008
|
+
// Ferocious Bite dumps energy after a landed school hit (already folded into damage).
|
|
3009
|
+
// Cite: SpellEffects.cpp:311–320 EffectSchoolDMG.
|
|
3010
|
+
if (hostileLanded === true && src.spec.powerType === 'energy' && src.power > 0) {
|
|
3011
|
+
const before = src.power;
|
|
3012
|
+
src.power = 0;
|
|
3013
|
+
push({ t, kind: 'resource', actor: src.side, power: 'energy', value: 0, delta: -before });
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
else {
|
|
3017
|
+
// A resisted cast lands no late rider either: Frostbolt's aura-33 snare had no roll.
|
|
3018
|
+
// Cite: Spell.cpp:1211-1212 missInfo -> DoSpellHitOnUnit (every effect on a target, or none).
|
|
3019
|
+
if (
|
|
3020
|
+
(hostileSpell || bypassPositiveAutoHit)
|
|
3021
|
+
&& isEnemySide(src.side) !== isEnemySide(tgt.side)
|
|
3022
|
+
&& isFoeLandingLateEffect(eff)
|
|
3023
|
+
&& !rollHostileLand(spell.school, eff.kind === 'speed' ? 'snare' : undefined)
|
|
3024
|
+
) continue;
|
|
3025
|
+
// Late cases recompute land/crit locally where needed; pass spell-level binary only.
|
|
3026
|
+
// finisherHit is mutated by late cases (shared with early loop).
|
|
3027
|
+
const hitBox = { finisherHit };
|
|
3028
|
+
// Speed-only HEARTBEAT_RESIST (Frost Shot) has no cc/root slot. Cite: Spell.cpp:1483-1489.
|
|
3029
|
+
const speedHeartbeat = heartbeatSlotOf(spell) === 'speed' && isHostileSpeedEffect(eff)
|
|
3030
|
+
? armHeartbeatResist({
|
|
3031
|
+
now: t,
|
|
3032
|
+
missPlusResistPercent: landMissPlusResistPct ?? 0,
|
|
3033
|
+
// eff is the broad late-effect union here; armHeartbeatResist refuses 0 the way
|
|
3034
|
+
// Spell.cpp:1483 refuses originalDuration <= 0, so a missing field is a no-arm.
|
|
3035
|
+
originalDurationMs: durFor(Number((eff as { durationMs?: number }).durationMs) || 0),
|
|
3036
|
+
drLevel: drStepOf(tgt, ((eff as { mechanic?: Mechanic }).mechanic || spell.mechanic || 'snare'), t),
|
|
3037
|
+
})
|
|
3038
|
+
: null;
|
|
3039
|
+
applySpellEffectLate(L, src, tgt, spell, eff, {
|
|
3040
|
+
t,
|
|
3041
|
+
durFor,
|
|
3042
|
+
crit: false,
|
|
3043
|
+
binary: resolveSpellBinary(spell),
|
|
3044
|
+
hitBox,
|
|
3045
|
+
hitCount,
|
|
3046
|
+
unitTargetGender: tgtIn.spec.gender,
|
|
3047
|
+
...(speedHeartbeat ? { heartbeat: speedHeartbeat } : {}),
|
|
3048
|
+
});
|
|
3049
|
+
if (hitBox.finisherHit) finisherHit = true;
|
|
3050
|
+
// Recast-to-leave (Ghost Wolf / Travel): do not apply the speed rider after form drop.
|
|
3051
|
+
// Cite: oocCastEffects KI-699.
|
|
3052
|
+
if (eff.kind === 'shapeshift' && src.form === 0) break;
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// SPELL_AURA_ADD_TARGET_TRIGGER: after immediate effects, chance-cast on this hit.
|
|
3057
|
+
// Cite: Spell.cpp:3384 / :7433–7468 ProcSpellAuraTriggers.
|
|
3058
|
+
if (finisherHit && src.addTargetTriggers.length) {
|
|
3059
|
+
src.addTargetTriggers = src.addTargetTriggers.filter((r) => r.expiresAt > t);
|
|
3060
|
+
for (const row of src.addTargetTriggers) {
|
|
3061
|
+
if (!addTargetTriggerAffects(row, spell)) continue;
|
|
3062
|
+
const auraSpell = ctx.spellOf?.(row.spellId);
|
|
3063
|
+
if (auraSpell && hasAttrEx4(auraSpell, SPELL_ATTR_EX4.CLASS_TRIGGER_ONLY_ON_TARGET) && src !== tgt) continue;
|
|
3064
|
+
if (!improvedSprintShouldProc(row.chancePct, rng.next())) continue;
|
|
3065
|
+
const trig = src.spec.spells.find((s) => s.id === row.triggerSpellId);
|
|
3066
|
+
if (trig) applyProc(src, tgt, trig);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
// RegisterOverrideScriptAura — aura 112 exists on the target even when the slot
|
|
3071
|
+
// has no combat mod. Cite: SpellAuras.cpp:5096–5098; Unit.cpp:11980–11985.
|
|
3072
|
+
if (overrideScriptsOnSpell(spell).length) {
|
|
3073
|
+
const holder = overrideScriptAuraTargetIsCaster(spell) ? src : tgt;
|
|
3074
|
+
const dur = durMs(spell.durationIndex) ?? 0;
|
|
3075
|
+
const expAt = dur > 0 ? t + dur : Number.POSITIVE_INFINITY;
|
|
3076
|
+
holder.buffActive.add(spell.id);
|
|
3077
|
+
if (!ctx.effectsOn(holder.side).some((e) => e.kind === 'buff' && e.spellId === spell.id)) {
|
|
3078
|
+
effects.push({
|
|
3079
|
+
kind: 'buff',
|
|
3080
|
+
source: src.side,
|
|
3081
|
+
target: holder.side,
|
|
3082
|
+
spellId: spell.id,
|
|
3083
|
+
name: spell.name,
|
|
3084
|
+
expiresAt: expAt,
|
|
3085
|
+
});
|
|
3086
|
+
push({ t, kind: 'aura', actor: holder.side, name: spell.name, applied: true, spellId: spell.id });
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// The cast resolved against this target, so seat the caster on its threat list even when nothing
|
|
3091
|
+
// landed. A false land is the miss arm; anything else counts as a hit for THREAT_ONLY_ON_MISS.
|
|
3092
|
+
if (hostileSpell) engageOnHostileSpell(src, tgt, spell, hostileLanded !== false);
|
|
3093
|
+
|
|
3094
|
+
// Finisher: clear combo points on a successful finish (Spell.cpp finish path). A full miss on a
|
|
3095
|
+
// hostile finisher retains CP — self-only finishers (SnD) always clear via finisherHit=true.
|
|
3096
|
+
if (isFinisher && finisherHit) {
|
|
3097
|
+
clearComboPoints(src);
|
|
3098
|
+
}
|
|
3099
|
+
// Builder: award CP from effect 80 after a successful cast that also has a land path. When
|
|
3100
|
+
// combo_points is already in effects[], applySpellLate granted it once — do not fire twice.
|
|
3101
|
+
else if (
|
|
3102
|
+
!isFinisher
|
|
3103
|
+
&& spell.comboPointsGenerated
|
|
3104
|
+
&& spell.role !== 'strike'
|
|
3105
|
+
&& !spell.strikeBonus
|
|
3106
|
+
&& !spell.effects.some((e) => e.kind === 'combo_points')
|
|
3107
|
+
) {
|
|
3108
|
+
// Bake-only builders (no combo_points row yet): grant when something landed.
|
|
3109
|
+
if (finisherHit || spell.effects.length === 0) addComboPoints(src, tgt, spell.comboPointsGenerated);
|
|
3110
|
+
}
|
|
3111
|
+
// Seal Fate and Ruthlessness grant CP AFTER the current ability finishes: a finisher after the
|
|
3112
|
+
// clear, a builder after its own grant. A white swing never arms pending.
|
|
3113
|
+
if (src.pendingFinisherComboPoints > 0 && (finisherHit || !isFinisher)) {
|
|
3114
|
+
addComboPoints(src, tgt, src.pendingFinisherComboPoints);
|
|
3115
|
+
src.pendingFinisherComboPoints = 0;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// Improved Sap: talent aura 42 rolls; 14093 OnSuccessfulFinish recasts Stealth.
|
|
3119
|
+
// Cite: Rogue.cpp:105–109; sql/scriptdev2/spell.sql 14093; UnitAuraProcHandler.cpp:592–597,679.
|
|
3120
|
+
if ((spell.id | 0) === IMPROVED_SAP_TRIGGER_SPELL_ID) {
|
|
3121
|
+
ctx.castHighestStealthRank?.(src);
|
|
3122
|
+
} else if (SAP_SPELL_IDS.has(spell.id | 0) && finisherHit && isPlayerCharacterSide(src.side)) {
|
|
3123
|
+
const savedProcs = src.procs;
|
|
3124
|
+
let kitProc: NonNullable<typeof savedProcs>[number] | null = null;
|
|
3125
|
+
if (!(savedProcs ?? []).some((p) => (p.triggerSpellId | 0) === IMPROVED_SAP_TRIGGER_SPELL_ID)) {
|
|
3126
|
+
const holder = improvedSapHolderFromKnown(src.spec.spells);
|
|
3127
|
+
const chance = holder ? procChanceUnitFromRow(holder) : null;
|
|
3128
|
+
if (holder && chance != null) {
|
|
3129
|
+
kitProc = {
|
|
3130
|
+
triggerSpellId: IMPROVED_SAP_TRIGGER_SPELL_ID,
|
|
3131
|
+
chance,
|
|
3132
|
+
ppm: holder.spellProcEvent?.ppmRate ?? null,
|
|
3133
|
+
procFlags: holder.procFlags ?? holder.spellProcEvent?.procFlags,
|
|
3134
|
+
spellFamilyName: holder.spellProcEvent?.spellFamilyName ?? holder.spellFamilyName,
|
|
3135
|
+
spellFamilyMask: holder.spellProcEvent?.spellFamilyMask,
|
|
3136
|
+
};
|
|
3137
|
+
src.procs = (savedProcs ?? []).concat(kitProc);
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
triggerProcs(src, tgt, false, 'spell', spell);
|
|
3141
|
+
if (kitProc) src.procs = savedProcs;
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
// Stop attack for some spells. Cite: Spell.cpp:3691-3700.
|
|
3145
|
+
// The ranged autorepeat check is implied by autoAttackEnabled handling both here.
|
|
3146
|
+
if (hasAttr(spell, SPELL_ATTR.CANCELS_AUTO_ATTACK_COMBAT)) {
|
|
3147
|
+
src.autoAttackEnabled = false;
|
|
3148
|
+
src.pendingSwing = null;
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
// Channel arm / action interrupt. Cite: engine/combat/channel.ts; Unit.cpp InterruptSpellsWithChannelFlags.
|
|
3152
|
+
if (isChannelSpell(spell)) {
|
|
3153
|
+
// One channelled slot per caster: arming a second overwrote the state and stranded the first
|
|
3154
|
+
// channel's auras on their targets forever. Cite: Unit.cpp:4236-4239.
|
|
3155
|
+
if (src.channeling && src.channeling.spellId !== spell.id) {
|
|
3156
|
+
interruptChannel(src, channelInterruptCtx(ctx), { quiet: true });
|
|
3157
|
+
}
|
|
3158
|
+
beginChannel(src, spell, t, tgt.side);
|
|
3159
|
+
}
|
|
3160
|
+
// No ACTION cancel here. applySpell is the LANDING, where every triggered payload also arrives,
|
|
3161
|
+
// so a channel cancelled itself on its own tick. The cancel lives at the cast site instead.
|
|
3162
|
+
};
|
|
3163
|
+
|
|
3164
|
+
ctx.applySpell = applySpell;
|
|
3165
|
+
|
|
3166
|
+
// Late-effect bag: same bindings the peeled late cases closed over.
|
|
3167
|
+
// Assembled in applySpellLateBag.ts — pure wiring, kept out of this module.
|
|
3168
|
+
bindLateEffectBag(L, ctx, deps, { push });
|
|
3169
|
+
}
|