import { Dex, toID } from './dex';

const CHOOSABLE_TARGETS = new Set(['normal', 'any', 'adjacentAlly', 'adjacentAllyOrSelf', 'adjacentFoe']);

export class BattleActions {
	battle: Battle;
	dex: ModdedDex;

	readonly MAX_MOVES: { readonly [k: string]: string } = {
		Flying: 'Max Airstream',
		Dark: 'Max Darkness',
		Fire: 'Max Flare',
		Bug: 'Max Flutterby',
		Water: 'Max Geyser',
		Status: 'Max Guard',
		Ice: 'Max Hailstorm',
		Fighting: 'Max Knuckle',
		Electric: 'Max Lightning',
		Psychic: 'Max Mindstorm',
		Poison: 'Max Ooze',
		Grass: 'Max Overgrowth',
		Ghost: 'Max Phantasm',
		Ground: 'Max Quake',
		Rock: 'Max Rockfall',
		Fairy: 'Max Starfall',
		Steel: 'Max Steelspike',
		Normal: 'Max Strike',
		Dragon: 'Max Wyrmwind',
	};

	readonly Z_MOVES: { readonly [k: string]: string } = {
		Poison: "Acid Downpour",
		Fighting: "All-Out Pummeling",
		Dark: "Black Hole Eclipse",
		Grass: "Bloom Doom",
		Normal: "Breakneck Blitz",
		Rock: "Continental Crush",
		Steel: "Corkscrew Crash",
		Dragon: "Devastating Drake",
		Electric: "Gigavolt Havoc",
		Water: "Hydro Vortex",
		Fire: "Inferno Overdrive",
		Ghost: "Never-Ending Nightmare",
		Bug: "Savage Spin-Out",
		Psychic: "Shattered Psyche",
		Ice: "Subzero Slammer",
		Flying: "Supersonic Skystrike",
		Ground: "Tectonic Rage",
		Fairy: "Twinkle Tackle",
	};

	constructor(battle: Battle) {
		this.battle = battle;
		this.dex = battle.dex;
		if (this.dex.data.Scripts.actions) Object.assign(this, this.dex.data.Scripts.actions);
		if (battle.format.actions) Object.assign(this, battle.format.actions);
	}

	// #region SWITCH
	// ==================================================================

	switchIn(pokemon: Pokemon, pos: number, sourceEffect: Effect | null = null, isDrag?: boolean) {
		if (!pokemon || pokemon.isActive) {
			this.battle.hint("A switch failed because the Pokémon trying to switch in is already in.");
			return false;
		}

		const side = pokemon.side;
		if (pos >= side.active.length) {
			throw new Error(`Invalid switch position ${pos} / ${side.active.length}`);
		}
		const oldActive = side.active[pos];
		const unfaintedActive = oldActive?.hp ? oldActive : null;
		if (unfaintedActive) {
			oldActive.beingCalledBack = true;
			let switchCopyFlag: 'copyvolatile' | 'shedtail' | boolean = false;
			if (sourceEffect && typeof (sourceEffect as Move).selfSwitch === 'string') {
				switchCopyFlag = (sourceEffect as Move).selfSwitch!;
			}
			if (!oldActive.skipBeforeSwitchOutEventFlag && !isDrag) {
				this.battle.runEvent('BeforeSwitchOut', oldActive);
				if (this.battle.gen >= 5) {
					this.battle.eachEvent('Update');
				}
			}
			oldActive.skipBeforeSwitchOutEventFlag = false;
			if (!this.battle.runEvent('SwitchOut', oldActive)) {
				// Warning: DO NOT interrupt a switch-out if you just want to trap a pokemon.
				// To trap a pokemon and prevent it from switching out, (e.g. Mean Look, Magnet Pull)
				// use the 'trapped' flag instead.

				// Note: Nothing in the real games can interrupt a switch-out (except Pursuit KOing,
				// which is handled elsewhere); this is just for custom formats.
				return false;
			}
			if (!oldActive.hp) {
				// a pokemon fainted from Pursuit before it could switch
				return 'pursuitfaint';
			}

			// will definitely switch out at this point

			this.battle.singleEvent('End', oldActive.getAbility(), oldActive.abilityState, oldActive);
			this.battle.singleEvent('End', oldActive.getItem(), oldActive.itemState, oldActive);

			// if a pokemon is forced out by Whirlwind/etc or Eject Button/Pack, it can't use its chosen move
			this.battle.queue.cancelAction(oldActive);

			let newMove = null;
			if (this.battle.gen === 4 && sourceEffect) {
				newMove = oldActive.lastMove;
			}
			if (switchCopyFlag) {
				pokemon.copyVolatileFrom(oldActive, switchCopyFlag);
			}
			if (newMove) pokemon.lastMove = newMove;
			oldActive.clearVolatile();
		}
		if (oldActive) {
			oldActive.isActive = false;
			oldActive.isStarted = false;
			oldActive.usedItemThisTurn = false;
			oldActive.statsRaisedThisTurn = false;
			oldActive.statsLoweredThisTurn = false;
			oldActive.position = pokemon.position;
			if (oldActive.fainted) oldActive.status = '';
			if (this.battle.gen <= 4) {
				pokemon.lastItem = oldActive.lastItem;
				oldActive.lastItem = '';
			}
			pokemon.position = pos;
			side.pokemon[pokemon.position] = pokemon;
			side.pokemon[oldActive.position] = oldActive;
		}
		pokemon.isActive = true;
		side.active[pos] = pokemon;
		pokemon.activeTurns = 0;
		pokemon.activeMoveActions = 0;
		for (const moveSlot of pokemon.moveSlots) {
			moveSlot.used = false;
		}
		pokemon.abilityState = this.battle.initEffectState({ id: pokemon.ability, target: pokemon });
		pokemon.itemState = this.battle.initEffectState({ id: pokemon.item, target: pokemon });
		this.battle.runEvent('BeforeSwitchIn', pokemon);
		if (sourceEffect) {
			this.battle.add(isDrag ? 'drag' : 'switch', pokemon, pokemon.getFullDetails, `[from] ${sourceEffect}`);
		} else {
			this.battle.add(isDrag ? 'drag' : 'switch', pokemon, pokemon.getFullDetails);
		}
		if (isDrag && this.battle.gen === 2) pokemon.draggedIn = this.battle.turn;
		pokemon.previouslySwitchedIn++;

		if (isDrag && this.battle.gen >= 5) {
			// runSwitch happens immediately so that Mold Breaker can make hazards bypass Clear Body and Levitate
			this.runSwitch(pokemon);
		} else {
			this.battle.queue.insertChoice({ choice: 'runSwitch', pokemon });
		}

		return true;
	}
	dragIn(side: Side, pos: number) {
		const pokemon = this.battle.getRandomSwitchable(side);
		if (!pokemon || pokemon.isActive) return false;
		const oldActive = side.active[pos];
		if (!oldActive) throw new Error(`nothing to drag out`);
		if (!oldActive.hp) return false;

		if (!this.battle.runEvent('DragOut', oldActive)) {
			return false;
		}
		if (!this.switchIn(pokemon, pos, null, true)) return false;
		return true;
	}
	runSwitch(pokemon: Pokemon) {
		const switchersIn = [pokemon];
		while (this.battle.queue.peek()?.choice === 'runSwitch') {
			const nextSwitch = this.battle.queue.shift();
			switchersIn.push(nextSwitch!.pokemon!);
		}
		const allActive = this.battle.getAllActive(true);
		this.battle.speedSort(allActive);
		this.battle.speedOrder = allActive.map(a => a.getFieldPositionValue());
		this.battle.fieldEvent('SwitchIn', switchersIn);

		for (const poke of switchersIn) {
			if (!poke.hp) continue;
			poke.isStarted = true;
			poke.draggedIn = null;
		}
		return true;
	}

	// #endregion

	// #region MOVES
	// ==================================================================

	/**
	 * runMove is the "outside" move caller. It handles deducting PP,
	 * flinching, full paralysis, etc. All the stuff up to and including
	 * the "POKEMON used MOVE" message.
	 *
	 * For details of the difference between runMove and useMove, see
	 * useMove's info.
	 *
	 * externalMove skips LockMove and PP deduction, mostly for use by
	 * Dancer.
	 */
	runMove(
		moveOrMoveName: Move | string, pokemon: Pokemon, targetLoc: number,
		options?: {
			sourceEffect?: Effect | null, zMove?: string, externalMove?: boolean,
			maxMove?: string, originalTarget?: Pokemon,
		}
	) {
		pokemon.activeMoveActions++;
		const zMove = options?.zMove;
		const maxMove = options?.maxMove;
		const externalMove = options?.externalMove;
		const originalTarget = options?.originalTarget;
		let sourceEffect = options?.sourceEffect;
		let target = this.battle.getTarget(pokemon, maxMove || zMove || moveOrMoveName, targetLoc, originalTarget);
		let baseMove = this.dex.getActiveMove(moveOrMoveName);
		const priority = baseMove.priority;
		const pranksterBoosted = baseMove.pranksterBoosted;
		if (baseMove.id !== 'struggle' && !zMove && !maxMove && !externalMove) {
			const changedMove = this.battle.runEvent('OverrideAction', pokemon, target, baseMove);
			if (changedMove && changedMove !== true) {
				baseMove = this.dex.getActiveMove(changedMove);
				baseMove.priority = priority;
				if (pranksterBoosted) baseMove.pranksterBoosted = pranksterBoosted;
				target = this.battle.getRandomTarget(pokemon, baseMove);
			}
		}
		let move = baseMove;
		if (zMove) {
			move = this.getActiveZMove(baseMove, pokemon);
		} else if (maxMove) {
			move = this.getActiveMaxMove(baseMove, pokemon);
		}

		move.isExternal = externalMove;

		this.battle.setActiveMove(move, pokemon, target);

		/* if (pokemon.moveThisTurn) {
			// THIS IS PURELY A SANITY CHECK
			// DO NOT TAKE ADVANTAGE OF THIS TO PREVENT A POKEMON FROM MOVING;
			// USE this.queue.cancelMove INSTEAD
			this.battle.debug(`${pokemon.id} INCONSISTENT STATE, ALREADY MOVED: ${pokemon.moveThisTurn}`);
			this.battle.clearActiveMove(true);
			return;
		} */
		const willTryMove = this.battle.runEvent('BeforeMove', pokemon, target, move);
		if (!willTryMove) {
			this.battle.runEvent('MoveAborted', pokemon, target, move);
			this.battle.clearActiveMove(true);
			// The event 'BeforeMove' could have returned false or null
			// false indicates that this counts as a move failing for the purpose of calculating Stomping Tantrum's base power
			// null indicates the opposite, as the Pokemon didn't have an option to choose anything
			pokemon.moveThisTurnResult = willTryMove;
			return;
		}

		// Used exclusively for a hint later
		if (move.flags['cantusetwice'] && pokemon.lastMove?.id === move.id) {
			pokemon.addVolatile(move.id);
		}

		if (move.beforeMoveCallback) {
			if (move.beforeMoveCallback.call(this.battle, pokemon, target, move)) {
				this.battle.clearActiveMove(true);
				pokemon.moveThisTurnResult = false;
				return;
			}
		}
		pokemon.lastDamage = 0;
		if (!externalMove) {
			const lockedMove = pokemon.getLockedMove();
			if (!lockedMove) {
				if (!pokemon.deductPP(baseMove, null, target) && (move.id !== 'struggle')) {
					this.battle.add('cant', pokemon, 'nopp', move);
					this.battle.clearActiveMove(true);
					pokemon.moveThisTurnResult = false;
					return;
				}
			} else {
				sourceEffect = this.dex.conditions.get('lockedmove');
			}
			pokemon.moveUsed(move, targetLoc);
		}

		// Dancer Petal Dance hack
		// TODO: implement properly
		const noLock = externalMove && !pokemon.volatiles['lockedmove'];

		if (zMove) {
			if (pokemon.illusion) {
				this.battle.singleEvent('End', this.dex.abilities.get('Illusion'), pokemon.abilityState, pokemon);
			}
			this.battle.add('-zpower', pokemon);
			pokemon.side.zMoveUsed = true;
		}

		const oldActiveMove = move;

		const moveDidSomething = this.useMove(baseMove, pokemon, { target, sourceEffect, zMove, maxMove });
		this.battle.lastSuccessfulMoveThisTurn = moveDidSomething ? this.battle.activeMove && this.battle.activeMove.id : null;
		if (this.battle.activeMove) move = this.battle.activeMove;
		this.battle.singleEvent('AfterMove', move, null, pokemon, target, move);
		this.battle.runEvent('AfterMove', pokemon, target, move);
		if (move.flags['cantusetwice'] && pokemon.removeVolatile(move.id)) {
			this.battle.add('-hint', `Some effects can force a Pokemon to use ${move.name} again in a row.`);
		}

		// TODO: Refactor to use BattleQueue#prioritizeAction in onAnyAfterMove handlers
		// Dancer's activation order is completely different from any other event, so it's handled separately
		if (move.flags['dance'] && moveDidSomething && !move.isExternal) {
			const dancers = [];
			for (const currentPoke of this.battle.getAllActive()) {
				if (pokemon === currentPoke) continue;
				if (currentPoke.hasAbility('dancer') && !currentPoke.isSemiInvulnerable()) {
					dancers.push(currentPoke);
				}
			}
			// Dancer activates in order of lowest speed stat to highest
			// Note that the speed stat used is after any volatile replacements like Speed Swap,
			// but before any multipliers like Agility or Choice Scarf
			// Ties go to whichever Pokemon has had the ability for the least amount of time
			dancers.sort(
				(a, b) => -(b.storedStats['spe'] - a.storedStats['spe']) || b.abilityState.effectOrder - a.abilityState.effectOrder
			);
			const targetOf1stDance = this.battle.activeTarget!;
			for (const dancer of dancers) {
				if (this.battle.faintMessages()) break;
				if (dancer.fainted) continue;
				this.battle.add('-activate', dancer, 'ability: Dancer');
				const dancersTarget = !targetOf1stDance.isAlly(dancer) && pokemon.isAlly(dancer) ?
					targetOf1stDance :
					pokemon;
				const dancersTargetLoc = dancer.getLocOf(dancersTarget);
				this.runMove(move.id, dancer, dancersTargetLoc, { sourceEffect: this.dex.abilities.get('dancer'), externalMove: true });
			}
		}
		if (noLock && pokemon.volatiles['lockedmove']) delete pokemon.volatiles['lockedmove'];
		this.battle.faintMessages();
		this.battle.checkWin();

		if (this.battle.gen <= 4) {
			// In gen 4, the outermost move is considered the last move for Copycat
			this.battle.activeMove = oldActiveMove;
		}
	}
	/**
	 * useMove is the "inside" move caller. It handles effects of the
	 * move itself, but not the idea of using the move.
	 *
	 * Most caller effects, like Sleep Talk, Nature Power, Magic Bounce,
	 * etc use useMove.
	 *
	 * The only ones that use runMove are Instruct, Pursuit, and
	 * Dancer.
	 */
	useMove(
		move: Move | string, pokemon: Pokemon, options?: {
			target?: Pokemon | null, sourceEffect?: Effect | null,
			zMove?: string, maxMove?: string,
		}
	) {
		pokemon.moveThisTurnResult = undefined;
		const oldMoveResult: boolean | null | undefined = pokemon.moveThisTurnResult;
		const moveResult = this.useMoveInner(move, pokemon, options);
		if (oldMoveResult === pokemon.moveThisTurnResult) pokemon.moveThisTurnResult = moveResult;
		return moveResult;
	}
	useMoveInner(
		moveOrMoveName: Move | string, pokemon: Pokemon, options?: {
			target?: Pokemon | null, sourceEffect?: Effect | null,
			zMove?: string, maxMove?: string,
		},
	) {
		let target = options?.target;
		let sourceEffect = options?.sourceEffect;
		const zMove = options?.zMove;
		const maxMove = options?.maxMove;
		if (!sourceEffect && this.battle.effect.id) sourceEffect = this.battle.effect;
		if (sourceEffect && ['instruct', 'custapberry'].includes(sourceEffect.id)) sourceEffect = null;

		let move = this.dex.getActiveMove(moveOrMoveName);
		pokemon.lastMoveUsed = move;
		if (move.id === 'weatherball' && zMove) {
			// Z-Weather Ball only changes types if it's used directly,
			// not if it's called by Z-Sleep Talk or something.
			this.battle.singleEvent('ModifyType', move, null, pokemon, target, move, move);
			if (move.type !== 'Normal') sourceEffect = move;
		}
		if (zMove || (move.category !== 'Status' && sourceEffect && (sourceEffect as ActiveMove).isZ)) {
			move = this.getActiveZMove(move, pokemon);
		}
		if (maxMove && move.category !== 'Status') {
			// Max move outcome is dependent on the move type after type modifications from ability and the move itself
			this.battle.singleEvent('ModifyType', move, null, pokemon, target, move, move);
			this.battle.runEvent('ModifyType', pokemon, target, move, move);
		}
		if (maxMove || (move.category !== 'Status' && sourceEffect && (sourceEffect as ActiveMove).isMax)) {
			move = this.getActiveMaxMove(move, pokemon);
		}

		if (this.battle.activeMove) {
			move.priority = this.battle.activeMove.priority;
			if (!move.hasBounced) move.pranksterBoosted = this.battle.activeMove.pranksterBoosted;
		}
		const baseTarget = move.target;
		let targetRelayVar = { target };
		targetRelayVar = this.battle.runEvent('ModifyTarget', pokemon, target, move, targetRelayVar, true);
		if (targetRelayVar.target !== undefined) target = targetRelayVar.target;
		if (target === undefined) target = this.battle.getRandomTarget(pokemon, move);
		if (move.target === 'self' || move.target === 'allies') {
			target = pokemon;
		}
		if (sourceEffect) {
			move.sourceEffect = sourceEffect.id;
			move.ignoreAbility = (sourceEffect as ActiveMove).ignoreAbility;
		}
		let moveResult = false;

		this.battle.setActiveMove(move, pokemon, target);

		this.battle.singleEvent('ModifyType', move, null, pokemon, target, move, move);
		this.battle.singleEvent('ModifyMove', move, null, pokemon, target, move, move);
		if (baseTarget !== move.target) {
			// Target changed in ModifyMove, so we must adjust it here
			// Adjust before the next event so the correct target is passed to the
			// event
			target = this.battle.getRandomTarget(pokemon, move);
		}
		move = this.battle.runEvent('ModifyType', pokemon, target, move, move);
		move = this.battle.runEvent('ModifyMove', pokemon, target, move, move);
		if (baseTarget !== move.target) {
			// Adjust again
			target = this.battle.getRandomTarget(pokemon, move);
		}
		if (!move || pokemon.fainted) {
			return false;
		}

		let attrs = '';

		let movename = move.name;
		if (move.id === 'hiddenpower') movename = 'Hidden Power';
		if (sourceEffect) attrs += `|[from] ${sourceEffect.fullname}`;
		if (zMove && move.isZ === true) {
			attrs = `|[anim]${movename}${attrs}`;
			movename = `Z-${movename}`;
		}
		this.battle.addMove('move', pokemon, movename, `${target}${attrs}`);

		if (zMove) this.runZPower(move, pokemon);

		if (!target) {
			this.battle.attrLastMove('[notarget]');
			this.battle.add(this.battle.gen >= 5 ? '-fail' : '-notarget', pokemon);
			return false;
		}

		const { targets, pressureTargets } = pokemon.getMoveTargets(move, target);
		if (targets.length) {
			target = targets[targets.length - 1]; // in case of redirection
		}

		const callerMoveForPressure = sourceEffect && (sourceEffect as ActiveMove).pp ? sourceEffect as ActiveMove : null;
		if (!sourceEffect || callerMoveForPressure || sourceEffect.id === 'pursuit') {
			let extraPP = 0;
			for (const source of pressureTargets) {
				const ppDrop = this.battle.runEvent('DeductPP', source, pokemon, move);
				if (ppDrop !== true) {
					extraPP += ppDrop || 0;
				}
			}
			if (extraPP > 0) {
				pokemon.deductPP(callerMoveForPressure || moveOrMoveName, extraPP);
			}
		}

		let tryMoveResult = this.battle.singleEvent('TryMove', move, null, pokemon, target, move);
		if (tryMoveResult) {
			tryMoveResult = this.battle.runEvent('TryMove', pokemon, target, move);
		}
		if (!tryMoveResult) {
			move.mindBlownRecoil = false;
			return tryMoveResult;
		}

		this.battle.singleEvent('UseMoveMessage', move, null, pokemon, target, move);

		if (move.ignoreImmunity === undefined) {
			move.ignoreImmunity = (move.category === 'Status');
		}

		if (this.battle.gen !== 4 && move.selfdestruct === 'always') {
			this.battle.faint(pokemon, pokemon, move);
		}

		let damage: number | false | undefined | '' = false;
		if (move.target === 'all' || move.target === 'foeSide' || move.target === 'allySide' || move.target === 'allyTeam') {
			damage = this.tryMoveHit(targets, pokemon, move);
			if (damage === this.battle.NOT_FAIL) pokemon.moveThisTurnResult = null;
			if (damage || damage === 0 || damage === undefined) moveResult = true;
		} else {
			if (!targets.length) {
				this.battle.attrLastMove('[notarget]');
				this.battle.add(this.battle.gen >= 5 ? '-fail' : '-notarget', pokemon);
				return false;
			}
			if (this.battle.gen === 4 && move.selfdestruct === 'always') {
				this.battle.faint(pokemon, pokemon, move);
			}
			moveResult = this.trySpreadMoveHit(targets, pokemon, move);
		}
		if (move.selfBoost && moveResult) this.moveHit(pokemon, pokemon, move, move.selfBoost, false, true);
		if (!pokemon.hp) {
			this.battle.faint(pokemon, pokemon, move);
		}

		if (!moveResult) {
			this.battle.singleEvent('MoveFail', move, null, target, pokemon, move);
			return false;
		}

		if (!(move.hasSheerForce && pokemon.hasAbility('sheerforce')) && !move.flags['futuremove']) {
			const originalHp = pokemon.hp;
			this.battle.singleEvent('AfterMoveSecondarySelf', move, null, pokemon, target, move);
			this.battle.runEvent('AfterMoveSecondarySelf', pokemon, target, move);
			if (pokemon && pokemon !== target && move.category !== 'Status') {
				if (pokemon.hp <= pokemon.maxhp / 2 && originalHp > pokemon.maxhp / 2) {
					this.battle.runEvent('EmergencyExit', pokemon, pokemon);
				}
			}
		}

		return true;
	}
	/** NOTE: includes single-target moves */
	trySpreadMoveHit(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove, notActive?: boolean) {
		if (targets.length > 1 && !move.smartTarget) move.spreadHit = true;

		const moveSteps: ((targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) =>
		(number | boolean | "" | undefined)[] | undefined)[] = [
			// 0. check for semi invulnerability
			this.hitStepInvulnerabilityEvent,

			// 1. run the 'TryHit' event (Protect, Magic Bounce, Volt Absorb, etc.) (this is step 2 in gens 5 & 6, and step 4 in gen 4)
			this.hitStepTryHitEvent,

			// 2. check for type immunity (this is step 1 in gens 4-6)
			this.hitStepTypeImmunity,

			// 3. check for various move-specific immunities
			this.hitStepTryImmunity,

			// 4. check accuracy
			this.hitStepAccuracy,

			// 5. break protection effects
			this.hitStepBreakProtect,

			// 6. steal positive boosts (Spectral Thief)
			this.hitStepStealBoosts,

			// 7. loop that processes each hit of the move (has its own steps per iteration)
			this.hitStepMoveHitLoop,
		];
		if (this.battle.gen <= 6) {
			// Swap step 1 with step 2
			[moveSteps[1], moveSteps[2]] = [moveSteps[2], moveSteps[1]];
		}
		if (this.battle.gen === 4) {
			// Swap step 4 with new step 2 (old step 1)
			[moveSteps[2], moveSteps[4]] = [moveSteps[4], moveSteps[2]];
		}

		if (notActive) this.battle.setActiveMove(move, pokemon, targets[0]);

		const hitResult = this.battle.singleEvent('Try', move, null, pokemon, targets[0], move) &&
			this.battle.singleEvent('PrepareHit', move, {}, targets[0], pokemon, move) &&
			this.battle.runEvent('PrepareHit', pokemon, targets[0], move);
		if (!hitResult) {
			if (hitResult === false) {
				this.battle.add('-fail', pokemon);
				this.battle.attrLastMove('[still]');
			}
			return hitResult === this.battle.NOT_FAIL;
		}

		let atLeastOneFailure = false;
		for (const step of moveSteps) {
			const hitResults: (number | boolean | "" | undefined)[] | undefined = step.call(this, targets, pokemon, move);
			if (!hitResults) continue;
			targets = targets.filter((val, i) => hitResults[i] || hitResults[i] === 0);
			atLeastOneFailure = atLeastOneFailure || hitResults.some(val => val === false);
			if (move.smartTarget && atLeastOneFailure) move.smartTarget = false;
			if (!targets.length) {
				// console.log(step.name);
				break;
			}
		}

		move.hitTargets = targets;
		const moveResult = !!targets.length;
		if (!moveResult && !atLeastOneFailure) pokemon.moveThisTurnResult = null;
		const hitSlot = targets.map(p => p.getSlot());
		if (move.spreadHit) this.battle.attrLastMove('[spread] ' + hitSlot.join(','));
		return moveResult;
	}
	hitStepInvulnerabilityEvent(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		if (move.id === 'helpinghand') return new Array(targets.length).fill(true);
		const hitResults: boolean[] = [];
		for (const [i, target] of targets.entries()) {
			if (target.volatiles['commanding']) {
				hitResults[i] = false;
			} else if (this.battle.gen >= 8 && move.id === 'toxic' && pokemon.hasType('Poison')) {
				hitResults[i] = true;
			} else {
				hitResults[i] = this.battle.runEvent('Invulnerability', target, pokemon, move);
			}
			if (hitResults[i] === false) {
				if (move.smartTarget) {
					move.smartTarget = false;
				} else {
					if (!move.spreadHit) this.battle.attrLastMove('[miss]');
					this.battle.add('-miss', pokemon, target);
				}
			}
		}
		return hitResults;
	}
	hitStepTryHitEvent(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		const hitResults = this.battle.runEvent('TryHit', targets, pokemon, move);
		if (!hitResults.includes(true) && hitResults.includes(false)) {
			this.battle.add('-fail', pokemon);
			this.battle.attrLastMove('[still]');
		}
		for (const i of targets.keys()) {
			if (hitResults[i] !== this.battle.NOT_FAIL) hitResults[i] = hitResults[i] || false;
		}
		return hitResults;
	}
	hitStepTypeImmunity(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		if (move.ignoreImmunity === undefined) {
			move.ignoreImmunity = (move.category === 'Status');
		}

		const hitResults = [];
		for (const i of targets.keys()) {
			hitResults[i] = targets[i].runImmunity(move, !move.smartTarget);
		}

		return hitResults;
	}
	hitStepTryImmunity(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		const hitResults = [];
		for (const [i, target] of targets.entries()) {
			if (this.battle.gen >= 6 && move.flags['powder'] && target !== pokemon && !this.dex.getImmunity('powder', target)) {
				this.battle.debug('natural powder immunity');
				this.battle.add('-immune', target);
				hitResults[i] = false;
			} else if (!this.battle.singleEvent('TryImmunity', move, {}, target, pokemon, move)) {
				this.battle.add('-immune', target);
				hitResults[i] = false;
			} else if (this.battle.gen >= 7 && move.pranksterBoosted && pokemon.hasAbility('prankster') &&
				!targets[i].isAlly(pokemon) && !this.dex.getImmunity('prankster', target)) {
				this.battle.debug('natural prankster immunity');
				if (target.illusion || !(move.status && !this.dex.getImmunity(move.status, target))) {
					this.battle.hint("Since gen 7, Dark is immune to Prankster moves.");
				}
				this.battle.add('-immune', target);
				hitResults[i] = false;
			} else {
				hitResults[i] = true;
			}
		}
		return hitResults;
	}
	hitStepAccuracy(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		const hitResults = [];
		for (const [i, target] of targets.entries()) {
			this.battle.activeTarget = target;
			// calculate true accuracy
			let accuracy = move.accuracy;
			if (move.ohko) { // bypasses accuracy modifiers
				if (!target.isSemiInvulnerable()) {
					accuracy = 30;
					if (move.ohko === 'Ice' && this.battle.gen >= 7 && !pokemon.hasType('Ice')) {
						accuracy = 20;
					}
					if (!target.volatiles['dynamax'] && pokemon.level >= target.level &&
						(move.ohko === true || !target.hasType(move.ohko))) {
						accuracy += (pokemon.level - target.level);
					} else {
						this.battle.add('-immune', target, '[ohko]');
						hitResults[i] = false;
						continue;
					}
				}
			} else {
				accuracy = this.battle.runEvent('ModifyAccuracy', target, pokemon, move, accuracy);
				if (accuracy !== true) {
					let boost = 0;
					if (!move.ignoreAccuracy) {
						const boosts = this.battle.runEvent('ModifyBoost', pokemon, null, null, { ...pokemon.boosts });
						boost = this.battle.clampIntRange(boosts['accuracy'], -6, 6);
					}
					if (!move.ignoreEvasion) {
						const boosts = this.battle.runEvent('ModifyBoost', target, null, null, { ...target.boosts });
						boost = this.battle.clampIntRange(boost - boosts['evasion'], -6, 6);
					}
					if (boost > 0) {
						accuracy = this.battle.trunc(accuracy * (3 + boost) / 3);
					} else if (boost < 0) {
						accuracy = this.battle.trunc(accuracy * 3 / (3 - boost));
					}
				}
			}
			if (
				move.alwaysHit || (move.id === 'toxic' && this.battle.gen >= 8 && pokemon.hasType('Poison')) ||
				(move.target === 'self' && move.category === 'Status' && !target.isSemiInvulnerable())
			) {
				accuracy = true; // bypasses ohko accuracy modifiers
			} else {
				accuracy = this.battle.runEvent('Accuracy', target, pokemon, move, accuracy);
			}
			if (accuracy !== true && !this.battle.randomChance(accuracy, 100)) {
				if (move.smartTarget) {
					move.smartTarget = false;
				} else {
					if (!move.spreadHit) this.battle.attrLastMove('[miss]');
					this.battle.add('-miss', pokemon, target);
				}
				if (!move.ohko && pokemon.hasItem('blunderpolicy') && pokemon.useItem()) {
					this.battle.boost({ spe: 2 }, pokemon);
				}
				hitResults[i] = false;
				continue;
			}
			hitResults[i] = true;
		}
		return hitResults;
	}
	hitStepBreakProtect(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		if (move.breaksProtect) {
			for (const target of targets) {
				let broke = false;
				for (const effectid of [
					'banefulbunker', 'burningbulwark', 'kingsshield', 'obstruct', 'protect', 'silktrap', 'spikyshield',
				]) {
					if (target.removeVolatile(effectid)) broke = true;
				}
				if (this.battle.gen >= 6 || !target.isAlly(pokemon)) {
					for (const effectid of ['craftyshield', 'matblock', 'quickguard', 'wideguard']) {
						if (target.side.removeSideCondition(effectid)) broke = true;
					}
				}
				if (broke) {
					if (move.id === 'feint') {
						this.battle.add('-activate', target, 'move: Feint');
					} else {
						this.battle.add('-activate', target, `move: ${move.name}`, '[broken]');
					}
					if (this.battle.gen >= 6) delete target.volatiles['stall'];
				}
			}
		}
		return undefined;
	}
	hitStepStealBoosts(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		const target = targets[0]; // hardcoded
		if (move.stealsBoosts) {
			const boosts: SparseBoostsTable = {};
			let stolen = false;
			let statName: BoostID;
			for (statName in target.boosts) {
				const stage = target.boosts[statName];
				if (stage > 0) {
					boosts[statName] = stage;
					stolen = true;
				}
			}
			if (stolen) {
				this.battle.attrLastMove('[still]');
				this.battle.add('-clearpositiveboost', target, pokemon, 'move: ' + move.name);
				this.battle.boost(boosts, pokemon, pokemon);

				let statName2: BoostID;
				for (statName2 in boosts) {
					boosts[statName2] = 0;
				}
				target.setBoost(boosts);
				if (move.id === "spectralthief") {
					this.battle.addMove('-anim', pokemon, "Spectral Thief", target);
				}
			}
		}
		return undefined;
	}
	afterMoveSecondaryEvent(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) {
		// console.log(`${targets}, ${pokemon}, ${move}`)
		if (!(move.hasSheerForce && pokemon.hasAbility('sheerforce'))) {
			this.battle.singleEvent('AfterMoveSecondary', move, null, targets[0], pokemon, move);
			this.battle.runEvent('AfterMoveSecondary', targets, pokemon, move);
		}
		return undefined;
	}
	/** NOTE: used only for moves that target sides/fields rather than pokemon */
	tryMoveHit(targetOrTargets: Pokemon | Pokemon[], pokemon: Pokemon, move: ActiveMove): number | undefined | false | '' {
		const target = Array.isArray(targetOrTargets) ? targetOrTargets[0] : targetOrTargets;
		const targets = Array.isArray(targetOrTargets) ? targetOrTargets : [target];

		this.battle.setActiveMove(move, pokemon, targets[0]);

		let hitResult = this.battle.singleEvent('Try', move, null, pokemon, target, move) &&
			this.battle.singleEvent('PrepareHit', move, {}, target, pokemon, move) &&
			this.battle.runEvent('PrepareHit', pokemon, target, move);
		if (!hitResult) {
			if (hitResult === false) {
				this.battle.add('-fail', pokemon);
				this.battle.attrLastMove('[still]');
			}
			return false;
		}

		const isFFAHazard = move.target === 'foeSide' && this.battle.gameType === 'freeforall';
		if (move.target === 'all') {
			hitResult = this.battle.runEvent('TryHitField', target, pokemon, move);
		} else if (isFFAHazard) {
			const hitResults: any[] = this.battle.runEvent('TryHitSide', targets, pokemon, move);
			// if some side blocked the move, prevent the move from executing against any other sides
			if (hitResults.some(result => !result)) return false;
			hitResult = true;
		} else {
			hitResult = this.battle.runEvent('TryHitSide', target, pokemon, move);
		}
		if (!hitResult) {
			if (hitResult === false) {
				this.battle.add('-fail', pokemon);
				this.battle.attrLastMove('[still]');
			}
			return false;
		}
		return this.moveHit(isFFAHazard ? targets : target, pokemon, move);
	}
	hitStepMoveHitLoop(targets: Pokemon[], pokemon: Pokemon, move: ActiveMove) { // Temporary name
		let damage: (number | boolean | undefined)[] = [];
		for (const i of targets.keys()) {
			damage[i] = 0;
		}
		move.totalDamage = 0;
		pokemon.lastDamage = 0;
		let targetHits = move.multihit || 1;
		if (Array.isArray(targetHits)) {
			// yes, it's hardcoded... meh
			if (targetHits[0] === 2 && targetHits[1] === 5) {
				if (this.battle.gen >= 5) {
					// 35-35-15-15 out of 100 for 2-3-4-5 hits
					targetHits = this.battle.sample([2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 5, 5, 5]);
					if (targetHits < 4 && pokemon.hasItem('loadeddice')) {
						targetHits = 5 - this.battle.random(2);
					}
				} else {
					targetHits = this.battle.sample([2, 2, 2, 3, 3, 3, 4, 5]);
				}
			} else {
				targetHits = this.battle.random(targetHits[0], targetHits[1] + 1);
			}
		}
		if (targetHits === 10 && pokemon.hasItem('loadeddice')) targetHits -= this.battle.random(7);
		targetHits = Math.floor(targetHits);
		let nullDamage = true;
		let moveDamage: (number | boolean | undefined)[] = [];
		// There is no need to recursively check the ´sleepUsable´ flag as Sleep Talk can only be used while asleep.
		const isSleepUsable = move.sleepUsable || this.dex.moves.get(move.sourceEffect).sleepUsable;

		let targetsCopy: (Pokemon | false | null)[] = targets.slice(0);
		let hit: number;
		for (hit = 1; hit <= targetHits; hit++) {
			if (damage.includes(false)) break;
			if (hit > 1 && pokemon.status === 'slp' && (!isSleepUsable || this.battle.gen === 4)) break;
			if (targets.every(target => !target?.hp)) break;
			move.hit = hit;
			move.lastHit = move.hit === targetHits;
			if (move.smartTarget && targets.length > 1) {
				targetsCopy = [targets[hit - 1]];
				damage = [damage[hit - 1]];
			} else {
				targetsCopy = targets.slice(0);
			}
			const target = targetsCopy[0]; // some relevant-to-single-target-moves-only things are hardcoded
			if (target && typeof move.smartTarget === 'boolean') {
				if (hit > 1) {
					this.battle.addMove('-anim', pokemon, move.name, target);
				} else {
					this.battle.retargetLastMove(target);
				}
			}

			// like this (Triple Kick)
			if (target && move.multiaccuracy && hit > 1) {
				let accuracy = move.accuracy;
				const boostTable = [1, 4 / 3, 5 / 3, 2, 7 / 3, 8 / 3, 3];
				if (accuracy !== true) {
					if (!move.ignoreAccuracy) {
						const boosts = this.battle.runEvent('ModifyBoost', pokemon, null, null, { ...pokemon.boosts });
						const boost = this.battle.clampIntRange(boosts['accuracy'], -6, 6);
						if (boost > 0) {
							accuracy *= boostTable[boost];
						} else {
							accuracy /= boostTable[-boost];
						}
					}
					if (!move.ignoreEvasion) {
						const boosts = this.battle.runEvent('ModifyBoost', target, null, null, { ...target.boosts });
						const boost = this.battle.clampIntRange(boosts['evasion'], -6, 6);
						if (boost > 0) {
							accuracy /= boostTable[boost];
						} else if (boost < 0) {
							accuracy *= boostTable[-boost];
						}
					}
				}
				accuracy = this.battle.runEvent('ModifyAccuracy', target, pokemon, move, accuracy);
				if (!move.alwaysHit) {
					accuracy = this.battle.runEvent('Accuracy', target, pokemon, move, accuracy);
					if (accuracy !== true && !this.battle.randomChance(accuracy, 100)) break;
				}
			}

			const moveData = move;
			if (!moveData.flags) moveData.flags = {};

			let moveDamageThisHit;
			// Modifies targetsCopy (which is why it's a copy)
			[moveDamageThisHit, targetsCopy] = this.spreadMoveHit(targetsCopy, pokemon, move, moveData);
			// When Dragon Darts targets two different pokemon, targetsCopy is a length 1 array each hit
			// so spreadMoveHit returns a length 1 damage array
			if (move.smartTarget) {
				moveDamage.push(...moveDamageThisHit);
			} else {
				moveDamage = moveDamageThisHit;
			}

			if (!moveDamage.some(val => val !== false)) break;
			nullDamage = false;

			for (const [i, md] of moveDamage.entries()) {
				if (move.smartTarget && i !== hit - 1) continue;
				// Damage from each hit is individually counted for the
				// purposes of Counter, Metal Burst, and Mirror Coat.
				damage[i] = md === true || !md ? 0 : md;
				// Total damage dealt is accumulated for the purposes of recoil (Parental Bond).
				move.totalDamage += damage[i];
			}
			if (move.mindBlownRecoil) {
				const hpBeforeRecoil = pokemon.hp;
				this.battle.damage(Math.round(pokemon.maxhp / 2), pokemon, pokemon, this.dex.conditions.get(move.id), true);
				move.mindBlownRecoil = false;
				if (pokemon.hp <= pokemon.maxhp / 2 && hpBeforeRecoil > pokemon.maxhp / 2) {
					this.battle.runEvent('EmergencyExit', pokemon, pokemon);
				}
			}
			this.battle.eachEvent('Update');
			if (!pokemon.hp && targets.length === 1) {
				hit++; // report the correct number of hits for multihit moves
				break;
			}
		}
		// hit is 1 higher than the actual hit count
		if (hit === 1) return damage.fill(false);
		if (nullDamage) damage.fill(false);
		this.battle.faintMessages(false, false, !pokemon.hp);
		if (move.multihit && typeof move.smartTarget !== 'boolean') {
			this.battle.add('-hitcount', targets[0], hit - 1);
		}

		if ((move.recoil || move.id === 'chloroblast') && move.totalDamage) {
			const hpBeforeRecoil = pokemon.hp;
			this.battle.damage(this.calcRecoilDamage(move.totalDamage, move, pokemon), pokemon, pokemon, 'recoil');
			if (pokemon.hp <= pokemon.maxhp / 2 && hpBeforeRecoil > pokemon.maxhp / 2) {
				this.battle.runEvent('EmergencyExit', pokemon, pokemon);
			}
		}

		if (move.struggleRecoil) {
			const hpBeforeRecoil = pokemon.hp;
			let recoilDamage;
			if (this.dex.gen >= 5) {
				recoilDamage = this.battle.clampIntRange(Math.round(pokemon.baseMaxhp / 4), 1);
			} else {
				recoilDamage = this.battle.clampIntRange(this.battle.trunc(pokemon.maxhp / 4), 1);
			}
			this.battle.directDamage(recoilDamage, pokemon, pokemon, { id: 'strugglerecoil' } as Condition);
			if (pokemon.hp <= pokemon.maxhp / 2 && hpBeforeRecoil > pokemon.maxhp / 2) {
				this.battle.runEvent('EmergencyExit', pokemon, pokemon);
			}
		}

		// smartTarget messes up targetsCopy, but smartTarget should in theory ensure that targets will never fail, anyway
		if (move.smartTarget) {
			targetsCopy = targets.slice(0);
		}

		for (const [i, target] of targetsCopy.entries()) {
			if (target && pokemon !== target) {
				target.gotAttacked(move, moveDamage[i] as number | false | undefined, pokemon);
				if (typeof moveDamage[i] === 'number') {
					target.timesAttacked += move.smartTarget ? 1 : hit - 1;
				}
			}
		}

		if (move.ohko && !targets[0].hp) this.battle.add('-ohko');

		if (!damage.some(val => !!val || val === 0)) return damage;

		this.battle.eachEvent('Update');

		this.afterMoveSecondaryEvent(targetsCopy.filter(val => !!val), pokemon, move);

		if (!(move.hasSheerForce && pokemon.hasAbility('sheerforce'))) {
			for (const [i, d] of damage.entries()) {
				// There are no multihit spread moves, so it's safe to use move.totalDamage for multihit moves
				// The previous check was for `move.multihit`, but that fails for Dragon Darts
				const curDamage = targets.length === 1 ? move.totalDamage : d;
				if (typeof curDamage === 'number' && targets[i].hp) {
					const targetHPBeforeDamage = (targets[i].hurtThisTurn || 0) + curDamage;
					if (targets[i].hp <= targets[i].maxhp / 2 && targetHPBeforeDamage > targets[i].maxhp / 2) {
						this.battle.runEvent('EmergencyExit', targets[i], pokemon);
					}
				}
			}
		}

		return damage;
	}
	spreadMoveHit(
		targets: SpreadMoveTargets, pokemon: Pokemon, moveOrMoveName: ActiveMove,
		hitEffect?: Dex.HitEffect, isSecondary?: boolean, isSelf?: boolean
	): [SpreadMoveDamage, SpreadMoveTargets] {
		// Hardcoded for single-target purposes
		// (no spread moves have any kind of onTryHit handler)
		const target = targets[0];
		let damage: (number | boolean | undefined)[] = [];
		for (const i of targets.keys()) {
			damage[i] = true;
		}
		const move = this.dex.getActiveMove(moveOrMoveName);
		let hitResult: boolean | number | null = true;
		let moveData = hitEffect as ActiveMove;
		if (!moveData) moveData = move;
		if (!moveData.flags) moveData.flags = {};
		if (move.target === 'all' && !isSelf) {
			hitResult = this.battle.singleEvent('TryHitField', moveData, {}, target || null, pokemon, move);
		} else if ((move.target === 'foeSide' || move.target === 'allySide' || move.target === 'allyTeam') && !isSelf) {
			hitResult = this.battle.singleEvent('TryHitSide', moveData, {}, target || null, pokemon, move);
		} else if (target) {
			hitResult = this.battle.singleEvent('TryHit', moveData, {}, target, pokemon, move);
		}
		if (!hitResult) {
			if (hitResult === false) {
				this.battle.add('-fail', pokemon);
				this.battle.attrLastMove('[still]');
			}
			return [[false], targets]; // single-target only
		}

		// 0. check for substitute
		if (!isSecondary && !isSelf) {
			if (move.target !== 'all' && move.target !== 'allyTeam' && move.target !== 'allySide' && move.target !== 'foeSide') {
				damage = this.tryPrimaryHitEvent(damage, targets, pokemon, move, moveData, isSecondary);
			}
		}

		for (const i of targets.keys()) {
			if (damage[i] === this.battle.HIT_SUBSTITUTE) {
				damage[i] = true;
				targets[i] = null;
			}
			if (targets[i] && isSecondary && !moveData.self) {
				damage[i] = true;
			}
			if (!damage[i]) targets[i] = false;
		}
		// 1. call to this.battle.getDamage
		damage = this.getSpreadDamage(damage, targets, pokemon, move, moveData, isSecondary, isSelf);

		for (const i of targets.keys()) {
			if (damage[i] === false) targets[i] = false;
		}

		// 2. call to this.battle.spreadDamage
		damage = this.battle.spreadDamage(damage, targets, pokemon, move);

		for (const i of targets.keys()) {
			if (damage[i] === false) targets[i] = false;
		}

		// 3. onHit event happens here
		damage = this.runMoveEffects(damage, targets, pokemon, move, moveData, isSecondary, isSelf);

		for (const i of targets.keys()) {
			if (!damage[i] && damage[i] !== 0) targets[i] = false;
		}

		// steps 4 and 5 can mess with this.battle.activeTarget, which needs to be preserved for Dancer
		const activeTarget = this.battle.activeTarget;

		// 4. self drops (start checking for targets[i] === false here)
		if (moveData.self && !move.selfDropped) this.selfDrops(targets, pokemon, move, moveData, isSecondary);

		// 5. secondary effects
		if (moveData.secondaries) this.secondaries(targets, pokemon, move, moveData, isSelf);

		this.battle.activeTarget = activeTarget;

		// 6. force switch
		if (moveData.forceSwitch) damage = this.forceSwitch(damage, targets, pokemon, move);

		for (const i of targets.keys()) {
			if (!damage[i] && damage[i] !== 0) targets[i] = false;
		}

		const damagedTargets: Pokemon[] = [];
		const damagedDamage = [];
		for (const [i, t] of targets.entries()) {
			if (typeof damage[i] === 'number' && t) {
				damagedTargets.push(t);
				damagedDamage.push(damage[i]);
			}
		}
		const pokemonOriginalHP = pokemon.hp;
		if (damagedDamage.length && !isSecondary && !isSelf) {
			if (this.battle.gen >= 5) {
				this.battle.runEvent('DamagingHit', damagedTargets, pokemon, move, damagedDamage);
			}
			if (moveData.onAfterHit && pokemon.hp) {
				for (const t of damagedTargets) {
					this.battle.singleEvent('AfterHit', moveData, {}, t, pokemon, move);
				}
			}
			if (this.battle.gen < 5) {
				this.battle.runEvent('DamagingHit', damagedTargets, pokemon, move, damagedDamage);
			}
			if (pokemon.hp && pokemon.hp <= pokemon.maxhp / 2 && pokemonOriginalHP > pokemon.maxhp / 2) {
				this.battle.runEvent('EmergencyExit', pokemon);
			}
		}

		return [damage, targets];
	}
	tryPrimaryHitEvent(
		damage: SpreadMoveDamage, targets: SpreadMoveTargets, pokemon: Pokemon,
		move: ActiveMove, moveData: ActiveMove, isSecondary?: boolean
	): SpreadMoveDamage {
		for (const [i, target] of targets.entries()) {
			if (!target) continue;
			damage[i] = this.battle.runEvent('TryPrimaryHit', target, pokemon, moveData);
		}
		return damage;
	}
	getSpreadDamage(
		damage: SpreadMoveDamage, targets: SpreadMoveTargets, source: Pokemon,
		move: ActiveMove, moveData: ActiveMove, isSecondary?: boolean, isSelf?: boolean
	): SpreadMoveDamage {
		for (const [i, target] of targets.entries()) {
			if (!target) continue;
			this.battle.activeTarget = target;
			damage[i] = undefined;
			const curDamage = this.getDamage(source, target, moveData);
			// getDamage has several possible return values:
			//
			//   a number:
			//     means that much damage is dealt (0 damage still counts as dealing
			//     damage for the purposes of things like Static)
			//   false:
			//     gives error message: "But it failed!" and move ends
			//   null:
			//     the move ends, with no message (usually, a custom fail message
			//     was already output by an event handler)
			//   undefined:
			//     means no damage is dealt and the move continues
			//
			// basically, these values have the same meanings as they do for event
			// handlers.

			if (curDamage === false || curDamage === null) {
				if (damage[i] === false && !isSecondary && !isSelf) {
					this.battle.add('-fail', source);
					this.battle.attrLastMove('[still]');
				}
				this.battle.debug('damage calculation interrupted');
				damage[i] = false;
				continue;
			}
			damage[i] = curDamage;
		}
		return damage;
	}
	runMoveEffects(
		damage: SpreadMoveDamage, targets: SpreadMoveTargets, source: Pokemon,
		move: ActiveMove, moveData: ActiveMove, isSecondary?: boolean, isSelf?: boolean
	) {
		let didAnything: number | boolean | null | undefined = damage.reduce(this.combineResults);
		for (const [i, target] of targets.entries()) {
			if (target === false) continue;
			let hitResult;
			let didSomething: number | boolean | null | undefined = undefined;

			if (target) {
				if (moveData.boosts && !target.fainted) {
					hitResult = this.battle.boost(moveData.boosts, target, source, move, isSecondary, isSelf);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.heal && !target.fainted) {
					if (target.hp >= target.maxhp) {
						this.battle.add('-fail', target, 'heal');
						this.battle.attrLastMove('[still]');
						damage[i] = this.combineResults(damage[i], false);
						didAnything = this.combineResults(didAnything, null);
						continue;
					}
					const amount = target.baseMaxhp * moveData.heal[0] / moveData.heal[1];
					const d = this.battle.heal((this.battle.gen < 5 ? Math.floor : Math.round)(amount), target, source, move);
					if (!d && d !== 0) {
						if (d !== null) {
							this.battle.add('-fail', source);
							this.battle.attrLastMove('[still]');
						}
						this.battle.debug('heal interrupted');
						damage[i] = this.combineResults(damage[i], false);
						didAnything = this.combineResults(didAnything, null);
						continue;
					}
					didSomething = true;
				}
				if (moveData.status) {
					hitResult = target.trySetStatus(moveData.status, source, moveData.ability ? moveData.ability : move);
					if (!hitResult && move.status) {
						damage[i] = this.combineResults(damage[i], false);
						didAnything = this.combineResults(didAnything, null);
						continue;
					}
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.forceStatus) {
					hitResult = target.setStatus(moveData.forceStatus, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.volatileStatus) {
					hitResult = target.addVolatile(moveData.volatileStatus, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.sideCondition) {
					hitResult = target.side.addSideCondition(moveData.sideCondition, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.slotCondition) {
					hitResult = target.side.addSlotCondition(target, moveData.slotCondition, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.weather) {
					hitResult = this.battle.field.setWeather(moveData.weather, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.terrain) {
					hitResult = this.battle.field.setTerrain(moveData.terrain, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.pseudoWeather) {
					hitResult = this.battle.field.addPseudoWeather(moveData.pseudoWeather, source, move);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				if (moveData.forceSwitch) {
					hitResult = !!this.battle.canSwitch(target.side);
					didSomething = this.combineResults(didSomething, hitResult);
				}
				// Hit events
				//   These are like the TryHit events, except we don't need a FieldHit event.
				//   Scroll up for the TryHit event documentation, and just ignore the "Try" part. ;)
				if (move.target === 'all' && !isSelf) {
					if (moveData.onHitField) {
						hitResult = this.battle.singleEvent('HitField', moveData, {}, target, source, move);
						didSomething = this.combineResults(didSomething, hitResult);
					}
				} else if ((move.target === 'foeSide' || move.target === 'allySide') && !isSelf) {
					if (moveData.onHitSide) {
						hitResult = this.battle.singleEvent('HitSide', moveData, {}, target.side, source, move);
						didSomething = this.combineResults(didSomething, hitResult);
					}
				} else {
					if (moveData.onHit) {
						hitResult = this.battle.singleEvent('Hit', moveData, {}, target, source, move);
						didSomething = this.combineResults(didSomething, hitResult);
					}
					if (!isSelf && !isSecondary) {
						this.battle.runEvent('Hit', target, source, move);
					}
				}
			}
			if (moveData.selfdestruct === 'ifHit' && damage[i] !== false) {
				this.battle.faint(source, source, move);
			}
			if (moveData.selfSwitch) {
				if (this.battle.canSwitch(source.side) && !source.volatiles['commanded']) {
					didSomething = true;
				} else {
					didSomething = this.combineResults(didSomething, false);
				}
			}
			// Move didn't fail because it didn't try to do anything
			if (didSomething === undefined) didSomething = true;
			damage[i] = this.combineResults(damage[i], didSomething === null ? false : didSomething);
			didAnything = this.combineResults(didAnything, didSomething);
		}

		if (!didAnything && didAnything !== 0 && !moveData.self && !moveData.selfdestruct) {
			if (!isSelf && !isSecondary) {
				if (didAnything === false) {
					this.battle.add('-fail', source);
					this.battle.attrLastMove('[still]');
				}
			}
			this.battle.debug('move failed because it did nothing');
		} else if (move.selfSwitch && source.hp && !source.volatiles['commanded']) {
			source.switchFlag = move.id;
		}

		return damage;
	}
	selfDrops(
		targets: SpreadMoveTargets, source: Pokemon,
		move: ActiveMove, moveData: ActiveMove, isSecondary?: boolean
	) {
		for (const target of targets) {
			if (target === false) continue;
			if (moveData.self && !move.selfDropped) {
				if (!isSecondary && moveData.self.boosts) {
					const secondaryRoll = this.battle.random(100);
					if (typeof moveData.self.chance === 'undefined' || secondaryRoll < moveData.self.chance) {
						this.moveHit(source, source, move, moveData.self, isSecondary, true);
					}
					if (!move.multihit) move.selfDropped = true;
				} else {
					this.moveHit(source, source, move, moveData.self, isSecondary, true);
				}
			}
		}
	}
	secondaries(targets: SpreadMoveTargets, source: Pokemon, move: ActiveMove, moveData: ActiveMove, isSelf?: boolean) {
		if (!moveData.secondaries) return;
		for (const target of targets) {
			if (target === false) continue;
			const secondaries: Dex.SecondaryEffect[] =
				this.battle.runEvent('ModifySecondaries', target, source, moveData, moveData.secondaries.slice());
			for (const secondary of secondaries) {
				const secondaryRoll = this.battle.random(100);
				// User stat boosts or target stat drops can possibly overflow if it goes beyond 256 in Gen 8 or prior
				const secondaryOverflow = (secondary.boosts || secondary.self) && this.battle.gen <= 8;
				if (typeof secondary.chance === 'undefined' ||
					secondaryRoll < (secondaryOverflow ? secondary.chance % 256 : secondary.chance)) {
					this.moveHit(target, source, move, secondary, true, isSelf);
				}
			}
		}
	}
	forceSwitch(
		damage: SpreadMoveDamage, targets: SpreadMoveTargets, source: Pokemon, move: ActiveMove
	) {
		for (const [i, target] of targets.entries()) {
			if (target && target.hp > 0 && source.hp > 0 && this.battle.canSwitch(target.side)) {
				const hitResult = this.battle.runEvent('DragOut', target, source, move);
				if (hitResult) {
					target.forceSwitchFlag = true;
				} else if (hitResult === false && move.category === 'Status') {
					this.battle.add('-fail', source);
					this.battle.attrLastMove('[still]');
					damage[i] = false;
				}
			}
		}
		return damage;
	}
	moveHit(
		targets: Pokemon | null | (Pokemon | null)[], pokemon: Pokemon, moveOrMoveName: ActiveMove,
		moveData?: Dex.HitEffect, isSecondary?: boolean, isSelf?: boolean
	): number | undefined | false {
		if (!Array.isArray(targets)) targets = [targets];
		const retVal = this.spreadMoveHit(targets, pokemon, moveOrMoveName, moveData, isSecondary, isSelf)[0][0];
		return retVal === true ? undefined : retVal;
	}

	calcRecoilDamage(damageDealt: number, move: Move, pokemon: Pokemon): number {
		if (move.id === 'chloroblast') return Math.round(pokemon.maxhp / 2);
		return this.battle.clampIntRange(Math.round(damageDealt * move.recoil![0] / move.recoil![1]), 1);
	}

	getZMove(move: Move, pokemon: Pokemon, skipChecks?: boolean): string | undefined {
		const item = pokemon.getItem();
		if (!skipChecks) {
			if (pokemon.side.zMoveUsed) return;
			if (!item.zMove) return;
			if (item.itemUser && !item.itemUser.includes(pokemon.species.name)) return;
			const moveData = pokemon.getMoveData(move);
			// Draining the PP of the base move prevents the corresponding Z-move from being used.
			if (!moveData?.pp) return;
		}

		if (item.zMoveFrom) {
			if (move.name === item.zMoveFrom) return item.zMove as string;
		} else if (item.zMove === true) {
			if (move.type === item.zMoveType) {
				if (move.category === "Status") {
					return move.name;
				} else if (move.zMove?.basePower) {
					return this.Z_MOVES[move.type];
				}
			}
		}
	}

	getActiveZMove(move: Move, pokemon: Pokemon): ActiveMove {
		if (pokemon) {
			const item = pokemon.getItem();
			if (move.name === item.zMoveFrom) {
				const zMove = this.dex.getActiveMove(item.zMove as string);
				zMove.isZOrMaxPowered = true;
				return zMove;
			}
		}

		if (move.category === 'Status') {
			const zMove = this.dex.getActiveMove(move);
			zMove.isZ = true;
			zMove.isZOrMaxPowered = true;
			return zMove;
		}
		const zMove = this.dex.getActiveMove(this.Z_MOVES[move.type]);
		zMove.basePower = move.zMove!.basePower!;
		zMove.category = move.category;
		// copy the priority for Quick Guard
		zMove.priority = move.priority;
		zMove.isZOrMaxPowered = true;
		return zMove;
	}

	canZMove(pokemon: Pokemon) {
		if (pokemon.side.zMoveUsed ||
			(pokemon.transformed &&
				(pokemon.species.isMega || pokemon.species.isPrimal || pokemon.species.forme === "Ultra"))
		) return;
		const item = pokemon.getItem();
		if (!item.zMove) return;
		if (item.itemUser && !item.itemUser.includes(pokemon.species.name)) return;
		let atLeastOne = false;
		let mustStruggle = true;
		const zMoves: ZMoveOptions = [];
		for (const moveSlot of pokemon.moveSlots) {
			if (moveSlot.pp <= 0) {
				zMoves.push(null);
				continue;
			}
			if (!moveSlot.disabled) {
				mustStruggle = false;
			}
			const move = this.dex.moves.get(moveSlot.move);
			let zMoveName = this.getZMove(move, pokemon, true) || '';
			if (zMoveName) {
				const zMove = this.dex.moves.get(zMoveName);
				if (!zMove.isZ && zMove.category === 'Status') zMoveName = "Z-" + zMoveName;
				zMoves.push({ move: zMoveName, target: zMove.target });
			} else {
				zMoves.push(null);
			}
			if (zMoveName) atLeastOne = true;
		}
		if (atLeastOne && !mustStruggle) return zMoves;
	}

	getMaxMove(move: Move, pokemon: Pokemon) {
		if (typeof move === 'string') move = this.dex.moves.get(move);
		if (move.name === 'Struggle') return move;
		if (pokemon.gigantamax && pokemon.canGigantamax && move.category !== 'Status') {
			const gMaxMove = this.dex.moves.get(pokemon.canGigantamax);
			if (gMaxMove.exists && gMaxMove.type === move.type) return gMaxMove;
		}
		const maxMove = this.dex.moves.get(this.MAX_MOVES[move.category === 'Status' ? move.category : move.type]);
		if (maxMove.exists) return maxMove;
	}

	getActiveMaxMove(move: Move, pokemon: Pokemon) {
		if (typeof move === 'string') move = this.dex.getActiveMove(move);
		if (move.name === 'Struggle') return this.dex.getActiveMove(move);
		let maxMove = this.dex.getActiveMove(this.MAX_MOVES[move.category === 'Status' ? move.category : move.type]);
		if (move.category !== 'Status') {
			if (pokemon.gigantamax && pokemon.canGigantamax) {
				const gMaxMove = this.dex.getActiveMove(pokemon.canGigantamax);
				if (gMaxMove.exists && gMaxMove.type === move.type) maxMove = gMaxMove;
			}
			if (!move.maxMove?.basePower) throw new Error(`${move.name} doesn't have a maxMove basePower`);
			if (!['gmaxdrumsolo', 'gmaxfireball', 'gmaxhydrosnipe'].includes(maxMove.id)) {
				maxMove.basePower = move.maxMove.basePower;
			}
			maxMove.category = move.category;
		}
		maxMove.baseMove = move.id;
		// copy the priority for Psychic Terrain, Quick Guard
		maxMove.priority = move.priority;
		maxMove.isZOrMaxPowered = true;
		return maxMove;
	}

	runZPower(move: ActiveMove, pokemon: Pokemon) {
		const zPower = this.dex.conditions.get('zpower');
		if (move.category !== 'Status') {
			this.battle.attrLastMove('[zeffect]');
		} else if (move.zMove?.boost) {
			this.battle.boost(move.zMove.boost, pokemon, pokemon, zPower);
		} else if (move.zMove?.effect) {
			switch (move.zMove.effect) {
			case 'heal':
				this.battle.heal(pokemon.maxhp, pokemon, pokemon, zPower);
				break;
			case 'healreplacement':
				pokemon.side.addSlotCondition(pokemon, 'healreplacement', pokemon, move);
				break;
			case 'clearnegativeboost':
				const boosts: SparseBoostsTable = {};
				let i: BoostID;
				for (i in pokemon.boosts) {
					if (pokemon.boosts[i] < 0) {
						boosts[i] = 0;
					}
				}
				pokemon.setBoost(boosts);
				this.battle.add('-clearnegativeboost', pokemon, '[zeffect]');
				break;
			case 'redirect':
				pokemon.addVolatile('followme', pokemon, zPower);
				break;
			case 'crit2':
				pokemon.addVolatile('focusenergy', pokemon, zPower);
				break;
			case 'curse':
				if (pokemon.hasType('Ghost')) {
					this.battle.heal(pokemon.maxhp, pokemon, pokemon, zPower);
				} else {
					this.battle.boost({ atk: 1 }, pokemon, pokemon, zPower);
				}
			}
		}
	}

	targetTypeChoices(targetType: string) {
		return CHOOSABLE_TARGETS.has(targetType);
	}

	combineResults<T extends number | boolean | null | '' | undefined,
		U extends number | boolean | null | '' | undefined>(
		left: T, right: U
	): T | U {
		const NOT_FAILURE = 'string';
		const NULL = 'object';
		const resultsPriorities = ['undefined', NOT_FAILURE, NULL, 'boolean', 'number'];
		if (resultsPriorities.indexOf(typeof left) > resultsPriorities.indexOf(typeof right)) {
			return left;
		} else if (left && !right && right !== 0) {
			return left;
		} else if (typeof left === 'number' && typeof right === 'number') {
			return (left + right) as T;
		} else {
			return right;
		}
	}

	/**
	 * 0 is a success dealing 0 damage, such as from False Swipe at 1 HP.
	 *
	 * Normal PS return value rules apply:
	 * undefined = success, null = silent failure, false = loud failure
	 */
	getDamage(
		source: Pokemon, target: Pokemon, move: string | number | ActiveMove,
		suppressMessages = false
	): number | undefined | null | false {
		if (typeof move === 'string') move = this.dex.getActiveMove(move);

		if (typeof move === 'number') {
			const basePower = move;
			move = new Dex.Move({
				basePower,
				type: '???',
				category: 'Physical',
				willCrit: false,
			}) as ActiveMove;
			move.hit = 0;
		}

		if (!target.runImmunity(move, !suppressMessages)) {
			return false;
		}

		if (move.ohko) return this.battle.gen === 3 ? target.hp : target.maxhp;
		if (move.damageCallback) return move.damageCallback.call(this.battle, source, target);
		if (move.damage === 'level') {
			return source.level;
		} else if (move.damage) {
			return move.damage;
		}

		const category = this.battle.getCategory(move);

		let basePower: number | false | null = move.basePower;
		if (move.basePowerCallback) {
			basePower = move.basePowerCallback.call(this.battle, source, target, move);
		}
		if (!basePower) return basePower === 0 ? undefined : basePower;
		basePower = this.battle.clampIntRange(basePower, 1);

		let critMult;
		let critRatio = this.battle.runEvent('ModifyCritRatio', source, target, move, move.critRatio || 0);
		if (this.battle.gen <= 5) {
			critRatio = this.battle.clampIntRange(critRatio, 0, 5);
			critMult = [0, 16, 8, 4, 3, 2];
		} else {
			critRatio = this.battle.clampIntRange(critRatio, 0, 4);
			if (this.battle.gen === 6) {
				critMult = [0, 16, 8, 2, 1];
			} else {
				critMult = [0, 24, 8, 2, 1];
			}
		}

		const moveHit = target.getMoveHitData(move);
		moveHit.crit = move.willCrit || false;
		if (move.willCrit === undefined) {
			if (critRatio) {
				moveHit.crit = this.battle.randomChance(1, critMult[critRatio]);
			}
		}

		if (moveHit.crit) {
			moveHit.crit = this.battle.runEvent('CriticalHit', target, null, move);
		}

		// happens after crit calculation
		basePower = this.battle.runEvent('BasePower', source, target, move, basePower, true);

		if (!basePower) return 0;
		basePower = this.battle.clampIntRange(basePower, 1);
		// Hacked Max Moves have 0 base power, even if you Dynamax
		if ((!source.volatiles['dynamax'] && move.isMax) || (move.isMax && this.dex.moves.get(move.baseMove).isMax)) {
			basePower = 0;
		}

		const dexMove = this.dex.moves.get(move.id);
		if (source.terastallized && (source.terastallized === 'Stellar' ?
			!source.stellarBoostedTypes.includes(move.type) : source.hasType(move.type)) &&
			basePower < 60 && dexMove.priority <= 0 && !dexMove.multihit &&
			// Hard move.basePower check for moves like Dragon Energy that have variable BP
			!((move.basePower === 0 || move.basePower === 150) && move.basePowerCallback)
		) {
			basePower = 60;
		}

		const level = source.level;

		const attacker = move.overrideOffensivePokemon === 'target' ? target : source;
		const defender = move.overrideDefensivePokemon === 'source' ? source : target;

		const isPhysical = move.category === 'Physical';
		let attackStat: StatIDExceptHP = move.overrideOffensiveStat || (isPhysical ? 'atk' : 'spa');
		const defenseStat: StatIDExceptHP = move.overrideDefensiveStat || (isPhysical ? 'def' : 'spd');

		const statTable = { atk: 'Atk', def: 'Def', spa: 'SpA', spd: 'SpD', spe: 'Spe' };

		let atkBoosts = attacker.boosts[attackStat];
		let defBoosts = defender.boosts[defenseStat];

		let ignoreNegativeOffensive = !!move.ignoreNegativeOffensive;
		let ignorePositiveDefensive = !!move.ignorePositiveDefensive;

		if (moveHit.crit) {
			ignoreNegativeOffensive = true;
			ignorePositiveDefensive = true;
		}
		const ignoreOffensive = !!(move.ignoreOffensive || (ignoreNegativeOffensive && atkBoosts < 0));
		const ignoreDefensive = !!(move.ignoreDefensive || (ignorePositiveDefensive && defBoosts > 0));

		if (ignoreOffensive) {
			this.battle.debug('Negating (sp)atk boost/penalty.');
			atkBoosts = 0;
		}
		if (ignoreDefensive) {
			this.battle.debug('Negating (sp)def boost/penalty.');
			defBoosts = 0;
		}

		let attack = attacker.calculateStat(attackStat, atkBoosts, 1, source);
		let defense = defender.calculateStat(defenseStat, defBoosts, 1, target);

		attackStat = (category === 'Physical' ? 'atk' : 'spa');

		// Apply Stat Modifiers
		attack = this.battle.runEvent('Modify' + statTable[attackStat], source, target, move, attack);
		defense = this.battle.runEvent('Modify' + statTable[defenseStat], target, source, move, defense);

		if (this.battle.gen <= 4 && ['explosion', 'selfdestruct'].includes(move.id) && defenseStat === 'def') {
			defense = this.battle.clampIntRange(Math.floor(defense / 2), 1);
		}

		const tr = this.battle.trunc;

		// int(int(int(2 * L / 5 + 2) * A * P / D) / 50);
		const baseDamage = tr(tr(tr(tr(2 * level / 5 + 2) * basePower * attack) / defense) / 50);

		// Calculate damage modifiers separately (order differs between generations)
		return this.modifyDamage(baseDamage, source, target, move, suppressMessages);
	}

	modifyDamage(
		baseDamage: number, pokemon: Pokemon, target: Pokemon, move: ActiveMove, suppressMessages = false
	) {
		const tr = this.battle.trunc;
		if (!move.type) move.type = '???';
		const type = move.type;

		baseDamage += 2;

		if (move.spreadHit) {
			// multi-target modifier (doubles only)
			const spreadModifier = this.battle.gameType === 'freeforall' ? 0.5 : 0.75;
			this.battle.debug(`Spread modifier: ${spreadModifier}`);
			baseDamage = this.battle.modify(baseDamage, spreadModifier);
		} else if (move.multihitType === 'parentalbond' && move.hit > 1) {
			// Parental Bond modifier
			const bondModifier = this.battle.gen > 6 ? 0.25 : 0.5;
			this.battle.debug(`Parental Bond modifier: ${bondModifier}`);
			baseDamage = this.battle.modify(baseDamage, bondModifier);
		}

		// weather modifier
		baseDamage = this.battle.priorityEvent('WeatherModifyDamage', pokemon, target, move, baseDamage);

		// crit - not a modifier
		const isCrit = target.getMoveHitData(move).crit;
		if (isCrit) {
			baseDamage = tr(baseDamage * (move.critModifier || (this.battle.gen >= 6 ? 1.5 : 2)));
		}

		// random factor - also not a modifier
		baseDamage = this.battle.randomizer(baseDamage);

		// STAB
		// The "???" type never gets STAB
		// Not even if you Roost in Gen 4 and somehow manage to use
		// Struggle in the same turn.
		// (On second thought, it might be easier to get a MissingNo.)
		if (type !== '???') {
			let stab: number | [number, number] = 1;

			const isSTAB = move.forceSTAB || pokemon.hasType(type) || pokemon.getTypes(false, true).includes(type);
			if (isSTAB) {
				stab = 1.5;
			}

			// The Stellar tera type makes this incredibly confusing
			// If the move's type does not match one of the user's base types,
			// the Stellar tera type applies a one-time 1.2x damage boost for that type.
			//
			// If the move's type does match one of the user's base types,
			// then the Stellar tera type applies a one-time 2x STAB boost for that type,
			// and then goes back to using the regular 1.5x STAB boost for those types.
			if (pokemon.terastallized === 'Stellar') {
				if (!pokemon.stellarBoostedTypes.includes(type) || move.stellarBoosted) {
					stab = isSTAB ? 2 : [4915, 4096];
					move.stellarBoosted = true;
					if (pokemon.species.name !== 'Terapagos-Stellar') {
						pokemon.stellarBoostedTypes.push(type);
					}
				}
			} else {
				if (pokemon.terastallized === type && pokemon.getTypes(false, true).includes(type)) {
					stab = 2;
				}
				stab = this.battle.runEvent('ModifySTAB', pokemon, target, move, stab);
			}

			baseDamage = this.battle.modify(baseDamage, stab);
		}

		// types
		let typeMod = target.runEffectiveness(move);
		typeMod = this.battle.clampIntRange(typeMod, -6, 6);
		target.getMoveHitData(move).typeMod = typeMod;
		if (typeMod > 0) {
			if (!suppressMessages) this.battle.add('-supereffective', target);

			for (let i = 0; i < typeMod; i++) {
				baseDamage *= 2;
			}
		}
		if (typeMod < 0) {
			if (!suppressMessages) this.battle.add('-resisted', target);

			for (let i = 0; i > typeMod; i--) {
				baseDamage = tr(baseDamage / 2);
			}
		}

		if (isCrit && !suppressMessages) this.battle.add('-crit', target);

		if (pokemon.status === 'brn' && move.category === 'Physical' && !pokemon.hasAbility('guts')) {
			if (this.battle.gen < 6 || move.id !== 'facade') {
				baseDamage = this.battle.modify(baseDamage, 0.5);
			}
		}

		// Generation 5, but nothing later, sets damage to 1 before the final damage modifiers
		if (this.battle.gen === 5 && !baseDamage) baseDamage = 1;

		// Final modifier. Modifiers that modify damage after min damage check, such as Life Orb.
		baseDamage = this.battle.runEvent('ModifyDamage', pokemon, target, move, baseDamage);

		const bypassProtect = target.getMoveHitData(move).bypassProtect;
		if (bypassProtect) {
			baseDamage = this.battle.modify(baseDamage, 0.25);
			if (bypassProtect !== true && bypassProtect.effectType === 'Ability') {
				this.battle.add('-ability', pokemon, bypassProtect.name);
			}
			this.battle.add('-zbroken', target);
		}

		// Generation 6-7 moves the check for minimum 1 damage after the final modifier...
		if (this.battle.gen !== 5 && !baseDamage) return 1;

		// ...but 16-bit truncation happens even later, and can truncate to 0
		return tr(baseDamage, 16);
	}

	/**
	 * Confusion damage is unique - most typical modifiers that get run when calculating
	 * damage (e.g. Huge Power, Life Orb, critical hits) don't apply. It also uses a 16-bit
	 * context for its damage, unlike the regular damage formula (though this only comes up
	 * for base damage).
	 */
	getConfusionDamage(pokemon: Pokemon, basePower: number) {
		const tr = this.battle.trunc;

		const attack = pokemon.calculateStat('atk', pokemon.boosts['atk']);
		const defense = pokemon.calculateStat('def', pokemon.boosts['def']);
		const level = pokemon.level;
		const baseDamage = tr(tr(tr(tr(2 * level / 5 + 2) * basePower * attack) / defense) / 50) + 2;

		// Damage is 16-bit context in self-hit confusion damage
		let damage = tr(baseDamage, 16);
		damage = this.battle.randomizer(damage);
		return Math.max(1, damage);
	}

	// #endregion

	// #region MEGA EVOLUTION
	// ==================================================================

	canMegaEvo(pokemon: Pokemon) {
		const species = pokemon.baseSpecies;
		const altForme = species.otherFormes && this.dex.species.get(species.otherFormes[0]);
		const item = pokemon.getItem();
		// Mega Rayquaza
		if ((this.battle.gen <= 7 || this.battle.ruleTable.has('+tag:past') ||
			this.battle.ruleTable.has('+tag:future')) &&
			altForme?.isMega && altForme?.requiredMove &&
			pokemon.baseMoves.includes(toID(altForme.requiredMove)) && !item.zMove) {
			return altForme.name;
		}
		if (!item.megaStone) return null;
		// TODO confirm with generation shift
		let megaEvolution = item.megaStone[species.name];
		if (megaEvolution && this.dex.species.get(megaEvolution).gen >= 9) return megaEvolution;
		// a hacked-in Megazard X can mega evolve into Megazard Y, but not into Megazard X
		// FIXME: Change to species.name when champions comes
		megaEvolution = item.megaStone[species.baseSpecies];
		return megaEvolution && megaEvolution !== species.name ? megaEvolution : null;
	}

	canUltraBurst(pokemon: Pokemon) {
		if (['Necrozma-Dawn-Wings', 'Necrozma-Dusk-Mane'].includes(pokemon.baseSpecies.name) &&
			pokemon.getItem().id === 'ultranecroziumz') {
			return "Necrozma-Ultra";
		}
		return null;
	}

	runMegaEvo(pokemon: Pokemon) {
		const speciesid = pokemon.canMegaEvo || pokemon.canUltraBurst;
		if (!speciesid) return false;

		pokemon.formeChange(speciesid, pokemon.getItem(), true);

		// Limit one mega evolution
		const wasMega = pokemon.canMegaEvo;
		for (const ally of pokemon.side.pokemon) {
			if (wasMega) {
				ally.canMegaEvo = false;
			} else {
				ally.canUltraBurst = null;
			}
		}

		this.battle.runEvent('AfterMega', pokemon);
		return true;
	}

	// Let's Go
	canMegaEvoX?: (this: BattleActions, pokemon: Pokemon) => string | null;
	canMegaEvoY?: (this: BattleActions, pokemon: Pokemon) => string | null;
	runMegaEvoX?: (this: BattleActions, pokemon: Pokemon) => boolean;
	runMegaEvoY?: (this: BattleActions, pokemon: Pokemon) => boolean;

	canTerastallize(pokemon: Pokemon) {
		if (pokemon.getItem().zMove || pokemon.canMegaEvo || this.dex.gen !== 9) {
			return null;
		}
		return pokemon.teraType;
	}

	terastallize(pokemon: Pokemon) {
		if (pokemon.species.baseSpecies === 'Ogerpon' && !['Fire', 'Grass', 'Rock', 'Water'].includes(pokemon.teraType) &&
			(!pokemon.illusion || pokemon.illusion.species.baseSpecies === 'Ogerpon')) {
			this.battle.hint("If Ogerpon Terastallizes into a type other than Fire, Grass, Rock, or Water, the game crashes.", false, pokemon.side);
			return;
		}

		if (pokemon.illusion && ['Ogerpon', 'Terapagos'].includes(pokemon.illusion.species.baseSpecies)) {
			this.battle.singleEvent('End', this.dex.abilities.get('Illusion'), pokemon.abilityState, pokemon);
		}

		const type = pokemon.teraType;
		this.battle.add('-terastallize', pokemon, type);
		pokemon.terastallized = type;
		for (const ally of pokemon.side.pokemon) {
			ally.canTerastallize = null;
		}
		pokemon.addedType = '';
		pokemon.knownType = true;
		pokemon.apparentType = type;
		if (pokemon.species.baseSpecies === 'Ogerpon') {
			let ogerponSpecies = toID(pokemon.species.battleOnly || pokemon.species.id);
			ogerponSpecies += ogerponSpecies === 'ogerpon' ? 'tealtera' : 'tera';
			pokemon.formeChange(ogerponSpecies, null, true);
		}
		if (pokemon.species.name === 'Terapagos-Terastal') {
			pokemon.formeChange('Terapagos-Stellar', null, true);
		}
		if (pokemon.species.baseSpecies === 'Morpeko' && !pokemon.transformed &&
			pokemon.baseSpecies.id !== pokemon.species.id
		) {
			pokemon.formeRegression = true;
			pokemon.baseSpecies = pokemon.species;
			pokemon.details = pokemon.getUpdatedDetails();
		}
		this.battle.runEvent('AfterTerastallization', pokemon);
	}

	// #endregion
}
