Files
gen1recomp/src/battle/gen2/Battle.lua
T

4535 lines
199 KiB
Lua

-- Gen 2 battle engine: the turn loop, as pure logic.
--
-- No love calls and no rendering: a battle is a state machine that consumes
-- actions and produces a queue of events, so the same engine drives the screen
-- (src/ui/gen2/BattleState.lua), a headless driver, and the tests. Gen 1's
-- src/battle/BattleState.lua interleaves logic with drawing, which is exactly
-- what makes its turn order hard to assert; this does not repeat that.
--
-- Ported from engine/battle/core.asm's turn structure:
-- * both sides choose (a move, an item, a switch, or run)
-- * order is by Speed after stat stages, with a coin flip on a tie
-- (DetermineMoveOrder); a switch or item always goes first
-- * each attack: PP, status gates (sleep/freeze/paralysis), accuracy, damage,
-- then the move's secondary effect
-- * end of turn: burn and poison tick, then faint checks and experience
--
-- Status handling follows Gen 2's rules rather than Gen 1's: burn is 1/8 max HP
-- (not 1/16) and halves physical Attack, poison is 1/8, and sleep counts down
-- from 1-7 turns.
local Damage = require("src.battle.gen2.Damage")
local Ai = require("src.battle.gen2.Ai")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")
local Happiness = require("src.core.gen2.Happiness")
local Pokerus = require("src.core.gen2.Pokerus")
local Roamers = require("src.core.gen2.Roamers")
local Prize = require("src.battle.gen2.Prize")
-- The mod event/hook buses. Every name raised from this file is the SAME name
-- src/battle/BattleState.lua raises on Gen 1, carrying the same payload keys
-- with the same meaning -- a mod written against Red's battle reads Gold's
-- without learning a second vocabulary (docs/mod-api-gen2-compat.md). Where
-- Gen 2 genuinely carries more (split special stats, a held item on either
-- battler) the extra rides BESIDE the Gen 1 key, never instead of it.
--
-- Two shape differences are unavoidable and are called out at each site:
-- * Gen 1's `user` / `target` / `battler` are battler wrappers around a mon
-- ({ mon = , name = , isPlayer = }); Gen 2's engine works on the party mon
-- table directly, so that is what these payloads carry.
-- * Gen 1's `rng` is love.math.random (1..n); Gen 2's injected `random` is
-- the cart's BattleRandom convention (0..n-1). Both keys are present.
local Runtime = require("src.mods.Runtime")
-- The two battle lines that carry the cart's own `line` break: a marker-bearing
-- literal has to stay reachable from a translation mod (#186, #245), which is
-- what tests/engine/gate_strings_coverage.lua watches for.
local Strings = require("src.core.Strings")
local Battle = {}
Battle.__index = Battle
-- Burn and poison both tick 1/8 of max HP at the end of a turn in Gen 2.
Battle.BURN_FRACTION = 8
Battle.POISON_FRACTION = 8
-- Burn halves physical Attack; paralysis quarters Speed.
Battle.BURN_ATTACK_DIVISOR = 2
Battle.PARALYSIS_SPEED_DIVISOR = 4
-- A paralysed mon loses its turn a quarter of the time.
Battle.PARALYSIS_SKIP_CHANCE = 4
-- A frozen mon thaws on a 1-in-5 roll each turn it tries to move.
Battle.THAW_CHANCE = 5
-- Moves whose effect the engine models. Everything else lands as a plain hit
-- (or, with no power, as a no-op message), which is honest: an unmodelled
-- effect never silently does the wrong thing.
Battle.STATUS_EFFECTS = {
EFFECT_SLEEP = "sleep",
EFFECT_POISON = "poison",
EFFECT_TOXIC = "toxic",
EFFECT_PARALYZE = "paralyze",
EFFECT_BURN = "burn",
EFFECT_FREEZE = "freeze",
EFFECT_CONFUSE = "confuse",
}
Battle.SECONDARY_EFFECTS = {
EFFECT_POISON_HIT = "poison",
EFFECT_BURN_HIT = "burn",
EFFECT_FREEZE_HIT = "freeze",
EFFECT_PARALYZE_HIT = "paralyze",
EFFECT_SLEEP_HIT = "sleep",
EFFECT_CONFUSE_HIT = "confuse",
}
local function rand(random, n)
if random then return random(n) end
if love and love.math and love.math.random then
return love.math.random(n) - 1
end
return math.random(n) - 1
end
-- data/trainers/leaders.asm. The two lists are ONE array in the ROM: only
-- KantoGymLeaders carries the -1 terminator, and GymLeaders falls through into
-- it, so IsGymLeader matches all twenty-two classes while IsKantoGymLeader
-- (which starts halfway down) matches the last eight. Splitting them into two
-- separate tables here and forgetting the fallthrough would deny Brock's party
-- its HAPPINESS_GYMBATTLE, which is exactly the bug the comment at the top of
-- leaders.asm warns about.
Battle.KANTO_GYM_LEADER_CLASSES = {
BROCK = true, MISTY = true, LT_SURGE = true, ERIKA = true,
JANINE = true, SABRINA = true, BLAINE = true, BLUE = true,
}
Battle.GYM_LEADER_CLASSES = {
FALKNER = true, WHITNEY = true, BUGSY = true, MORTY = true,
PRYCE = true, JASMINE = true, CHUCK = true, CLAIR = true,
WILL = true, BRUNO = true, KAREN = true, KOGA = true,
CHAMPION = true, RED = true,
}
for class in pairs(Battle.KANTO_GYM_LEADER_CLASSES) do
Battle.GYM_LEADER_CLASSES[class] = true
end
-- IsGymLeader / IsKantoGymLeader, as predicates.
function Battle.isGymLeader(class)
return class ~= nil and Battle.GYM_LEADER_CLASSES[class] == true
end
function Battle.isKantoGymLeader(class)
return class ~= nil and Battle.KANTO_GYM_LEADER_CLASSES[class] == true
end
-- The four items XItemEffect covers (data/items/x_stats.asm). DIRE_HIT and
-- GUARD_SPEC have their own effect routines and award nothing, so they are
-- deliberately not here.
Battle.X_ITEMS = {
X_ATTACK = true, X_DEFEND = true, X_SPEED = true, X_SPECIAL = true,
}
-- data/items/x_stats.asm: which stat each X item raises one stage of.
-- X SPECIAL is SP_ATTACK only in Gen 2.
Battle.X_ITEM_STATS = {
X_ATTACK = "attack", X_DEFEND = "defense", X_SPEED = "speed",
X_SPECIAL = "specialAttack",
}
-- XAccuracyEffect / DireHitEffect / GuardSpecEffect (engine/items/
-- item_effects.asm:2079-2113): each sets one wPlayerSubStatus4 bit on the
-- active mon and refuses when it is already up. The bits live in the mon's
-- volatile so a switch drops them, which is what SUBSTATUS4 does too.
Battle.SUBSTATUS_ITEMS = {
X_ACCURACY = "xAccuracy", -- SUBSTATUS_X_ACCURACY: skip the accuracy roll
DIRE_HIT = "focusEnergy", -- SUBSTATUS_FOCUS_ENERGY: +1 critical level
GUARD_SPEC = "mist", -- SUBSTATUS_MIST: no stat drops from the foe
}
-- constants/battle_constants.asm const order: the two battle types whose
-- whole meaning is "no escape". TryToRunAwayFromBattle jumps straight to
-- .cant_escape for both, ahead of even the trainer check, and
-- BattleCommand_ForceSwitch fails outright for both -- the Lake of Rage
-- Gyarados (FORCESHINY) and the Rocket base's exploding traps (TRAP) cannot
-- be run from or Roared away.
Battle.BATTLETYPE_FORCESHINY = 7
Battle.BATTLETYPE_TRAP = 9
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
-- badge bit after swapping PlainBadge and MineralBadge, which is what lands
-- Mineral on Defense and Plain on Speed; Glacier boosts Special Attack, and
-- its Special Defense re-check is the buggy tail modelled in
-- Battle.glacierBoostsSpDef below.
Battle.BADGE_STAT_BOOSTS = {
attack = "ZEPHYR",
defense = "MINERAL",
speed = "PLAIN",
specialAttack = "GLACIER",
}
-- data/types/badge_type_boosts.asm, in the cart's own walk order: the eight
-- wJohtoBadges bits, then the eight wKantoBadges bits. DoBadgeTypeBoosts
-- boosts the player's damage by 1/8 when an owned badge's type matches the
-- move's.
Battle.BADGE_TYPE_BOOSTS = {
{ store = "badges", badge = "ZEPHYR", type = "FLYING" },
{ store = "badges", badge = "HIVE", type = "BUG" },
{ store = "badges", badge = "PLAIN", type = "NORMAL" },
{ store = "badges", badge = "FOG", type = "GHOST" },
{ store = "badges", badge = "MINERAL", type = "STEEL" },
{ store = "badges", badge = "STORM", type = "FIGHTING" },
{ store = "badges", badge = "GLACIER", type = "ICE" },
{ store = "badges", badge = "RISING", type = "DRAGON" },
{ store = "kantoBadges", badge = "BOULDER", type = "ROCK" },
{ store = "kantoBadges", badge = "CASCADE", type = "WATER" },
{ store = "kantoBadges", badge = "THUNDER", type = "ELECTRIC" },
{ store = "kantoBadges", badge = "RAINBOW", type = "GRASS" },
{ store = "kantoBadges", badge = "SOUL", type = "POISON" },
{ store = "kantoBadges", badge = "MARSH", type = "PSYCHIC_TYPE" },
{ store = "kantoBadges", badge = "VOLCANO", type = "FIRE" },
{ store = "kantoBadges", badge = "EARTH", type = "GROUND" },
}
-- wJohtoBadges bit order, for the positional keying FieldMoves.hasBadge also
-- accepts (a save may key player.badges by name or by bit position).
Battle.JOHTO_BADGE_ORDER = {
"ZEPHYR", "HIVE", "PLAIN", "FOG", "MINERAL", "STORM", "GLACIER", "RISING",
}
Battle.KANTO_BADGE_ORDER = {
"BOULDER", "CASCADE", "THUNDER", "RAINBOW",
"SOUL", "MARSH", "VOLCANO", "EARTH",
}
-- opts:
-- data { pokemon, moves, type_chart, items }
-- party the player's party (array of Mon)
-- wild a single Mon for a wild battle
-- trainer { class, name, party, baseMoney } for a trainer battle;
-- baseMoney is the class's TRNATTR_BASE_REWARD and is what
-- src/battle/gen2/Prize.lua pays out of when the trainer loses
-- save the Gold save, for the two money accounts WinTrainerBattle
-- writes. Optional: a headless turn-order test hands in no save
-- and the payout is simply skipped, the way wMoney is untouched
-- by a link battle
-- roaming the save's roamer slot index (1 Raikou, 2 Entei, 3 Suicune) when
-- this wild battle is BATTLETYPE_ROAMING; the caller built `wild`
-- through Roamers.beginBattle and reads Battle.roaming back to
-- bank the beast's HP afterwards
-- random(n) 0..n-1, injected so a test is deterministic
function Battle.new(opts)
opts = opts or {}
local self = setmetatable({}, Battle)
self.data = opts.data or {}
self.random = opts.random or function(n) return rand(nil, n) end
self.party = opts.party or {}
self.trainer = opts.trainer
self.save = opts.save
-- wBattleType, when the caller knows it: "fish" gates the Lure Ball's x3
-- (BATTLETYPE_FISH is the one condition LureBallMultiplier reads), and the
-- FORCESHINY / TRAP no-escape rules will hang off the same field.
self.battleType = opts.battleType
self.events = {}
self.turn = 0
self.over = false
self.outcome = nil -- "win" | "lose" | "run" | "caught"
-- Participants earn experience; a switch adds to the set.
self.participants = {}
self.playerIndex = Battle.firstHealthy(self.party) or 1
self.player = self.party[self.playerIndex]
if self.player then self.participants[self.playerIndex] = true end
-- wAmuletCoin, latched by CheckAmuletCoin on every send-out and never
-- cleared again until the next battle starts.
self.amuletCoin = false
self:checkAmuletCoin(self.player)
if opts.wild then
self.wild = true
self.enemy = opts.wild
self.enemyParty = { opts.wild }
self.enemyIndex = 1
-- wBattleType = BATTLETYPE_ROAMING. Kept as the SLOT index rather than a
-- boolean because BattleEnd_HandleRoamMons needs to know whose HP byte to
-- write, and GetRoamMonHP resolves that from the species.
self.roaming = opts.roaming
else
self.wild = false
self.enemyParty = (self.trainer and self.trainer.party) or {}
-- trainer.party, the same hook BattleState:startTrainer calls on Gen 1 and
-- with the same three arguments: the class, which roster of that class, and
-- the roster itself, returning the roster to fight. Gen 2's rows carry a
-- held item and split special stats; a mod that hands back rows it built
-- itself keeps them, because nothing here rewrites what the hook returned.
-- The second argument is the party MEMBER id (RIVAL2_2_CHIKORITA), which is
-- what picks a roster out of a class in Gen 2 -- Gen 1's numeric index by
-- another name.
if self.trainer and Runtime.wantsHook("trainer.party") then
self.enemyParty = Runtime.call("trainer.party", function(_, _, party)
return party
end, self.trainer.classId or self.trainer.class,
self.trainer.memberId or self.trainer.index or 1,
self.enemyParty) or self.enemyParty
end
self.enemyIndex = Battle.firstHealthy(self.enemyParty) or 1
self.enemy = self.enemyParty[self.enemyIndex]
end
for _, mon in ipairs(self.party) do
Mon.refreshStats(mon, self.data)
end
for _, mon in ipairs(self.enemyParty or {}) do
Mon.refreshStats(mon, self.data)
end
-- Battle RAM opens empty on both sides: NewBattleMonStatus and
-- NewEnemyMonStatus run at the first send-out of every battle.
self:clearAllVolatiles()
self.stages = {
player = Battle.newStages(),
enemy = Battle.newStages(),
}
-- wBattleWeather / wWeatherCount: field state, not per-mon, so it survives a
-- switch on either side.
self.weather = nil
self.weatherTurns = 0
-- wPlayerScreens / wEnemyScreens SCREENS_SPIKES: laid on the side that will
-- be switching INTO them.
self.spikes = { player = false, enemy = false }
-- The other two wPlayerScreens bits, with their five-turn counts
-- (BattleCommand_Screen / HandleScreens): SIDE state like the spikes, so
-- a switch does not take a screen down.
self.screens = { player = {}, enemy = {} }
-- The side substrate Gen 1's battle carries (src/battle/BattleState.lua's
-- own self.sides): index 1 is the player's side, index 2 the foe's, and the
-- engine writes nothing into screens/hazards/tokens -- they are the stable
-- shape mods hang their own state on. This is what battle.battler_switched
-- names as `side`, so a mod reading payload.side.index reads the same number
-- it does on Red. `battlers[1]` is kept current by Battle:syncSides.
self.sides = Battle.newSides()
-- InitEnemyTrainer's tail: a Gym Leader (or an Elite Four member, or the
-- Champion, or Red -- IsGymLeader's list is longer than its name) raises the
-- happiness of every party mon still standing, BEFORE the first turn. You
-- are paid for showing up, not for winning.
if self.trainer and Battle.isGymLeader(self.trainer.class) then
Happiness.changeParty(self.party, "GYMBATTLE")
end
-- battle.started, the payload BattleState:enter emits on Gen 1: `kind` is the
-- battle's shape, `trainerId` the class the fight is against (nil for a wild
-- one), and `species` / `level` the mon standing opposite. The battle object
-- differs between generations and always has -- a mod reads it through the
-- fields it knows, which is why the four scalars are here at all.
Runtime.emit("battle.started", {
battle = self, kind = self.wild and "wild" or "trainer",
trainerId = self.trainer
and (self.trainer.classId or self.trainer.class) or nil,
species = self.enemy and self.enemy.species,
level = self.enemy and self.enemy.level,
-- Gen 2 additions: the wBattleType byte (BATTLETYPE_FORCESHINY and friends,
-- or "fish"), and the roster this trainer brought, held items and all.
battleType = self.battleType,
trainer = self.trainer,
})
return self
end
-- The side substrate, kept next to the stage table it sits beside in the
-- constructor. Built lazily by Battle:syncSides as well, so a caller that
-- assembles a battle by hand (the tests drive resolveFaints against a stub)
-- still gets the shape battle.battler_switched reports.
function Battle.newSides()
return {
{ index = 1, key = "player", battlers = {}, screens = {}, hazards = {},
tokens = {} },
{ index = 2, key = "enemy", battlers = {}, screens = {}, hazards = {},
tokens = {} },
}
end
function Battle.newStages()
return {
attack = 0, defense = 0, speed = 0,
specialAttack = 0, specialDefense = 0,
accuracy = 0, evasion = 0,
}
end
-- The first party member that can actually FIGHT.
--
-- An EGG has HP and is not fainted, and nothing here used to exclude it -- so
-- carrying the Togepi egg (which the game hands you in Violet City and expects
-- you to keep until it hatches) meant it counted as a battler: the wipe check
-- never fired while the egg was intact, and the game asked you to send an egg
-- out against Morty. The cart cannot: CheckCurPartyMon and the switch menu
-- both refuse an egg, and `wPartyCount` minus the eggs is what decides a
-- whiteout.
function Battle.firstHealthy(party)
for index, mon in ipairs(party or {}) do
if not mon.isEgg and (mon.hp or 0) > 0 then return index end
end
return nil
end
function Battle:emit(event)
self.events[#self.events + 1] = event
return event
end
-- Drain the event queue; the screen calls this each time it finishes showing
-- what it already had.
function Battle:takeEvents()
local out = self.events
self.events = {}
return out
end
-- The one place a battle is decided, so battle.ended is raised exactly once
-- however many times the faint sweep runs over an already-finished battle.
-- Gen 1's payload is { battle, result }; `result` here is the same string
-- Battle.outcome carries, with Gen 2's own two extra outcomes ("fled" for
-- WildFled_EnemyFled, "draw" for a Bug Contest that ran out of balls) beside
-- Gen 1's win / lose / run / caught.
function Battle:endBattle(outcome)
self.over = true
self.outcome = outcome
-- Whoever is still standing leaves the battle as itself: the copy Transform
-- wrote is battle ram on the cart and CleanUpBattleRAM takes it. The screen
-- reaches the same restore through Battle:clearAllVolatiles, but a caller
-- that ends a battle without a screen (every headless test, and the scripted
-- exits) has to leave the party clean too -- Battle.party IS save.party.
self:untransform(self.player)
self:untransform(self.enemy)
if self.endedEmitted then return end
self.endedEmitted = true
Runtime.emit("battle.ended", { battle = self, result = outcome })
end
-- A never-nil 0..n-1 roller for the hook contexts. `random` is optional on the
-- constructor (a headless test injects one, the game leaves it to love.math),
-- and a mod reaching for ctx.rng must not have to know that. Note the
-- convention: this is the cart's BattleRandom byte (0..n-1), NOT Gen 1's
-- love.math.random (1..n).
function Battle:roller()
if not self.rollerFn then
self.rollerFn = function(n) return rand(self.random, n) end
end
return self.rollerFn
end
function Battle:sideOf(mon)
return (mon == self.player) and "player" or "enemy"
end
-- Point each side record at whoever is standing on it, the way Gen 1's
-- BattleState:syncSides does.
function Battle:syncSides()
self.sides = self.sides or Battle.newSides()
self.sides[1].battlers[1] = self.player
self.sides[2].battlers[1] = self.enemy
end
-- The side RECORD a mon is on (Gen 1's payload shape) rather than the string
-- key the Gen 2 engine indexes its own tables with.
function Battle:sideRecord(mon)
self:syncSides()
return (mon == self.player) and self.sides[1] or self.sides[2]
end
function Battle:monName(mon)
if not mon then return "?" end
return mon.nickname or mon.name or mon.species or "?"
end
function Battle:moveDef(moveId)
return self.data.moves and self.data.moves[moveId] or nil
end
-- The board as AI_Smart reads it. Every field is optional on the AI side, so
-- a value the engine does not model simply never fires its branch.
-- CheckPlayerMoveTypeMatchups (engine/battle/ai/switch.asm).
-- wEnemyAISwitchScore starts at BASE_AI_SWITCH_SCORE and walks down one for
-- every super-effective move the player has ACTUALLY shown against whatever the
-- AI has out; below that base means the player is winning the type war. Both
-- the switch layer and four AI_Smart handlers (ForceSwitch, BatonPass,
-- PerishSong, MeanLook) read this one number, so it lives in one place and the
-- two cannot drift apart.
function Battle:playerMatchupScore()
local score = Ai.BASE_SWITCH_SCORE
local enemyTypes = (self:speciesDef(self.enemy) or {}).types
or self.enemy.types or {}
local matchups = self.data.type_chart and self.data.type_chart.matchups
for _, id in ipairs(self:volatile(self.player).usedMoves or {}) do
local def = self:moveDef(id)
if def and (def.power or 0) > 0
and Damage.typeMultiplier(def.type, enemyTypes, matchups) > 10 then
score = score - 1
end
end
return score
end
-- engine/battle/hidden_power.asm's type table, as the sixteen values
-- (Atk & 3) * 4 + (Def & 3) can take. The routine's `inc a` past NORMAL, its
-- second `inc a` past BIRD and its `add UNUSED_TYPES_END - UNUSED_TYPES`
-- collapse to exactly this list, in this order.
Battle.HIDDEN_POWER_TYPES = {
"FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", "STEEL",
"FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC_TYPE", "ICE", "DRAGON", "DARK",
}
-- HiddenPowerDamage: Hidden Power's real base power (31..70) and type come from
-- the user's DVs, not from the move table's stub. Returns nil, nil when the
-- mon carries no DVs to read, which is the only honest answer for a mon the
-- fixtures built by hand.
--
-- The power byte takes the TOP bit of each of the four DVs (`and %1000`), NOT
-- the low bits Mon.hpDV builds the HP DV out of, so this cannot borrow that
-- helper.
function Battle:hiddenPower(mon)
local dvs = mon and mon.dvs
if not dvs then return nil, nil end
local function high(value) return math.floor((value or 0) / 8) % 2 end
local bits = high(dvs.attack) * 8 + high(dvs.defense) * 4
+ high(dvs.speed) * 2 + high(dvs.special)
local power = math.floor((bits * 5 + (dvs.special or 0) % 4) / 2) + 31
local index = ((dvs.attack or 0) % 4) * 4 + ((dvs.defense or 0) % 4)
return power, Battle.HIDDEN_POWER_TYPES[index + 1]
end
-- Everything the AI_Smart layer reads, gathered once per enemy decision. A
-- field the engine cannot answer honestly is simply left nil, and the matching
-- handler branch never fires: see the "read but never produced" list in Ai.lua.
function Battle:smartAiState()
local enemyState = self:volatile(self.enemy)
local playerState = self:volatile(self.player)
local chart = self.data.type_chart
local typeTable = chart and chart.types
local matchups = chart and chart.matchups
-- AIHasMoveEffect walks the enemy's list by effect; AIHasMoveInArray (the
-- weather moves) matches raw move IDS, so both shapes are built here.
local known, ids = {}, {}
for _, move in ipairs(self.enemy.moves or {}) do
local def = self:moveDef(move.id)
if def and def.effect then known[def.effect] = true end
ids[move.id] = true
end
-- wPlayerUsedMoves, read three ways: AI_Smart_Counter counts the physical
-- damaging entries, AI_Smart_MirrorCoat the special ones, and
-- AI_Smart_RazorWind wants the EFFECTS behind them (it dismisses itself on
-- EFFECT_PROTECT).
local physical, special = 0, 0
local usedEffects = {}
for _, id in ipairs(playerState.usedMoves or {}) do
local def = self:moveDef(id)
if def then
if def.effect then usedEffects[def.effect] = true end
if (def.power or 0) > 0 then
if Damage.isPhysical(def.type, typeTable) then
physical = physical + 1
else
special = special + 1
end
end
end
end
local enemyTypes = (self:speciesDef(self.enemy) or {}).types
or self.enemy.types or {}
local playerTypes = (self:speciesDef(self.player) or {}).types
or self.player.types or {}
-- `cp SPECIAL` against wBattleMonType1/2: AI_Smart_SpDefenseUp2 and
-- AI_Smart_Curse ask the same question, "is EITHER player type special".
-- nil rather than false when the types are unknown, so the branch stays shut.
local playerSpecialType
for _, name in ipairs(playerTypes) do
if not Damage.isPhysical(name, typeTable) then playerSpecialType = true end
end
-- The player's own ramp. The port keeps ONE counter pair for Rollout and
-- Fury Cutter, so the loaded move is what tells wPlayerFuryCutterCount from
-- SUBSTATUS_ROLLOUT apart.
local rampDef = playerState.rampMove and self:moveDef(playerState.rampMove)
local rampEffect = rampDef and rampDef.effect
-- wLastPlayerCounterMove: what Spite drains, what Mimic would copy (the cart
-- sets hBattleTurn to 1, so the matchup defends with the PLAYER's own types)
-- and what Mirror Coat's tail tests.
local lastId = playerState.lastMove
local lastDef = lastId and self:moveDef(lastId)
local lastEntry = lastId and self:findMove(self.player, lastId)
-- AI_Smart_LockOn's `.checkmove`: a move worth aiming, meaning one under
-- `71 percent - 1` ($b4) raw accuracy whose type is at least neutral against
-- the player. Explicitly false when the loop found nothing, since that is
-- the case the cart discourages on.
local aimable = false
for _, move in ipairs(self.enemy.moves or {}) do
local def = self:moveDef(move.id)
if def and (def.accuracyRaw or 255) < 0xb4
and Damage.typeMultiplier(def.type, playerTypes, matchups) >= 10 then
aimable = true
end
end
-- AI_Smart_HealBell ORs the status byte of every unfainted mon in wOTParty,
-- the active one included.
local partyStatus = false
for _, mon in ipairs(self.enemyParty or {}) do
if (mon.hp or 0) > 0 and mon.status then partyStatus = true end
end
-- FindAliveEnemyMons and AICheckLastPlayerMon: both skip the mon that is out
-- and ask whether anything is left behind it.
local enemyHasBench = false
for index, mon in ipairs(self.enemyParty or {}) do
if index ~= self.enemyIndex and (mon.hp or 0) > 0 then
enemyHasBench = true
end
end
local playerLastMon = true
for index, mon in ipairs(self.party or {}) do
if index ~= self.playerIndex and (mon.hp or 0) > 0 then
playerLastMon = false
end
end
local hiddenPowerPower, hiddenPowerType = self:hiddenPower(self.enemy)
return {
enemyHp = self.enemy.hp,
enemyMaxHp = self.enemy.maxHp or (self.enemy.stats or {}).hp,
playerHp = self.player.hp,
playerMaxHp = self.player.maxHp or (self.player.stats or {}).hp,
enemyLevel = self.enemy.level, playerLevel = self.player.level,
enemyFaster = self:effectiveSpeed(self.enemy)
> self:effectiveSpeed(self.player),
enemyStatus = self.enemy.status, playerStatus = self.player.status,
enemyTurns = enemyState.turnsTaken or 0,
playerTurns = playerState.turnsTaken or 0,
stages = self.stages.enemy, playerStages = self.stages.player,
playerToxic = self.player.status == "toxic",
playerLeechSeed = playerState.leechSeed,
playerCharged = playerState.chargeMove ~= nil,
playerFlying = playerState.vanished,
playerLastMove = lastId,
-- wPlayerSubStatus5 & SUBSTATUS_LOCK_ON: the enemy's OWN Lock-On, since
-- BattleCommand_LockOn sets the bit on the target it was aimed at.
playerLockOn = playerState.lockOn or nil,
playerPhysicalMoves = physical,
enemyRage = enemyState.rage,
enemyRageCount = enemyState.rageCount,
enemyProtectCount = enemyState.protectCount,
enemyFuryCutterCount = enemyState.rampCount,
enemyConfused = enemyState.confuseCount ~= nil,
-- wPlayerWrapCount and SUBSTATUS_CURSE, live now that the trap and
-- curse volatiles are modelled.
playerTrapped = ((playerState.wrapCount or 0) > 0) or nil,
playerCursed = playerState.cursed or nil,
knownEffects = known,
enemyMoveIds = ids,
-- Types, IN SLOT ORDER: the weather handlers read slot 1 before slot 2 and
-- a swapped pair scores differently, so this is never sorted.
enemyTypes = enemyTypes,
playerTypes = playerTypes,
playerSpecialType = playerSpecialType,
playerMatchupScore = self:playerMatchupScore(),
playerSpecialMoves = special,
playerUsedEffects = usedEffects,
-- SUBSTATUS_FLYING and SUBSTATUS_UNDERGROUND split apart. Both Fly and Dig
-- carry EFFECT_FLY in Gen 2, so the vanish flag alone is ambiguous and the
-- stored move is what separates them; playerFlying above stays the combined
-- mask AI_Smart_Fly and AI_Smart_FutureSight want.
playerFlyingUp = (playerState.vanished
and playerState.chargeMove == "FLY") or nil,
playerUnderground = (playerState.vanished
and playerState.chargeMove == "DIG") or nil,
playerFuryCutter = (rampEffect == "EFFECT_FURY_CUTTER")
and (playerState.rampCount or 0) or nil,
playerRollout = (rampEffect == "EFFECT_ROLLOUT") or nil,
playerLastMovePp = lastEntry and lastEntry.pp or nil,
playerLastMoveMatchup = lastDef
and Damage.typeMultiplier(lastDef.type, playerTypes, matchups) or nil,
playerLastMoveSpecial = lastDef ~= nil
and not Damage.isPhysical(lastDef.type, typeTable) or nil,
playerLastMon = playerLastMon,
enemyToxic = self.enemy.status == "toxic",
enemyLeechSeed = enemyState.leechSeed,
enemySpikes = self.spikes and self.spikes.enemy or nil,
enemyPerishCount = enemyState.perish,
-- wEnemyMonStatus & SLP_MASK, on the cart's own scale: Battle:canAct
-- decrements statusTurns and clears the status at zero, so a value of 1 is
-- exactly the `cp 1` last sleeping turn. Always a number, never nil, or
-- AI_Smart_Snore scores nothing at all.
enemySleepTurns = (self.enemy.status == "sleep")
and (self.enemy.statusTurns or 0) or 0,
enemyPartyStatus = partyStatus,
enemyHasBench = enemyHasBench,
enemyInaccurateEffectiveMove = aimable,
hiddenPowerPower = hiddenPowerPower,
hiddenPowerMatchup = hiddenPowerType
and Damage.typeMultiplier(hiddenPowerType, playerTypes, matchups) or nil,
weather = self.weather,
}
end
-- Every move id the cache knows, for Metronome. Sorted so the pick is
-- reproducible from a seeded roll rather than from Lua's hash order.
function Battle:moveOrder()
if self._moveOrder then return self._moveOrder end
local out = {}
for id, def in pairs(self.data.moves or {}) do
if type(def) == "table" and def.power ~= nil then out[#out + 1] = id end
end
table.sort(out)
self._moveOrder = out
return out
end
function Battle:speciesDef(mon)
return mon and self.data.pokemon and self.data.pokemon[mon.species] or nil
end
-- One badge, read the way FieldMoves.hasBadge reads it: save.player.badges /
-- save.player.kantoBadges keyed by name, with the bit position accepted as a
-- fallback key so the two readers cannot disagree about who owns what.
function Battle:hasBadge(store, badge)
local player = self.save and self.save.player
local owned = player and player[store]
if type(owned) ~= "table" then return false end
if owned[badge] then return true end
local order = store == "kantoBadges" and Battle.KANTO_BADGE_ORDER
or Battle.JOHTO_BADGE_ORDER
for index, name in ipairs(order) do
if name == badge then return owned[index] == true end
end
return false
end
-- BoostStat (engine/battle/core.asm:6590): raise a stat by 1/8, capped at
-- MAX_STAT_VALUE (999). The eighth is a plain shift, so a stat under 8
-- gains nothing.
function Battle.boostStat(value)
return math.min(999, value + math.floor(value / 8))
end
-- BadgeStatBoosts' buggy tail: the Special Defense re-check does `srl a`
-- assuming `a` still holds the badge bits, but when GlacierBadge fired for
-- Special Attack the preceding BoostStat overwrote `a` with its cap-check
-- arithmetic. So with Glacier owned, whether SpDef is ALSO boosted depends
-- on the boosted Special Attack value:
-- * at or past the 999 cap, `a` leaves as LOW(999) = $e7, odd: boosted
-- * otherwise `a` is high(v) - 3 - borrow, where the borrow is set when
-- low(v) < LOW(999); the shifted-out low bit decides
-- (pokegold's own comment at core.asm:6584 marks the check buggy.)
function Battle.glacierBoostsSpDef(boostedSpAtk)
local v = boostedSpAtk or 0
if v >= 999 then return true end
local borrow = (v % 256) < 231 and 1 or 0
local a = (math.floor(v / 256) - 3 - borrow) % 256
return a % 2 == 1
end
-- The stat a hit actually reads: the party stat, plus the player-side badge
-- boost. BadgeStatBoosts runs against wBattleMon (the PLAYER's active mon
-- only, never the enemy and never in link), so the boost is applied here at
-- read time rather than mutating mon.stats, which IS the party slot in this
-- port and must survive the battle unboosted.
function Battle:battleStat(mon, key)
local value = (mon.stats or {})[key] or 1
if mon ~= self.player then return value end
local badge = Battle.BADGE_STAT_BOOSTS[key]
if badge and self:hasBadge("badges", badge) then
return Battle.boostStat(value)
end
if key == "specialDefense" and self:hasBadge("badges", "GLACIER") then
local spAtk = Battle.boostStat((mon.stats or {}).specialAttack or 1)
if Battle.glacierBoostsSpDef(spAtk) then
return Battle.boostStat(value)
end
end
return value
end
-- DoBadgeTypeBoosts (engine/battle/misc.asm:146): player's turn only, and
-- the first owned badge whose BadgeTypeBoosts row matches the move's type
-- boosts the damage. Each type appears once, so this is a plain scan.
function Battle:badgeTypeBoost(attacker, moveType)
if attacker ~= self.player or not moveType then return false end
for _, row in ipairs(Battle.BADGE_TYPE_BOOSTS) do
if row.type == moveType then
return self:hasBadge(row.store, row.badge)
end
end
return false
end
-- The screen guarding this defender against this KIND of hit, the way
-- DamageStats consults wEnemyScreens/wPlayerScreens: Reflect doubles the
-- defending side's Defense against a physical move, Light Screen its
-- Special Defense against a special one.
function Battle:screenActive(defender, physical)
local side = self.screens and self.screens[self:sideOf(defender)]
if not side then return false end
local turns = physical and (side.reflect or 0) or (side.lightScreen or 0)
return turns > 0
end
-- GetUserItem's b/c pair: the held effect id and its parameter out of
-- ItemAttributes, or nil/0 for an empty hand.
--
-- held_item.trigger, the most load-bearing of the names Gen 2 invents: Gen 1
-- has no held items at all, so there is no name to share. It wraps this
-- function rather than each of the eight places an item acts, because on the
-- cart those eight places are all one routine -- GetUserItem / GetOpponentItem
-- loading b and c and the caller comparing b against the HELD_* it cares about
-- -- and `trigger` says which comparison is about to happen:
--
-- "priority" DetermineMoveOrder's .equal_priority (Quick Claw)
-- "damage" DamageCalc's crit ladder and .DoneItem type boost (Scope Lens,
-- the HELD_<TYPE>_BOOST family)
-- "endure" the 1 HP clamp (Focus Band)
-- "flinch" the post-hit flinch roll (King's Rock)
-- "accuracy" BattleCommand_CheckHit's .BrightPowder
-- "confuse" the confusion gate (HELD_PREVENT_CONFUSE)
-- "residual" Battle:tickHeldItem, the end-of-turn Leftovers/Berry/cure arm
-- "check" any other read; nothing in the engine passes this today
--
-- ctx: battle, mon, item (the item id), def (its record), effect, parameter,
-- trigger. Vanilla answers `ctx.effect, ctx.parameter`, so a chain that wants
-- the item to do nothing at this trigger returns nil and one that wants a
-- different behaviour returns another HELD_* name -- the call sites all
-- compare against a name, so substitution is the whole mechanism. A returned
-- effect that is not a string is read as "no effect"; the parameter falls back
-- to the item's own rather than to 0, because 0 is a meaningful parameter
-- (a 0% BrightPowder) and a mod that only wanted to rename the effect should
-- not silently lose the number.
function Battle:heldEffect(mon, trigger)
local def = self:itemDef(mon and mon.item)
local effect = def and def.heldEffect or nil
local parameter = (def and def.heldParameter) or 0
if not Runtime.wantsHook("held_item.trigger") then return effect, parameter end
local hookedEffect, hookedParameter = Runtime.call("held_item.trigger",
function(c) return c.effect, c.parameter end,
{ battle = self, mon = mon, item = mon and mon.item, def = def,
effect = effect, parameter = parameter, trigger = trigger or "check" })
if type(hookedEffect) ~= "string" then return nil, 0 end
return hookedEffect, tonumber(hookedParameter) or parameter
end
-- Effective Speed for ordering: stat stages, then the paralysis quarter.
function Battle:effectiveSpeed(mon)
local stages = self.stages[self:sideOf(mon)]
local speed = Damage.applyStage(self:battleStat(mon, "speed"), stages.speed)
return Battle.statusPenaltyFor(self.data, mon, "speed", speed)
end
-- DetermineMoveOrder: faster side first, a coin flip on a tie. Priority comes
-- from the move (Quick Attack and friends) and beats Speed outright.
function Battle:orderOf(playerMove, enemyMove)
local playerPriority = self:movePriority(playerMove)
local enemyPriority = self:movePriority(enemyMove)
if playerPriority ~= enemyPriority then
return playerPriority > enemyPriority and "player" or "enemy"
end
-- HELD_QUICK_CLAW (engine/battle/core.asm `.equal_priority`): consulted
-- only once priority ties, ahead of the Speed compare. One byte against
-- the item's parameter (60 -> 60/256). When both sides hold one the
-- ENEMY's roll goes first, exactly as the non-link `.both_have_quick_claw`
-- arm orders them.
local playerEffect, playerParam = self:heldEffect(self.player, "priority")
local enemyEffect, enemyParam = self:heldEffect(self.enemy, "priority")
local playerClaw = playerEffect == "HELD_QUICK_CLAW"
local enemyClaw = enemyEffect == "HELD_QUICK_CLAW"
if playerClaw and enemyClaw then
if rand(self.random, 256) < enemyParam then return "enemy" end
if rand(self.random, 256) < playerParam then return "player" end
elseif playerClaw then
if rand(self.random, 256) < playerParam then return "player" end
elseif enemyClaw then
if rand(self.random, 256) < enemyParam then return "enemy" end
end
local playerSpeed = self:effectiveSpeed(self.player)
local enemySpeed = self:effectiveSpeed(self.enemy)
if playerSpeed ~= enemySpeed then
return playerSpeed > enemySpeed and "player" or "enemy"
end
return rand(self.random, 2) == 0 and "player" or "enemy"
end
-- Gen 2 priority moves. data/moves/effects_priorities.asm keys off the move
-- *effect*, so a modded move inherits the priority of whatever it copies.
Battle.PRIORITY = {
EFFECT_PRIORITY_HIT = 1, -- Quick Attack, Mach Punch
EFFECT_PROTECT = 3,
EFFECT_ENDURE = 3,
EFFECT_COUNTER = -1,
EFFECT_MIRROR_COAT = -1,
EFFECT_VITAL_THROW = -1,
}
function Battle:movePriority(moveId)
local def = self:moveDef(moveId)
return (def and Battle.PRIORITY[def.effect]) or 0
end
-- Can this mon act? Returns true, or false plus the message the cart prints.
function Battle:canAct(mon)
local name = self:monName(mon)
-- SUBSTATUS_RECHARGE, and it is checked BEFORE status: CheckPlayerTurn reads
-- it first, clears it, prints MustRechargeText and jumps to EndTurn, so a mon
-- that is both recharging and asleep spends this turn recharging.
local vol = self:volatile(mon)
if vol.recharge then
vol.recharge = nil
self:emit({ kind = "message", text = name .. " must recharge!" })
return false
end
-- The status arms, through the merged record. beforeMovePriority is what
-- puts sleep (40) and freeze (30) ahead of the flinch/confusion block and
-- paralysis (10) after it, the way CheckPlayerTurn orders them; the high
-- arms answer for the whole turn (a mon that woke up does not then get
-- asked about flinching) and the low one falls through when it lets the
-- move go.
local record = Battle.statusRecordFor(self.data, mon.status)
local beforeMove = record and record.beforeMove
if beforeMove
and (record.beforeMovePriority or 0) > Battle.VOLATILE_PRIORITY then
return beforeMove(self, mon, name) and true or false
end
-- SUBSTATUS_FLINCHED, read and cleared right after the freeze check
-- (CheckPlayerTurn / CheckEnemyTurn `.not_frozen`). Set this turn by the
-- opponent's HELD_FLINCH item (King's Rock) -- and the EFFECT_FLINCH_HIT
-- moves once they write the same flag.
if vol.flinched then
vol.flinched = nil
self:emit({ kind = "message", text = name .. " flinched!" })
return false
end
-- SUBSTATUS_CONFUSED (CheckPlayerTurn past `.not_flinched`): the count
-- decrements FIRST and zero snaps out -- the mon still acts that turn.
-- While it holds, one byte under 50 percent + 1 spends the turn on
-- HitConfusion's self-hit instead.
if vol.confuseCount then
vol.confuseCount = vol.confuseCount - 1
if vol.confuseCount <= 0 then
vol.confuseCount = nil
self:emit({ kind = "message", text = name .. "'s confused no more!" })
else
self:emit({ kind = "message", text = name .. " is confused!" })
if rand(self.random, 256) < 128 then
self:confusionSelfHit(mon)
return false
end
end
end
if beforeMove then
return beforeMove(self, mon, name) and true or false
end
return true
end
-- STRUGGLE, the move a mon with nothing left to spend falls back to
-- (engine/battle/core.asm `.CheckPlayerHasUsableMoves` for the player and
-- `.struggle` for the enemy). It lives in the move table like any other move
-- -- typeless-in-practice NORMAL, 50 power, EFFECT_RECOIL_HIT -- and is
-- deliberately NOT in anyone's move list, which is why useMove's PP guard is
-- written `if move and ...`: findMove returns nil for it and the guard is
-- skipped rather than tripped.
Battle.STRUGGLE = "STRUGGLE"
-- .LockOn's three exceptions against a flying target
-- (engine/battle/effect_commands.asm:1683-1688).
Battle.LOCK_ON_GROUND_MOVES = { EARTHQUAKE = true, FISSURE = true,
MAGNITUDE = true }
-- .CheckPlayerHasUsableMoves skips the disabled slot (engine/battle/core.asm:5290-5305).
function Battle:hasUsableMoves(mon)
local disabled = mon and mon.volatile and mon.volatile.disabled
for _, move in ipairs((mon and mon.moves) or {}) do
if (move.pp or 0) > 0 and move.id ~= disabled then return true end
end
return false
end
function Battle:findMove(mon, moveId)
for _, move in ipairs(mon.moves or {}) do
if move.id == moveId then return move end
end
return nil
end
-- A "state" is the per-mon volatile bookkeeping a turn needs: the charge a
-- two-turn move is midway through, a Substitute's remaining HP, the counters
-- Rollout and Fury Cutter ramp on, and what the mon took this turn so Counter
-- and Mirror Coat have something to answer. It hangs off the mon rather than
-- the battle so a switch takes it away, which is what the cart does.
function Battle:volatile(mon)
mon.volatile = mon.volatile or {}
return mon.volatile
end
-- Clears everything a switch clears (ResetBattleParticipants / SwitchOutMon).
--
-- SwitchOutMon reloads the battle struct from the party slot, which is what
-- takes a Transform down with the switch; the port's one-table-per-mon shape
-- makes that a restore rather than a reload (Battle:untransform). It has to
-- happen HERE and not only at the switch sites, because CleanUpBattleRAM at
-- the end of the battle runs through Battle:clearAllVolatiles -- and for a
-- wild catch that table is already sitting in the player's party.
function Battle:clearVolatile(mon)
if not mon then return end
self:untransform(mon)
mon.volatile = nil
end
-- The cart keeps every substatus in battle RAM (wPlayerSubStatus1-5), which
-- NewBattleMonStatus zeroes at each send-out and CleanUpBattleRAM zeroes on
-- the way out of the battle. This port hangs the same bookkeeping off the mon
-- record, and Battle.party IS save.party, so nothing a battle wrote may be
-- left on a party table: an X item's bit, a confusion count or a wrap counter
-- would otherwise be written to the save file and read back by the next
-- battle, where DIRE HIT is then refused forever as an already-set bit.
function Battle:clearAllVolatiles()
for _, mon in ipairs(self.party or {}) do self:clearVolatile(mon) end
for _, mon in ipairs(self.enemyParty or {}) do self:clearVolatile(mon) end
self:clearVolatile(self.player)
self:clearVolatile(self.enemy)
end
-- A battle.damage chain may be a Gen 1 mod, which returns Gen 1's info table
-- ({ crit, typeMult }) rather than Gen 2's ({ critical, effectiveness, ... }).
-- The two names mean the same thing in both generations, so read either --
-- src/battle/gen2/Damage.lua answers to both for the same reason.
local function normalizeDamageInfo(info)
if type(info) ~= "table" then return info end
if info.critical == nil and info.crit ~= nil then info.critical = info.crit end
if info.effectiveness == nil and info.typeMult ~= nil then
info.effectiveness = info.typeMult
end
return info
end
-- One damaging hit. Returns the damage actually dealt (0 when the move did
-- not connect at all), so recoil, drain and Counter all read the same number.
function Battle:hitOnce(attacker, defender, def, opts)
opts = opts or {}
local attackerStages = self.stages[self:sideOf(attacker)]
local defenderStages = self.stages[self:sideOf(defender)]
local types = self.data.type_chart and self.data.type_chart.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
local heldEffect, heldParam = self:heldEffect(attacker, "damage")
-- BattleCommand_Critical: SUBSTATUS_FOCUS_ENERGY (Focus Energy or a
-- DIRE HIT) and HELD_CRITICAL_UP (Scope Lens) each raise the ladder a
-- rung; a high-crit move raises it two.
local criticalLevel = Damage.criticalLevel({
highCritMove = def.effect == "EFFECT_ALWAYS_CRIT",
focusEnergy = self:volatile(attacker).focusEnergy,
scopeLens = heldEffect == "HELD_CRITICAL_UP",
})
-- battle.crit, the same hook src/battle/Damage.lua calls on Gen 1, with the
-- same ctx keys: a mod that forces or refuses criticals reads `attacker`,
-- `moveId` and `highCrit` exactly where it did on Red. `ruleset` has no Gen 2
-- counterpart (Gold's engine IS the ruleset) so it is absent rather than
-- invented, and `criticalLevel` is the Gen 2 addition -- the rung of
-- data/battle/critical_hit_chances.asm this hit reached, which Gen 1's
-- base-Speed derivation had no equivalent of.
local critical
if Runtime.wantsHook("battle.crit") then
critical = Runtime.call("battle.crit", function(c)
return Damage.rollCritical(c.criticalLevel, c.battle.random)
end, { battle = self, attacker = attacker, moveId = opts.moveId or def.id,
rng = self:roller(), random = self.random,
highCrit = def.effect == "EFFECT_ALWAYS_CRIT",
criticalLevel = criticalLevel })
else
critical = Damage.rollCritical(criticalLevel, self.random)
end
local attack = self:battleStat(attacker, "attack")
-- Burn halves physical Attack (Gen 2 does this in DamageStats), off the
-- status record's statPenalty.
attack = Battle.statusPenaltyFor(self.data, attacker, "attack", attack)
-- The HELD_<TYPE>_BOOST items (Charcoal, Mystic Water, ...): the item's
-- parameter is the percent boost DamageCalc's .DoneItem applies when the
-- held type matches the move's. PSYCHIC's type id is PSYCHIC_TYPE in the
-- port's chart, so the effect name is rebuilt from the move type.
local itemBoost
if def.type and heldEffect then
local wanted = "HELD_" .. (def.type == "PSYCHIC_TYPE" and "PSYCHIC"
or def.type) .. "_BOOST"
if heldEffect == wanted then itemBoost = heldParam end
end
local calcOpts = {
level = attacker.level or 1,
power = opts.power or def.power,
moveType = def.type,
attacker = {
attack = attack,
specialAttack = self:battleStat(attacker, "specialAttack"),
types = (self:speciesDef(attacker) or {}).types or attacker.types,
stages = attackerStages,
},
defender = {
defense = self:battleStat(defender, "defense"),
specialDefense = self:battleStat(defender, "specialDefense"),
types = (self:speciesDef(defender) or {}).types or defender.types,
stages = defenderStages,
},
types = types,
matchups = matchups,
critical = critical,
itemBoostPercent = itemBoost,
-- DoWeatherModifiers, the first thing BattleCommand_Stab farcalls
-- (effect_commands.asm:1254): rain boosts Water and cuts Fire, sun the
-- reverse, and rain cuts Solarbeam by its EFFECT rather than its type.
-- Scaled to the cart's tenths here so Damage.calc can apply it where the
-- cart does, ahead of the badge boost, STAB, the type rows and the roll.
weatherPercent = math.floor(
Effects.weatherModifier(self.weather, def.type, def.effect) * 10),
-- DoBadgeTypeBoosts, farcalled between the weather modifiers and STAB.
badgeTypeBoost = self:badgeTypeBoost(attacker, def.type),
-- SCREENS_REFLECT / SCREENS_LIGHT_SCREEN on the defending side double
-- the matching defence (the crit exemption lives in Damage.calc).
screen = self:screenActive(defender,
Damage.isPhysical(def.type, types)),
-- BattleCommand_DamageCalc's `srl c` (effect_commands.asm:2905-2913).
defenseHalved = def.effect == "EFFECT_SELFDESTRUCT",
random = self.random,
}
-- battle.damage, the same hook BattleState:computeDamage calls on Gen 1 and
-- with the same ctx keys: `user`, `target`, `move` and the `opts` table the
-- formula is actually run on, so a mod that edits c.opts (or returns its own
-- number) works the same way it does on Red. Gen 2's opts carry more than
-- Gen 1's -- the split special stats live inside c.opts.attacker /
-- c.opts.defender, and the weather, badge and held-item modifiers are there
-- as their own fields. `ruleset` is absent for the reason given on
-- battle.crit above. The ctx table is only built when a chain is installed,
-- so a mod-free boot pays nothing.
local damage, info
if Runtime.wantsHook("battle.damage") then
damage, info = Runtime.call("battle.damage", function(c)
return Damage.calc(c.opts)
end, { battle = self, user = attacker, target = defender, move = def,
moveId = opts.moveId or def.id, opts = calcOpts,
rng = self:roller(), random = self.random })
info = normalizeDamageInfo(info) or { effectiveness = 10 }
damage = damage or 0
else
damage, info = Damage.calc(calcOpts)
end
if info.effectiveness == 0 then
-- BattleCommand_Stab's `.GotMatchup` arm writes wAttackMissed when the
-- matchup byte is 0 (effect_commands.asm:1337), and `stab` runs ahead of
-- `moveanim` in every damaging effect list (data/moves/effects.asm:5), so
-- BattleCommand_MoveAnimNoSub's wAttackMissed early-out (:1958) turns an
-- immune hit into MoveDelay and no animation at all.
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return 0, info
end
-- BattleCommand_FalseSwipe (engine/battle/move_effects/false_swipe.asm):
-- wCurDamage is capped at the target's HP minus one before applydamage, so
-- the move can never KO -- the clamp that makes it a safe catching tool
-- against a mon (a roamer above all) a win would retire.
if def.effect == "EFFECT_FALSE_SWIPE" and damage >= (defender.hp or 0) then
damage = math.max(0, (defender.hp or 0) - 1)
end
return self:dealDamage(attacker, defender, damage, {
critical = critical, effectiveness = info.effectiveness,
-- Counter answers physical damage and Mirror Coat special, so what kind
-- of hit this was has to be recorded with it.
kind = Damage.isPhysical(def.type, types) and "physical" or "special",
-- Carried only so battle.damage_dealt can name the move, the way Gen 1's
-- EffectRegistry damage loop does.
move = def, moveId = opts.moveId or def.id,
}), info
end
-- Applies damage, routing it through the target's Substitute first: a
-- Substitute soaks the whole hit and breaks when it runs out
-- (BattleCommand_SubstituteFadeIfDead), so the mon behind it never loses HP.
function Battle:dealDamage(attacker, defender, damage, opts)
opts = opts or {}
damage = math.max(0, math.floor(damage or 0))
local state = self:volatile(defender)
if (state.substitute or 0) > 0 then
local absorbed = math.min(state.substitute, damage)
state.substitute = state.substitute - absorbed
self:emit({ kind = "message",
text = "The SUBSTITUTE took damage for " .. self:monName(defender) .. "!" })
if state.substitute <= 0 then
state.substitute = nil
self:emit({ kind = "message",
text = self:monName(defender) .. "'s SUBSTITUTE broke!" })
end
return absorbed
end
local defenderState = self:volatile(defender)
-- Endure leaves the holder on one hit point, however big the hit was.
-- BattleCommand_ApplyDamage calls BattleCommand_FalseSwipe unconditionally
-- for the Endure bit and FalseSwipe clamps wCurDamage to MonHP - 1, so a mon
-- braced at exactly 1 HP takes zero and still holds.
-- HELD_FOCUS_BAND rides the same clamp: the band is only consulted once
-- Endure is down, rolling one byte against the item parameter
-- (30 -> 30/256) and reusing the False Swipe clamp on success.
local endured, hungOn = false, false
if defenderState.endure and damage >= (defender.hp or 0)
and (defender.hp or 0) > 0 then
damage = (defender.hp or 0) - 1
endured = true
elseif damage >= (defender.hp or 0) and (defender.hp or 0) > 0 then
local effect, parameter = self:heldEffect(defender, "endure")
if effect == "HELD_FOCUS_BAND"
and rand(self.random, 256) < parameter then
damage = (defender.hp or 0) - 1
hungOn = true
end
end
defender.hp = math.max(0, (defender.hp or 0) - damage)
defenderState.tookThisTurn = (defenderState.tookThisTurn or 0) + damage
defenderState.tookKind = opts.kind or "physical"
-- Bide stores everything the user takes while it is counting down.
if defenderState.bideTurns then
defenderState.bideStored = (defenderState.bideStored or 0) + damage
end
self:emit({
kind = "damage", side = self:sideOf(defender),
amount = damage, hp = defender.hp, critical = opts.critical,
effectiveness = opts.effectiveness,
})
if opts.critical then
self:emit({ kind = "message", text = "A critical hit!" })
end
if opts.effectiveness and opts.effectiveness > 10 then
self:emit({ kind = "message", text = "It's super effective!" })
elseif opts.effectiveness and opts.effectiveness < 10 then
self:emit({ kind = "message", text = "It's not very effective..." })
end
if endured then
self:emit({ kind = "message",
text = self:monName(defender) .. " endured the hit!" })
elseif hungOn then
-- HungOnText, named after the item the way the cart pipes it through
-- wStringBuffer1.
local def = self:itemDef(defender.item)
self:emit({ kind = "message",
text = self:monName(defender) .. " hung on with "
.. ((def and def.name) or "FOCUS BAND") .. "!" })
end
-- SUBSTATUS_RAGE: being hit while raging raises the rager's Attack.
if defenderState.rage and damage > 0 and (defender.hp or 0) > 0 then
self:changeStage(defender, "attack", 1)
end
-- battle.damage_dealt, the payload src/battle/EffectRegistry.lua emits once
-- per landed hit on Gen 1, guarded the same way so an unsubscribed boot
-- builds nothing. `typeMult` is the x10 type multiplier under Gen 1's name;
-- Gen 2's own name for the same number is `effectiveness`, and both are here.
-- `move` is nil for the damage no move owns (Counter's answer, Future Sight's
-- delayed hit, spikes), which is a Gen 2 shape Gen 1 has no site for.
if Runtime.wants("battle.damage_dealt") then
Runtime.emit("battle.damage_dealt", {
battle = self, user = attacker, target = defender,
move = opts.move, moveId = opts.moveId,
damage = damage, crit = opts.critical or false,
typeMult = opts.effectiveness or 10,
-- Gen 2 additions: which side took it and whether the hit was physical
-- or special, which is what Counter and Mirror Coat answer.
effectiveness = opts.effectiveness or 10,
side = self:sideOf(defender), kind = opts.kind,
})
end
return damage
end
function Battle:heal(mon, amount, opts)
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
local before = mon.hp or 0
mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0)))
local healed = mon.hp - before
if healed > 0 then
self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed,
hp = mon.hp, anim = opts and opts.anim })
end
return healed
end
-- move_effects/selfdestruct.asm:6-12: the user's status and both HP bytes are
-- zeroed, and the user's Leech Seed goes with them.
function Battle:selfdestructUser(attacker)
local lost = attacker.hp or 0
attacker.status, attacker.statusTurns = nil, nil
attacker.toxicCounter = nil
attacker.hp = 0
self:volatile(attacker).leechSeed = nil
-- The move carries ONE after-anim for the whole thing
-- (move_effects/selfdestruct.asm:2-3), and the target's hit already plays it.
self:emit({ kind = "damage", side = self:sideOf(attacker),
amount = lost, hp = 0, anim = false })
end
-- One stat change, with the cart's own message (or its refusal).
function Battle:changeStage(target, stat, stages)
local applied = Effects.applyStage(self.stages[self:sideOf(target)], stat,
stages)
local name = self:monName(target)
if not applied then
-- WontRiseAnymoreText / WontDropAnymoreText (data/text/battle.asm:718-732).
self:emit({ kind = "message", text = ("%s's %s won't %s anymore!"):format(
name, Effects.STAT_NAMES[stat] or stat,
stages > 0 and "rise" or "drop") })
return false
end
self:emit({ kind = "stage", side = self:sideOf(target), stat = stat,
stages = applied, text = Effects.stageMessage(name, stat, applied) })
return true
end
-- wAttackMissed, modelled on the event the screen animates off. Every path
-- that sets it (CheckHit's .Miss arms and the effect commands' own `.failed`
-- tails, which reach AnimateFailedMove: a delay and no animation) marks the
-- move event, and the screen skips the attack animation for a marked one --
-- BattleCommand_MoveAnimNoSub, engine/battle/effect_commands.asm:1958.
function Battle:markMissed()
if self.moveEvent then self.moveEvent.missed = true end
end
-- One attack, start to finish.
function Battle:useMove(attacker, defender, moveId)
local move = self:findMove(attacker, moveId)
local def = self:moveDef(moveId)
local name = self:monName(attacker)
local state = self:volatile(attacker)
-- wAttackMissed is per-move: BattleTurn's ResetTurn clears it before the
-- effect list runs, so nothing a previous move set can reach this one.
self.moveEvent = nil
if not def then
self:emit({ kind = "message", text = name .. " has no move to use!" })
return
end
-- A mon locked into the second half of a two-turn move spends no PP and
-- makes no new choice: it just lands the stored attack.
local charging = state.chargeMove == moveId
if charging then
state.chargeMove = nil
state.vanished = nil
end
-- BattleCommand_CheckRampage (effect_commands.asm:4851) is the FIRST command
-- in the Rampage list, ahead of checkobedience and doturn, and
-- SkipToBattleCommand leaves the script pointer PAST the command it looked
-- for (:6674-6689) -- so a continuing Thrash or Petal Dance spends no PP,
-- makes no obedience check and never re-rolls its count. The counter runs
-- down here; when it reaches zero the lock ends and the user is confused,
-- and the move STILL resolves this turn (`.continue_rampage`).
local rampaging = def.effect == "EFFECT_RAMPAGE"
and state.rampageMove == moveId and (state.rampageTurns or 0) > 0
if rampaging then
state.rampageTurns = state.rampageTurns - 1
if state.rampageTurns <= 0 then
state.rampageMove, state.rampageTurns = nil, nil
-- CheckRampage writes SUBSTATUS_CONFUSED and the count itself rather
-- than calling FinishConfusingTarget, so there is no text, no
-- Substitute test and no HELD_PREVENT_CONFUSE test -- just the same
-- `and %00000001` plus two roll, 2 or 3 turns. The cart's one
-- exemption is the user's own Safeguard, which this port does not model
-- yet.
state.confuseCount = state.confuseCount or (rand(self.random, 2) + 2)
end
end
-- BattleCommand_CheckRollout (move_effects/rollout.asm) skips past
-- doturn_command while SUBSTATUS_ROLLOUT is set, so a continuing Rollout is
-- free of PP and obedience in exactly the same way.
local rolling = state.rolloutLock == moveId
if not (charging or rampaging or rolling) then
if move and (move.pp or 0) <= 0 then
self:emit({ kind = "message", text = "No PP left for this move!" })
return
end
if move then move.pp = (move.pp or 1) - 1 end
-- BattleCommand_Rampage (effect_commands.asm:4886): the opening turn rolls
-- 1 or 2 MORE turns of lock-in, so Thrash and Petal Dance run for two or
-- three turns in all. A mon acting through Sleep Talk never rampages.
if def.effect == "EFFECT_RAMPAGE" and attacker.status ~= "sleep" then
state.rampageMove = moveId
state.rampageTurns = rand(self.random, 2) + 1
end
end
-- BattleCommand_Rage sets SUBSTATUS_RAGE and leaves the move to hit
-- normally; any OTHER move clears it, which is why Rage has no entry in
-- MOVE_EFFECTS -- it falls straight through to the damage path.
state.rage = (def.effect == "EFFECT_RAGE") or nil
-- Fury Cutter and Rollout reset the moment another move is used; the ramp
-- itself is maintained below.
-- UsedMoveText is built out of _ActorNameText followed by _UsedMove1Text,
-- which is `text_start` plus `line "used @"` (data/text/common_2.asm:339),
-- so the break after the user's name is part of the string and lands on the
-- box's second row however short the name is. It is not a wrap, and the
-- panel must not be left to invent one.
--
-- The event is kept so the miss paths below can mark it: the screen animates
-- off this event, and BattleCommand_MoveAnimNoSub
-- (engine/battle/effect_commands.asm:1958) opens on
-- `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`, so a
-- move that missed or failed burns the delay and plays nothing at all.
self.moveEvent = self:emit({ kind = "move", side = self:sideOf(attacker),
move = moveId,
text = Strings("%s\nused %s!", name, def.name or moveId) })
-- battle.move_used, where BattleState:executeMove raises it on Gen 1: after
-- the announcement and before the effect runs, so a mod sees the move that
-- is about to resolve. `move` is the move record (it carries `id`, the way
-- Gen 1's does) and `isCalled` is true for the move Metronome or Mirror Move
-- picked -- Gen 2 tracks that as the copy depth rather than a flag argument.
if Runtime.wants("battle.move_used") then
Runtime.emit("battle.move_used", {
battle = self, user = attacker, target = defender, move = def,
isCalled = (self.copyDepth or 0) > 0,
-- Gen 2 additions: the id on its own (Gen 1 mods read move.id), and
-- which side is swinging.
moveId = moveId, side = self:sideOf(attacker),
})
end
-- Metronome and Mirror Move do not attack: they pick another move and run
-- it instead (both end in `ResetTurn`). `copyDepth` is the port's own
-- guard -- the cart cannot recurse because it restarts the turn, and
-- Metronome's own exception list keeps it from picking itself.
if def.effect == "EFFECT_METRONOME" or def.effect == "EFFECT_MIRROR_MOVE" then
local picked
if def.effect == "EFFECT_METRONOME" then
local order = (self.data.constants or {}).moveOrder
or (self.data.moves and self.data.moves.order)
picked = Effects.metronomePick(order or self:moveOrder(),
attacker.moves, self.random)
else
-- Mirror Move copies the OPPONENT's last move and fails when there is
-- none, or when the user already knows it (CheckUserMove).
local last = self:volatile(defender).lastMove
picked = last
for _, own in ipairs(attacker.moves or {}) do
if own.id == last then picked = nil break end
end
end
if not picked or (self.copyDepth or 0) > 0 then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
self.copyDepth = (self.copyDepth or 0) + 1
self:useMove(attacker, defender, picked)
self.copyDepth = self.copyDepth - 1
return
end
-- Everything past here counts as "the user's last move" for Mirror Move,
-- Encore and Disable.
state.lastMove = moveId
state.turnsTaken = (state.turnsTaken or 0) + 1
state.usedMoves = state.usedMoves or {}
local seen = false
for _, id in ipairs(state.usedMoves) do if id == moveId then seen = true end end
if not seen then state.usedMoves[#state.usedMoves + 1] = moveId end
-- ParsePlayerAction (core.asm:618-624) and its enemy twin (core.asm:5621-
-- 5627) zero the protect count for any move that is not Protect or Endure.
if def.effect ~= "EFFECT_PROTECT" and def.effect ~= "EFFECT_ENDURE" then
state.protectCount = nil
end
-- Turn one of a charge move: print the line, remember the move, done.
-- BattleCommand_SkipSunCharge (effect_commands.asm:6488): in sun,
-- Solarbeam's effect list jumps straight past the charge command and the
-- beam fires in one turn.
local charge = Effects.CHARGE[def.effect]
if def.effect == "EFFECT_SOLARBEAM" and self.weather == "sun" then
charge = nil
end
if charge and not charging then
state.chargeMove = moveId
state.vanished = charge.vanish or nil
-- DIG and FLY are the same effect in Gen 2 (both EFFECT_FLY), so the table
-- keyed by effect cannot tell them apart and DIG announced itself with
-- "flew up high!". BattleCommand_Fly picks the line off the MOVE, not the
-- effect: `cp DIG` and then the burrow text.
local text = charge.text
if moveId == "DIG" then text = "%s dug a hole!" end
self:emit({ kind = "message", text = text:format(name) })
return
end
-- Counter and Mirror Coat answer what the user took this turn, at double,
-- and fail outright when nothing of the right kind landed.
local counterKind = Effects.COUNTER[def.effect]
if counterKind then
local taken = state.tookThisTurn or 0
if taken <= 0 or state.tookKind ~= counterKind then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
self:dealDamage(attacker, defender, Effects.counterDamage(taken),
{ move = def, moveId = moveId })
return
end
-- Protect turns the whole move aside before accuracy is even rolled.
if self:volatile(defender).protect then
-- CheckHit's .Protect arm jumps to .Miss (effect_commands.asm:1557).
if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end
self:markMissed()
self:emit({ kind = "message",
text = self:monName(defender) .. " protected itself!" })
return
end
-- BattleCommand_CheckHit's .LockOn: the flag Lock-On left on the TARGET is
-- read and cleared by the very next move aimed at it, and while it is up the
-- accuracy roll does not happen at all.
local locked = self:consumeLockOn(defender)
-- SUBSTATUS_X_ACCURACY (an X ACCURACY) grants the same roll bypass,
-- CheckHit's `.XAccuracy` arm, but is not consumed: it lasts until the
-- switch drops the volatile.
local sureHit = locked or self:volatile(attacker).xAccuracy == true
-- .LockOn runs ahead of .FlyDigMoves and returns a HIT unless the target is
-- flying and the move is one of the three (effect_commands.asm:1563-1567,
-- :1674-1691).
local lockedThrough = locked and not (
self:volatile(defender).chargeMove == "FLY"
and Battle.LOCK_ON_GROUND_MOVES[moveId])
-- .FlyDigMoves: four moves reach a flying target, three an underground one
-- (effect_commands.asm:1566-1567, :1713-1746).
if self:volatile(defender).vanished and not lockedThrough
and not Effects.hitsVanished(self:volatile(defender).chargeMove, moveId) then
-- CheckHit's .Miss only sets wAttackMissed (effect_commands.asm:1619-1630),
-- so `selfdestruct` still runs ahead of failuretext.
if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end
self:markMissed()
self:emit({ kind = "message", text = name .. "'s attack missed!" })
return
end
-- The status-shaped moves: each one either sets its own state and returns,
-- or falls through to the ordinary damage path. Through the merged
-- `move_effects` record, so a mod's own primary effect is dispatched here
-- the way BattleState:performMove dispatches one on Gen 1.
local effectRecord = Battle.moveEffectRecordFor(self.data, def.effect)
local handler = effectRecord and effectRecord.run
if handler then
handler(self, attacker, defender, def, moveId, sureHit)
return
end
-- BattleCommand_CheckHit opens on `call .DreamEater / jp z, .Miss`
-- (engine/battle/effect_commands.asm:1554): DREAM EATER against a target
-- that is not asleep is a MISS, before anything is rolled, so no damage
-- lands and nothing is sapped. The gate sits ahead of CheckHit's .LockOn
-- and .XAccuracy arms, which is why `sureHit` does not carry the move past
-- it -- and ahead of the damage block, which is where this used to sit,
-- refusing the move only after it had already hit and healed.
if def.effect == "EFFECT_DREAM_EATER" and defender.status ~= "sleep" then
self:markMissed()
self:emit({ kind = "message", text = name .. "'s attack missed!" })
return
end
-- MAGNITUDE rolls its power before checkhit (`getmagnitude` sits between
-- damagestats and damagecalc, data/moves/effects.asm:1705), so the number is
-- announced even on a miss. The rolled power replaces the move's stored
-- one, which the ROM keeps at 1 for exactly this reason.
local powerOverride
if def.effect == "EFFECT_MAGNITUDE" then
local rolled, number = Effects.magnitudePower(self.random)
powerOverride = rolled
self:emit({ kind = "message",
text = ("Magnitude %d!"):format(number) })
end
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
-- data/moves/effects.asm:148-151: `selfdestruct` sits between checkhit and
-- failuretext, so a missed Explosion still kills the user.
if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end
self:markMissed()
self:emit({ kind = "message", text = name .. "'s attack missed!" })
-- Fury Cutter's ramp resets the moment it misses.
state.rampMove = nil
state.rampCount = nil
-- BattleCommand_RolloutPower reads wAttackMissed before it touches the
-- counter and clears SUBSTATUS_ROLLOUT outright (rollout.asm), so a missed
-- Rollout releases the lock as well as the power ramp. A missed rampage
-- does NOT: `rampage` runs ahead of checkhit and nothing reads the miss.
state.rolloutLock = nil
return
end
-- move_effects/selfdestruct.asm:6-12, run before applydamage.
if def.effect == "EFFECT_SELFDESTRUCT" then self:selfdestructUser(attacker) end
-- Substitute: a quarter of max HP, refused when the user has no more than
-- that to give.
if def.effect == "EFFECT_SUBSTITUTE" then
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
local cost = Effects.substituteCost(maxHp)
if (attacker.hp or 0) <= cost or (state.substitute or 0) > 0 then
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
return
end
attacker.hp = attacker.hp - cost
state.substitute = cost
-- The cost is paid silently: SUBSTITUTE's own anim is all that plays
-- (move_effects/substitute.asm:57-68).
self:emit({ kind = "damage", side = self:sideOf(attacker), amount = cost,
hp = attacker.hp, anim = false })
self:emit({ kind = "message",
text = name .. " made a SUBSTITUTE!" })
return
end
-- Damage that skips the formula entirely. The move's own power goes with
-- it: EFFECT_STATIC_DAMAGE's arm of BattleCommand_ConstantDamage reads
-- BATTLE_VARS_MOVE_POWER as the damage (effect_commands.asm:3157-3161).
local fixed = Effects.fixedDamage(def.effect, attacker, defender, self.random,
def.power)
if fixed then
-- The constant-damage effect list carries `resettypematchup` instead of
-- `stab`, and that command misses the move outright when the matchup byte
-- is 0 (effect_commands.asm:1480-1493) -- an immune target is the one
-- thing that stops SONIC BOOM, NIGHT SHADE or SUPER FANG.
local defenderTypes = (self:speciesDef(defender) or {}).types
or defender.types
local matchups = self.data.type_chart and self.data.type_chart.matchups
if Damage.typeMultiplier(def.type, defenderTypes, matchups) == 0 then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
self:dealDamage(attacker, defender, fixed, { move = def, moveId = moveId })
return
end
local dealt, info = 0, nil
if ((powerOverride or def.power) or 0) > 0 then
-- Rollout and Fury Cutter double their power for each consecutive use.
local power = powerOverride or def.power
if Effects.RAMPING[def.effect] then
-- The two ramps are separate bytes on the cart with separate reset
-- rules, so "is this a continuation?" is asked differently for each.
--
-- ROLLOUT: BattleCommand_CheckRollout's `.reset` arm zeroes
-- wPlayerRolloutCount whenever SUBSTATUS_ROLLOUT is CLEAR as the move
-- starts (move_effects/rollout.asm), and the fifth hit is what clears
-- that bit. So a sequence that has run its five hits out does NOT feed
-- the next one: picking ROLLOUT again opens a fresh count, at base power
-- and re-locked. Testing "was the last move also ROLLOUT?" instead kept
-- the spent counter, which left the second sequence starting at the 16x
-- cap and never locking the menu at all.
--
-- FURY CUTTER: wPlayerFuryCutterCount has no such bit. It is zeroed by
-- ResetFuryCutterCount, which move_effects/fury_cutter.asm calls on a
-- miss and effect_commands.asm:355 calls whenever another move is used,
-- which is exactly the same-move test below.
local continuing
if def.effect == "EFFECT_ROLLOUT" then
continuing = state.rolloutLock == moveId
else
continuing = state.rampMove == moveId
end
if continuing then
state.rampCount = math.min((state.rampCount or 0) + 1,
Effects.RAMPING[def.effect] - 1)
else
state.rampMove, state.rampCount = moveId, 0
end
power = Effects.rampedPower(def.power, state.rampCount,
def.effect == "EFFECT_ROLLOUT" and state.curled)
-- BattleCommand_RolloutPower's `.hit` arm sets SUBSTATUS_ROLLOUT while
-- the incremented counter is still short of MAX_ROLLOUT_COUNT and
-- clears it on the fifth (rollout.asm), and CheckPlayerLockedIn
-- (core.asm:546) offers no menu at all while the bit is set. Fury
-- Cutter shares the power ramp but not the lock: its effect list
-- carries no checkrollout. `rampCount` is the cart's counter minus
-- one, so the last locked turn is the one below the cap.
if def.effect == "EFFECT_ROLLOUT" then
local last = state.rampCount >= (Effects.RAMPING[def.effect] - 1)
state.rolloutLock = (not last) and moveId or nil
end
else
state.rampMove, state.rampCount = nil, nil
state.rolloutLock = nil
end
local hits = Effects.hitCount(def.effect, self:roller())
local landed = 0
for hit = 1, hits do
if (defender.hp or 0) <= 0 then break end
local hitPower = power
if def.effect == "EFFECT_TRIPLE_KICK" then
hitPower = Effects.tripleKickPower(def.power, hit)
-- Each kick rolls its own accuracy and the sequence stops on a miss.
if hit > 1 and not sureHit
and not self:accuracyRoll(def, attacker, defender) then
break
end
end
local amount
amount, info = self:hitOnce(attacker, defender, def, { power = hitPower })
if info and info.effectiveness == 0 then
-- rolloutpower sits after checkhit and reads the wAttackMissed that
-- `stab` set for the immunity, so an immune target breaks the Rollout
-- lock (rollout.asm, the arm above `.hit`).
state.rolloutLock = nil
return
end
dealt = dealt + amount
landed = landed + 1
end
if landed > 1 then
self:emit({ kind = "message",
text = ("Hit %d time(s)!"):format(landed) })
end
-- Recoil is a quarter of what was dealt; drain heals half of it. Dream
-- Eater's sleep requirement is checkhit's, not this block's, so by the
-- time a drain is paid out the target is known to have been asleep.
if def.effect == "EFFECT_RECOIL_HIT" and dealt > 0 then
local recoil = Effects.recoilDamage(dealt)
attacker.hp = math.max(0, (attacker.hp or 0) - recoil)
-- BattleCommand_Recoil is bar, huds and RecoilText only: no anim at all
-- (effect_commands.asm:5674-5687).
self:emit({ kind = "damage", side = self:sideOf(attacker),
amount = recoil, hp = attacker.hp, anim = false })
self:emit({ kind = "message", text = name .. " is hit with recoil!" })
elseif Effects.DRAIN[def.effect] and dealt > 0 then
self:heal(attacker, Effects.drainAmount(dealt))
self:emit({ kind = "message",
text = self:monName(defender) .. "'s energy was drained!" })
end
-- BattleCommand_RechargeNextTurn (effect_commands.asm:5899): HYPER BEAM
-- sets SUBSTATUS_RECHARGE on the user, and CheckPlayerTurn /
-- CheckEnemyTurn spend the next turn clearing it. Nothing here implemented
-- it, so HYPER BEAM was a 150-power move with no cost at all.
--
-- Found by the Gold route bot: CHAMPION LANCE's three DRAGONITE all carry
-- it, and they were firing it every single turn -- twice the damage output
-- the fight is balanced around, against a bot with one healthy mon.
-- Unlike Gen 1 there is no "no recharge if it KOs" exemption; the command
-- runs at the end of the effect list whenever the move connected.
if def.effect == "EFFECT_HYPER_BEAM" and dealt > 0 then
state.recharge = true
end
-- BattleCommand_HeldFlinch (effect_commands.asm:5349): a damaging move
-- that connected lets the ATTACKER's HELD_FLINCH item (King's Rock)
-- flinch the target, one byte against the parameter (30 -> 30/256).
-- Silent when it lands -- the message is the target's own "flinched!"
-- when it tries to act. A Substitute blocks it.
if dealt > 0 and (defender.hp or 0) > 0 then
local held, parameter = self:heldEffect(attacker, "flinch")
if held == "HELD_FLINCH"
and (self:volatile(defender).substitute or 0) <= 0
and rand(self.random, 256) < parameter then
self:volatile(defender).flinched = true
end
end
-- BattleCommand_FlinchTarget (effect_commands.asm:5314): the *_HIT
-- flinch moves (Rock Slide, Headbutt, Bite) roll the move's effect
-- chance after a connected hit; a Substitute blocks it. Silent when it
-- lands, same as the held-item flinch above.
if def.effect == "EFFECT_FLINCH_HIT" and dealt > 0
and (defender.hp or 0) > 0
and (self:volatile(defender).substitute or 0) <= 0 then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:volatile(defender).flinched = true
end
end
-- BattleCommand_TrapTarget (effect_commands.asm:5569): a connected Bind
-- class hit starts a 2-5 turn partial trap on the target -- unless one
-- is already running or a Substitute is up. The stored count is
-- `and %11` plus three because HandleWrap decrements BEFORE it acts, so
-- a count of n hurts on n-1 turns and releases on the last.
if def.effect == "EFFECT_TRAP_TARGET" and dealt > 0
and (defender.hp or 0) > 0 then
local target = self:volatile(defender)
if not target.wrapCount and (target.substitute or 0) <= 0 then
target.wrapCount = rand(self.random, 4) + 3
target.wrapMove = def.name or moveId
-- wFXAnimID keeps the trapping move itself, which is what HandleWrap
-- replays every turn (core.asm:1185-1202).
target.wrapMoveId = moveId
local trapText = Battle.TRAP_TEXT[moveId]
self:emit({ kind = "message",
text = trapText and trapText(self:monName(defender), name)
or (self:monName(defender) .. " was trapped!") })
end
end
end
-- Defense Curl arms Rollout as well as raising Defense.
if def.effect == "EFFECT_DEFENSE_CURL" then state.curled = true end
-- Stat changes: the primary ones always land, the *_HIT ones roll the
-- move's effect chance after a hit that connected.
--
-- A refused primary change is a failure the cart detects BEFORE its anim
-- command: RaiseStat's `.cant_raise_stat` and StatDown's `.CantLower` /
-- `.Mist` all write wAttackMissed (effect_commands.asm:4191, :4380-4400),
-- and `statupanim` / `statdownanim` read it (:2022) from a slot AFTER
-- `attackup` / `attackdown` in the effect list (data/moves/effects.asm,
-- AttackUp). The *_HIT twins must NOT be marked: their `attackdown` runs
-- after `moveanim` (AttackDownHit), so the animation has already played.
local change = Effects.STAT_CHANGES[def.effect]
if change then
local target = change[3] == "self" and attacker or defender
if not self:changeStageAgainstMist(attacker, target, change[1], change[2])
then
self:markMissed()
end
else
local onHit = Effects.STAT_CHANGES_ON_HIT[def.effect]
if onHit and dealt > 0 then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
local target = onHit[3] == "self" and attacker or defender
self:changeStageAgainstMist(attacker, target, onHit[1], onHit[2])
end
elseif def.effect == "EFFECT_ALL_UP_HIT" and dealt > 0 then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
for _, stat in ipairs(Effects.ALL_UP_STATS) do
self:changeStage(attacker, stat, 1)
end
end
end
end
-- Status moves land their status; damaging moves roll their effect chance.
-- Both come off the merged `move_effects` record: a primary record's
-- `status` is the one a zero-power move lands, a secondary record's is the
-- one rolled against the move's effect chance after a hit.
local record = Battle.moveEffectRecordFor(self.data, def.effect)
local status = record and record.kind == "primary" and record.status or nil
if status and (def.power or 0) == 0 then
-- Every status command's already-statused / immune arm ends on
-- AnimateFailedMove (BattleCommand_Poison's `.failed`,
-- effect_commands.asm:3748-3750, and :6656-6659): LowerSub, MoveDelay,
-- RaiseSub and no LoadMoveAnim. AnimateCurrentMove only runs on the
-- success path (:3752), and the effect scripts carry no moveanim of their
-- own (data/moves/effects.asm, Toxic / DoPoison). The SECONDARY_EFFECTS
-- branch below is the opposite case: that move already hit and already
-- animated, so a refused secondary must leave the event unmarked.
if not self:applyStatus(defender, status, attacker) then self:markMissed() end
else
local secondary = record and record.kind == "secondary"
and record.status or nil
if secondary and (defender.hp or 0) > 0 then
local chance = def.effectChance or 0
if chance > 0 and rand(self.random, 100) < chance then
self:applyStatus(defender, secondary, attacker)
end
end
end
end
--------------------------------------------------------------------------
-- The moves whose whole job is to set state
--------------------------------------------------------------------------
--
-- Each entry is one command out of engine/battle/move_effects/, and each one
-- either sets its state and returns or prints the cart's own failure line.
-- Anything NOT in this table falls through to the ordinary damage path, which
-- is what keeps an unmodelled effect honest.
--
-- Cross-file contract: useMove does NOT dispatch on this table any more, it
-- dispatches on Battle.MOVE_EFFECT_RECORDS, which is folded out of this one
-- (and out of STATUS_EFFECTS / SECONDARY_EFFECTS) at the bottom of the block.
-- A new effect goes here, ABOVE that fold; one added below it would be a
-- handler nothing ever calls.
Battle.MOVE_EFFECTS = {}
-- Every effect command's own `.failed` tail reaches AnimateFailedMove
-- (effect_commands.asm:6656): a delay and no animation, so a failed move is
-- marked the same way a missed one is.
local function fail(self)
self:markMissed()
self:emit({ kind = "message", text = "But it failed!" })
end
-- BattleCommand_Splash (engine/battle/move_effects/splash.asm): the whole
-- command is the animation and then `jp PrintNothingHappened`, and the effect
-- list (data/moves/effects.asm:1156) has no checkhit at all, so the move never
-- rolls accuracy and never touches the target. Without an entry here SPLASH
-- fell through to the damage path, where its zero power meant it announced
-- itself and then said nothing.
Battle.MOVE_EFFECTS.EFFECT_SPLASH = function(self)
-- NothingHappenedText (data/text/battle.asm:870): "But nothing" / "happened."
self:emit({ kind = "message",
text = Strings("But nothing\nhappened.") })
end
-- BattleCommand_StartRain / StartSun / StartSandstorm. Sandstorm is the only
-- one that refuses to re-cast itself.
for effect, weather in pairs(Effects.WEATHER) do
Battle.MOVE_EFFECTS[effect] = function(self, attacker)
if weather == "sandstorm" and self.weather == "sandstorm" then
return fail(self)
end
self.weather = weather
self.weatherTurns = Effects.WEATHER_TURNS
self:emit({ kind = "weather", weather = weather,
text = Effects.WEATHER_START_TEXT[weather] })
end
end
-- BattleCommand_PerishSong: four turns on BOTH sides, and it fails only when
-- both are already counting.
Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self)
local mine = self:volatile(self.player)
local theirs = self:volatile(self.enemy)
if mine.perish and theirs.perish then return fail(self) end
if not mine.perish then mine.perish = Effects.PERISH_TURNS end
if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end
self:emit({ kind = "message",
text = "All POKéMON hearing the song will faint in three turns!" })
end
-- BattleCommand_Encore: 3-6 turns locked into the move the target last used.
Battle.MOVE_EFFECTS.EFFECT_ENCORE = function(self, attacker, defender)
local target = self:volatile(defender)
local last = target.lastMove
if not last or Effects.ENCORE_BLOCKED[last] or target.encore then
return fail(self)
end
-- The move has to still be in the target's list with PP left.
local found
for _, move in ipairs(defender.moves or {}) do
if move.id == last and (move.pp or 0) > 0 then found = move end
end
if not found then return fail(self) end
target.encore = last
target.encoreTurns = Effects.encoreTurns(self.random)
self:emit({ kind = "message",
text = self:monName(defender) .. " got an ENCORE!" })
end
-- BattleCommand_Disable: one of the target's moves, for 2-9 turns. It fails
-- when something is already disabled, or when the target has not moved.
Battle.MOVE_EFFECTS.EFFECT_DISABLE = function(self, attacker, defender)
local target = self:volatile(defender)
if target.disabled then return fail(self) end
local last = target.lastMove
if not last or last == "STRUGGLE" then return fail(self) end
local found
for _, move in ipairs(defender.moves or {}) do
if move.id == last and (move.pp or 0) > 0 then found = move end
end
if not found then return fail(self) end
target.disabled = last
target.disabledTurns = Effects.disableTurns(self.random)
self:emit({ kind = "message",
text = self:monName(defender) .. "'s " .. last .. " was disabled!" })
end
-- BattleCommand_LockOn: Lock-On and Mind Reader set SUBSTATUS_LOCK_ON on the
-- TARGET, not on the user, which is why the AI reads wPlayerSubStatus5 to ask
-- whether its own lock-on has landed. A Substitute blocks it outright.
Battle.MOVE_EFFECTS.EFFECT_LOCK_ON = function(self, attacker, defender)
local target = self:volatile(defender)
if (target.substitute or 0) > 0 then
-- lock_on.asm's `.fail`: AnimateFailedMove, then PrintDidntAffect. The
-- animation is AnimateCurrentMove on the success arm only.
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
target.lockOn = true
self:emit({ kind = "message",
text = self:monName(attacker) .. " took aim!" })
end
-- BattleCommand_CheckHit's .LockOn: the flag is read AND cleared by the next
-- move aimed at the mon carrying it, whether or not that move was the one the
-- lock-on was meant for, and whether or not the exception at :1683-1688 then
-- misses (effect_commands.asm:1671-1672).
function Battle:consumeLockOn(defender)
local target = self:volatile(defender)
if not target.lockOn then return false end
target.lockOn = nil
return true
end
-- BattleCommand_CheckHit's `.BrightPowder`: the DEFENDER's HELD_BRIGHTPOWDER
-- subtracts its parameter (20) from the accuracy byte before the roll. The
-- port rolls accuracy in the percent domain, so the byte penalty is scaled
-- by 100/256 and floored -- never below 1, because rollHit reads a
-- non-positive accuracy as "never misses", the exact opposite of the cart's
-- underflow-to-zero always-miss.
function Battle:moveAccuracy(accuracy, defender)
if not accuracy or accuracy <= 0 then return accuracy end
local effect, parameter = self:heldEffect(defender, "accuracy")
if effect == "HELD_BRIGHTPOWDER" then
accuracy = math.max(1,
accuracy - math.floor((parameter or 0) * 100 / 256))
end
return accuracy
end
-- The one accuracy roll (BattleCommand_CheckHit), hooked as battle.accuracy --
-- the same hook BattleState:accuracyRoll calls on Gen 1, with the same ctx
-- keys: `move`, `user`, `target` and the rng, so a mod that makes a move never
-- miss reads the same fields it did on Red. `ruleset` has no Gen 2
-- counterpart and is absent rather than invented; `accuracy` (the byte the
-- roll is actually made against, after Bright Powder) and `moveId` are Gen 2
-- additions. The ctx table is built only when a chain is installed.
function Battle:accuracyRoll(def, attacker, defender, accuracy)
accuracy = accuracy or (def and def.accuracy)
if Runtime.wantsHook("battle.accuracy") then
return Runtime.call("battle.accuracy", function(c)
return c.battle:vanillaAccuracyRoll(c.accuracy, c.user, c.target)
end, { battle = self, move = def, moveId = def and def.id,
user = attacker, target = defender, accuracy = accuracy,
rng = self:roller(), random = self.random })
end
return self:vanillaAccuracyRoll(accuracy, attacker, defender)
end
function Battle:vanillaAccuracyRoll(accuracy, attacker, defender)
return Damage.rollHit(self:moveAccuracy(accuracy, defender),
self.stages[self:sideOf(attacker)].accuracy,
self.stages[self:sideOf(defender)].evasion, self.random)
end
-- BattleCommand_StatDown's SUBSTATUS_MIST arm (a GUARD SPEC): a drop the FOE
-- aims at the holder answers ProtectedByMistText and changes nothing. The
-- holder's own drops are not Mist's business, so self-targeted changes pass
-- straight through.
function Battle:changeStageAgainstMist(attacker, target, stat, stages)
if target ~= attacker and (stages or 0) < 0
and self:volatile(target).mist then
self:emit({ kind = "message",
text = self:monName(target) .. "'s protected by MIST." })
return false
end
return self:changeStage(target, stat, stages)
end
-- BattleCommand_Spikes: laid on the side that will switch into them, and it
-- refuses a second layer (Gen 2 has only one).
Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender)
local side = self:sideOf(defender)
if self.spikes[side] then return fail(self) end
self.spikes[side] = true
self:emit({ kind = "message", text = "Spikes were scattered all around!" })
end
-- BattleCommand_Protect / Endure share ProtectChance, which halves the odds
-- for every consecutive use and zeroes the counter the moment one fails.
local function protectLike(field, text)
return function(self, attacker)
local state = self:volatile(attacker)
-- move_effects/protect.asm:22-23: `call CheckOpponentWentFirst / jr nz,
-- .failed`, ahead of everything else ProtectChance rolls.
-- move_effects/protect.asm:27-30: no Protect from behind a Substitute.
if (self.firstMover and self.firstMover ~= self:sideOf(attacker))
or (state.substitute or 0) > 0
or not Effects.protectSucceeds(state.protectCount or 0, self:roller())
then
state.protectCount = 0
return fail(self)
end
state.protectCount = (state.protectCount or 0) + 1
state[field] = true
self:emit({ kind = "message", text = self:monName(attacker) .. text })
end
end
Battle.MOVE_EFFECTS.EFFECT_PROTECT = protectLike("protect", " protected itself!")
Battle.MOVE_EFFECTS.EFFECT_ENDURE = protectLike("endure", " braced itself!")
-- BattleCommand_UnleashEnergy / StoreEnergy. Turn one starts the store; the
-- turn the counter runs out the user hits for double everything it took.
Battle.MOVE_EFFECTS.EFFECT_BIDE = function(self, attacker, defender, def, moveId)
local state = self:volatile(attacker)
if not state.bideTurns then
state.bideTurns = Effects.bideTurns(self.random)
state.bideStored = 0
self:emit({ kind = "message",
text = self:monName(attacker) .. " is storing energy!" })
return
end
state.bideTurns = state.bideTurns - 1
if state.bideTurns > 0 then
self:emit({ kind = "message",
text = self:monName(attacker) .. " is storing energy!" })
return
end
local damage = Effects.bideDamage(state.bideStored)
state.bideTurns, state.bideStored = nil, nil
self:emit({ kind = "message",
text = self:monName(attacker) .. " unleashed energy!" })
if damage <= 0 then return fail(self) end
self:dealDamage(attacker, defender, damage, { move = def, moveId = moveId })
end
-- BattleCommand_Transform: the user takes the target's species, types, moves
-- and stats, keeping its own HP and level. Every copied move gets 5 PP.
Battle.MOVE_EFFECTS.EFFECT_TRANSFORM = function(self, attacker, defender)
local state = self:volatile(attacker)
if state.transformed or self:volatile(defender).substitute then
return fail(self)
end
state.transformed = true
-- The cart copies the target into BATTLE ram (wBattleMon / wEnemyMon) and
-- leaves the struct the mon was loaded FROM alone, so every route out of the
-- battle -- SwitchOutMon reloading the party slot, and PokeBallEffect
-- reloading the caught mon out of its base data -- hands back a DITTO. This
-- port has one table per mon, so the identity the copy is about to overwrite
-- is kept here and put back by Battle:untransform, which every one of those
-- routes goes through. Without it a Ditto that transformed was a permanent
-- copy of whatever it last faced: the caught record, and the player's own
-- party slot, went into the save as the wrong species with the wrong moves.
state.preTransform = {
species = attacker.species,
types = attacker.types,
moves = attacker.moves,
shiny = attacker.shiny,
stats = {},
}
local targetDef = self:speciesDef(defender)
attacker.species = defender.species
attacker.types = (targetDef and targetDef.types) or defender.types
attacker.shiny = defender.shiny
local moves = {}
for index, move in ipairs(defender.moves or {}) do
moves[index] = { id = move.id, pp = 5, maxPp = 5 }
end
attacker.moves = moves
-- Everything but HP is copied, which is why a Transformed Ditto has the
-- target's Attack and its own hit points.
local stats = attacker.stats or {}
local theirs = defender.stats or {}
for _, key in ipairs({ "attack", "defense", "speed", "specialAttack",
"specialDefense" }) do
state.preTransform.stats[key] = stats[key]
stats[key] = theirs[key] or stats[key]
end
attacker.stats = stats
self:emit({ kind = "message", text = self:monName(attacker)
.. " TRANSFORMED into " .. (defender.species or "?") .. "!" })
-- The moment itself, for the screen. src/ui/gen2/BattleState.lua draws each
-- side's pic and HUD from `shownMon`, which follows the EVENT QUEUE rather
-- than the battle -- a whole round is resolved by Battle:takeTurn before its
-- first message is read, so anything written straight into the mon record is
-- on screen a beat before its own line. That is what made a wild DITTO
-- change shape the instant the player confirmed a move. Damage has
-- `shownHp` and a switch has the `send` event for exactly this; a transform
-- is the third identity swap and this is its event. `mon` is the battler
-- whose pic changes (the same key `send` carries) and `from` is what it was.
self:emit({ kind = "transform", side = self:sideOf(attacker),
mon = attacker, species = attacker.species,
from = state.preTransform.species })
end
-- SwitchOutMon / PokeBallEffect's reload: the copy Transform wrote lives in
-- battle ram on the cart, so it never survives the mon leaving the field.
-- Called from Battle:clearVolatile (every switch, and CleanUpBattleRAM at the
-- end of the battle) and from Battle:caught, which is the catch's own reload.
-- Returns whether anything was restored.
function Battle:untransform(mon)
local state = mon and mon.volatile
local before = state and state.preTransform
if not before then return false end
mon.species = before.species
mon.types = before.types
mon.moves = before.moves
mon.shiny = before.shiny
-- The stat table is written through in place (a mon's `stats` is handed
-- around by reference), so the five copied numbers are put back one by one.
local stats = mon.stats
if stats then
for key, value in pairs(before.stats) do stats[key] = value end
end
state.preTransform = nil
state.transformed = nil
return true
end
-- BattleCommand_FutureSight: four turns, damage rolled and stored now.
Battle.MOVE_EFFECTS.EFFECT_FUTURE_SIGHT = function(self, attacker, defender, def)
local state = self:volatile(attacker)
if state.futureSight then return fail(self) end
local damage = Damage.calc({
level = attacker.level or 1,
power = def.power,
moveType = def.type,
attacker = {
attack = (attacker.stats or {}).attack,
specialAttack = (attacker.stats or {}).specialAttack,
types = (self:speciesDef(attacker) or {}).types or attacker.types,
stages = self.stages[self:sideOf(attacker)],
},
defender = {
defense = (defender.stats or {}).defense,
specialDefense = (defender.stats or {}).specialDefense,
types = (self:speciesDef(defender) or {}).types or defender.types,
stages = self.stages[self:sideOf(defender)],
},
types = self.data.type_chart and self.data.type_chart.types,
matchups = self.data.type_chart and self.data.type_chart.matchups,
random = self.random,
})
state.futureSight = Effects.FUTURE_SIGHT_TURNS
state.futureSightDamage = math.max(1, damage)
state.futureSightSide = self:sideOf(defender)
self:emit({ kind = "message",
text = self:monName(attacker) .. " foresaw an attack!" })
end
-- BattleCommand_OHKO: fails outright against a higher-level target, and the
-- level difference is worth two accuracy points each.
Battle.MOVE_EFFECTS.EFFECT_OHKO = function(self, attacker, defender, def, _,
locked)
local accuracy = Effects.ohkoAccuracy(def.accuracy, attacker.level,
defender.level)
if not accuracy then
-- `.no_effect` sets wAttackMissed (effect_commands.asm:5414-5419) and
-- `ohko` sits ahead of `moveanim` in OHKOHit (data/moves/effects.asm:917),
-- so the level refusal plays MoveDelay and nothing else.
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
-- BattleCommand_OHKO ends on `call BattleCommand_CheckHit`, so a lock-on
-- carries Fissure past the level-scaled roll -- and so does
-- SUBSTATUS_X_ACCURACY, which is why an X ACCURACY makes the OHKO moves
-- sure hits in Gen 2 (`locked` here is useMove's sureHit).
local hit = locked or self:accuracyRoll(def, attacker, defender, accuracy)
if not hit then
self:markMissed()
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s attack missed!" })
return
end
self:dealDamage(attacker, defender, defender.hp or 1,
{ move = def, moveId = def and def.id })
self:emit({ kind = "message", text = "It's a one-hit KO!" })
end
-- BattleCommand_BeatUp: one hit per healthy, unstatused party member, each
-- swinging with its own base Attack.
Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def)
local party = attacker == self.player and self.party or self.enemyParty
local active = attacker == self.player and self.playerIndex or self.enemyIndex
local hits = Effects.beatUpParty(party, active)
if #hits == 0 then return fail(self) end
local targetDef = self:speciesDef(defender)
local landed = 0
for _, entry in ipairs(hits) do
if (defender.hp or 0) <= 0 then break end
local monDef = self.data.pokemon and self.data.pokemon[entry.mon.species]
local base = monDef and monDef.baseStats or {}
local damage = Damage.calc({
level = entry.mon.level or attacker.level or 1,
power = def.power,
moveType = def.type,
-- The BASE stats, not the battle stats: Beat Up asks GetBaseData for
-- each party member and the target both.
attacker = { attack = base.attack or 1, specialAttack = base.attack or 1,
types = {}, stages = {} },
defender = {
defense = (targetDef and targetDef.baseStats
and targetDef.baseStats.defense) or 1,
specialDefense = (targetDef and targetDef.baseStats
and targetDef.baseStats.defense) or 1,
types = {}, stages = {},
},
types = self.data.type_chart and self.data.type_chart.types,
matchups = self.data.type_chart and self.data.type_chart.matchups,
random = self.random,
})
self:emit({ kind = "message",
text = self:monName(entry.mon) .. "'s attack!" })
self:dealDamage(attacker, defender, math.max(1, damage),
{ move = def, moveId = def and def.id })
landed = landed + 1
end
self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) })
end
-- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both
-- EFFECT_HEAL and split on the MOVE (:6007), Rest taking GetMaxHP (:6043).
Battle.MOVE_EFFECTS.EFFECT_HEAL = function(self, attacker, _, _, moveId)
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
-- effect_commands.asm:6061: full HP answers HPIsFullText, not the fail line.
if (attacker.hp or 0) >= maxHp then
self:markMissed()
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s HP is full!" })
return
end
if moveId == "REST" then
-- effect_commands.asm:6015-6027: the toxic bit clears, the status byte
-- becomes REST_SLEEP_TURNS + 1, and the line depends on the old status.
local cured = attacker.status ~= nil
attacker.status = "sleep"
attacker.statusTurns = 3
attacker.toxicCounter = nil
self:emit({ kind = "status", side = self:sideOf(attacker),
status = "sleep", text = self:monName(attacker)
.. (cured and " fell asleep and became healthy!"
or " went to sleep!") })
self:heal(attacker, maxHp)
else
self:heal(attacker, math.max(1, math.floor(maxHp / 2)))
end
-- effect_commands.asm:6058, RegainedHealthText.
self:emit({ kind = "message",
text = self:monName(attacker) .. " regained health!" })
end
-- BattleCommand_TimeBasedHealContinue (effect_commands.asm:6374) answers the
-- same two lines BattleCommand_Heal does: HPIsFullText (:6447) and
-- RegainedHealthText (:6440).
for effect in pairs(Effects.SUN_HEAL) do
Battle.MOVE_EFFECTS[effect] = function(self, attacker)
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
if (attacker.hp or 0) >= maxHp then
self:markMissed()
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s HP is full!" })
return
end
local fraction = Effects.weatherHealFraction(self.weather)
self:heal(attacker, math.max(1, math.floor(maxHp * fraction)))
self:emit({ kind = "message",
text = self:monName(attacker) .. " regained health!" })
end
end
-- BattleCommand_BatonPass: the switch keeps everything ResetBatonPassStatus
-- does NOT clear -- the stat stages above all, which is the point of the move.
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
local side = self:sideOf(attacker)
local party = side == "player" and self.party or self.enemyParty
local current = side == "player" and self.playerIndex or self.enemyIndex
local target
for index, mon in ipairs(party) do
if index ~= current and (mon.hp or 0) > 0 then
target = index
break
end
end
if not target then return fail(self) end
local carried = self:volatile(attacker)
for _, key in ipairs(Effects.BATON_PASS_DROPS) do carried[key] = nil end
-- The area moves with the baton rather than being copied: what the passer
-- leaves the field with is an empty one, the same as any other switch out.
self:clearVolatile(attacker)
self:emit({ kind = "baton-pass", side = side, index = target,
text = self:monName(attacker) .. " passed the baton!" })
if side == "player" then
self.playerIndex = target
self.player = party[target]
self.participants[target] = true
self.player.volatile = carried
else
self.enemyIndex = target
self.enemy = party[target]
self.enemy.volatile = carried
end
self:emit({ kind = "send", side = side,
mon = side == "player" and self.player or self.enemy,
text = "Go! " .. self:monName(side == "player" and self.player
or self.enemy) .. "!" })
end
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
-- user second. FIRE_SPIN and WHIRLPOOL share the plain WasTrappedText
-- fallback in the caller.
Battle.TRAP_TEXT = {
BIND = function(target, user)
return user .. " used BIND on " .. target .. "!"
end,
WRAP = function(target, user)
return target .. " was WRAPPED by " .. user .. "!"
end,
CLAMP = function(target, user)
return target .. " was CLAMPED by " .. user .. "!"
end,
}
-- BattleCommand_Screen (effect_commands.asm:6100): one wPlayerScreens bit
-- and a five-turn count per side; the second cast fails while the first is
-- still up.
Battle.SCREEN_TURNS = 5
Battle.MOVE_EFFECTS.EFFECT_LIGHT_SCREEN = function(self, attacker)
local side = self.screens[self:sideOf(attacker)]
if (side.lightScreen or 0) > 0 then return fail(self) end
side.lightScreen = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s SPCL.DEF rose!" })
end
Battle.MOVE_EFFECTS.EFFECT_REFLECT = function(self, attacker)
local side = self.screens[self:sideOf(attacker)]
if (side.reflect or 0) > 0 then return fail(self) end
side.reflect = Battle.SCREEN_TURNS
self:emit({ kind = "message",
text = self:monName(attacker) .. "'s DEFENSE rose!" })
end
-- BattleCommand_Curse (engine/battle/move_effects/curse.asm): two moves in
-- one body. A non-Ghost user trades a stage of Speed for one each of Attack
-- and Defense, refused only when BOTH raises are already capped; a Ghost
-- user pays half its max HP -- the cut can faint it -- to set
-- SUBSTATUS_CURSE on the target, worth a quarter of max HP every turn
-- (ResidualDamage's curse arm, Battle:tickSeedAndCurse).
Battle.MOVE_EFFECTS.EFFECT_CURSE = function(self, attacker, defender)
local ghost = false
for _, type_ in ipairs((self:speciesDef(attacker) or {}).types
or attacker.types or {}) do
if type_ == "GHOST" then ghost = true end
end
local name = self:monName(attacker)
if not ghost then
local stages = self.stages[self:sideOf(attacker)]
if (stages.attack or 0) >= Effects.MAX_STAGE
and (stages.defense or 0) >= Effects.MAX_STAGE then
-- curse.asm's `.cantraise`: AnimateFailedMove, then WontRiseAnymoreText.
-- The raising arm is the only one that calls AnimateCurrentMove.
self:markMissed()
self:emit({ kind = "message",
text = name .. "'s ATTACK won't rise anymore!" })
return
end
-- The cart's own order: Speed down first, then the two raises. The
-- user's own drop is not Mist's business.
self:changeStage(attacker, "speed", -1)
self:changeStage(attacker, "attack", 1)
self:changeStage(attacker, "defense", 1)
return
end
local target = self:volatile(defender)
if target.vanished or (target.substitute or 0) > 0 or target.cursed then
return fail(self)
end
target.cursed = true
local maxHp = attacker.maxHp or (attacker.stats and attacker.stats.hp) or 1
local cost = math.max(1, math.floor(maxHp / 2))
attacker.hp = math.max(0, (attacker.hp or 0) - cost)
-- AnimateCurrentMove has already run; SubtractHPFromUser adds nothing
-- (move_effects/curse.asm:70-76).
self:emit({ kind = "damage", side = self:sideOf(attacker), amount = cost,
hp = attacker.hp, anim = false })
self:emit({ kind = "message",
text = name .. " cut its own HP and put a CURSE on "
.. self:monName(defender) .. "!" })
end
-- BattleCommand_LeechSeed (engine/battle/move_effects/leech_seed.asm): the
-- flag sits on the SEEDED mon and ResidualDamage drains an eighth every turn
-- into whoever stands on the other side by then. A Grass target does not
-- take it at all; a miss, a Substitute or a repeat all "evaded" -- and the
-- move's own 90 accuracy rolls first, since its effect list carries
-- checkhit.
--
-- All three refusals (`.evaded` and `.grass`) end on AnimateFailedMove, so
-- none of them animates; only the seeding arm reaches AnimateCurrentMove.
Battle.MOVE_EFFECTS.EFFECT_LEECH_SEED = function(self, attacker, defender,
def, _, sureHit)
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
self:markMissed()
self:emit({ kind = "message",
text = self:monName(defender) .. " evaded the attack!" })
return
end
for _, type_ in ipairs((self:speciesDef(defender) or {}).types
or defender.types or {}) do
if type_ == "GRASS" then
self:markMissed()
self:emit({ kind = "message",
text = "It doesn't affect " .. self:monName(defender) .. "..." })
return
end
end
local target = self:volatile(defender)
if (target.substitute or 0) > 0 or target.leechSeed then
self:markMissed()
self:emit({ kind = "message",
text = self:monName(defender) .. " evaded the attack!" })
return
end
target.leechSeed = true
self:emit({ kind = "message",
text = self:monName(defender) .. " was seeded!" })
end
-- BattleCommand_Spite (engine/battle/move_effects/spite.asm): 2-5 PP off the
-- move the TARGET used last, clamped to what it has left. The cart reads
-- BATTLE_VARS_LAST_COUNTER_MOVE_OPP, so a target that has not moved yet -- or
-- that answered with STRUGGLE, or whose slot is already dry -- falls into
-- `.failed`, which is `jp PrintDidntAffect2`. The effect list carries
-- checkhit (data/moves/effects.asm:1366), and MOVE_EFFECTS handlers run ahead
-- of useMove's own roll, so the roll is made here the way Leech Seed makes it.
--
-- No party writeback: the cart copies the new PP into the party struct and
-- wWildMonPP behind the battle struct, but Battle.party IS save.party here and
-- the move table this edits is the live one.
Battle.MOVE_EFFECTS.EFFECT_SPITE = function(self, attacker, defender, def, _,
sureHit)
-- DidntAffect2Text (data/text/battle.asm:888). BattleCommand_Spite calls
-- AnimateCurrentMove itself and only on the way to the success text, so a
-- refused SPITE plays nothing.
local function didntAffect()
self:markMissed()
self:emit({ kind = "message",
text = "It didn't affect " .. self:monName(defender) .. "!" })
end
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
return didntAffect()
end
local last = self:volatile(defender).lastMove
if not last or last == Battle.STRUGGLE then return didntAffect() end
local entry = self:findMove(defender, last)
if not entry or (entry.pp or 0) <= 0 then return didntAffect() end
-- `call BattleRandom / and %11 / inc a / inc a`, then `cp b / jr nc` keeps
-- the loss inside what the slot still holds.
local loss = math.min(rand(self.random, 4) + 2, entry.pp)
entry.pp = entry.pp - loss
local moveName = (self:moveDef(last) or {}).name or last
self:emit({ kind = "message",
text = ("%s's %s was reduced by %d!"):format(self:monName(defender),
moveName, loss) })
end
-- BattleCommand_ArenaTrap (effect_commands.asm:6238): Mean Look and Spider
-- Web set SUBSTATUS_CANT_RUN on the USER's side, meaning "my opponent cannot
-- run or switch" -- which is why the pin dies with its user (a switch drops
-- the volatile) and why TryEnemyFlee reads the PLAYER's substatus to hold a
-- roamer. No accuracy roll: the effect list has no checkhit, only the
-- hidden-target and repeat guards.
Battle.MOVE_EFFECTS.EFFECT_MEAN_LOOK = function(self, attacker, defender)
if self:volatile(defender).vanished
or self:volatile(attacker).trapsTarget then
return fail(self)
end
self:volatile(attacker).trapsTarget = true
self:emit({ kind = "message",
text = self:monName(defender) .. " can't escape now!" })
end
-- BattleCommand_ForceSwitch (effect_commands.asm:4913). Fails outright for
-- BATTLETYPE_FORCESHINY and BATTLETYPE_TRAP. Against a WILD mon the battle
-- simply ENDS -- either direction writes DRAW into wBattleResult, which is
-- what makes a Roared-away roamer bank its HP -- with the level ladder
-- deciding: the user's level at or above the target's succeeds outright,
-- and below it one re-rolled byte can still get past a quarter of the
-- target's level. In a TRAINER battle the user must be moving SECOND (both
-- arms read wEnemyGoesFirst) and a random other able mon is dragged out.
Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
def, moveId, sureHit)
if self.battleType == Battle.BATTLETYPE_FORCESHINY
or self.battleType == Battle.BATTLETYPE_TRAP then
return fail(self)
end
-- checkhit runs ahead of forceswitch in the effect list; `.missed` fails.
if not sureHit
and not self:accuracyRoll(def, attacker, defender) then
return fail(self)
end
if self.wild then
-- `.wild_force_flee` / `.wild_succeed_playeristarget`.
local userLevel = attacker.level or 1
local targetLevel = defender.level or 1
local succeeds = userLevel >= targetLevel
if not succeeds then
local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1))
succeeds = roll >= math.floor(targetLevel / 4)
end
if not succeeds then return fail(self) end
self.over = true
self.outcome = "fled"
-- FledInFearText for ROAR, BlownAwayText for everything else, naming
-- the mon that was sent away.
self.forcedSwitch = true
self:emit({ kind = "run", side = self:sideOf(defender),
text = self:monName(defender)
.. (moveId == "ROAR" and " fled in fear!" or " was blown away!") })
return
end
-- `.trainer` / `.vs_trainer`: the user has to be moving second, and the
-- other side needs someone able on the bench.
if self.firstMover == self:sideOf(attacker) then return fail(self) end
local party = defender == self.player and self.party or self.enemyParty
local active = defender == self.player and self.playerIndex
or self.enemyIndex
local bench = {}
for index, mon in ipairs(party) do
if index ~= active and (mon.hp or 0) > 0 and not mon.isEgg then
bench[#bench + 1] = index
end
end
if #bench == 0 then return fail(self) end
local pick = bench[rand(self.random, #bench) + 1]
self:clearVolatile(defender)
local incoming = party[pick]
if defender == self.player then
self.playerIndex = pick
self.player = incoming
self.participants[pick] = true
self.stages.player = Battle.newStages()
self:checkAmuletCoin(incoming)
else
self.enemyIndex = pick
self.enemy = incoming
self.stages.enemy = Battle.newStages()
end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
text = self:monName(incoming) .. " was dragged out!" })
self:breakTrapsOnSend(incoming)
self:spikesDamage(incoming)
-- wForcedSwitch: the round ends here, skipping the between-turn effects.
self.forcedSwitch = true
end
-- BattleCommand_Teleport (engine/battle/move_effects/teleport.asm). Fails
-- outright for BATTLETYPE_FORCESHINY/TRAP, for a trapped user, and in any
-- TRAINER battle; in a WILD battle the level ladder is identical to
-- EFFECT_FORCE_SWITCH's. Without an entry here TELEPORT fell through to
-- the (0-power) damage path and never ended the battle.
Battle.MOVE_EFFECTS.EFFECT_TELEPORT = function(self, attacker, defender)
if self.battleType == Battle.BATTLETYPE_FORCESHINY
or self.battleType == Battle.BATTLETYPE_TRAP
or self:volatile(defender).trapsTarget then
return fail(self)
end
if not self.wild then return fail(self) end
local userLevel = attacker.level or 1
local targetLevel = defender.level or 1
local succeeds = userLevel >= targetLevel
if not succeeds then
local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1))
succeeds = roll >= math.floor(targetLevel / 4)
end
if not succeeds then return fail(self) end
self.over = true
self.outcome = "fled"
self.forcedSwitch = true
self:emit({ kind = "run", side = self:sideOf(attacker),
text = self:monName(attacker) .. " fled from battle!" })
end
-- -------------------------------------------------------- the move effects
--
-- The three tables above as records, in the shape src/mods/Schemas.lua's
-- `move_effects` registry validates. Same registry NAME Gen 1 fills from
-- src/battle/MoveEffects.lua, and the same `kind` vocabulary:
--
-- primary the effect runs INSTEAD of the damage path. Both the
-- state-setting commands above (`run`) and the zero-power status
-- moves (`status`) are primary; which one a record is is which
-- field it carries.
-- secondary a side-effect rolled against the move's effect chance after a
-- hit that already landed.
--
-- `run` is the Gold signature fn(battle, attacker, defender, def, moveId,
-- sureHit), the same six arguments useMove has always dispatched on -- Gen 1's
-- run takes its own engine's context for the same reason the status records
-- above do.
--
-- One field Gen 2 adds rather than renaming anything: `status`, the name the
-- effect writes into mon.status, which is what makes EFFECT_TOXIC and
-- EFFECT_POISON_HIT records rather than two more lookup tables. Effects that
-- are steered from inside the damage pipeline (EFFECT_MULTI_HIT, the recoil
-- and drain families) have no standalone handler and so no record yet, exactly
-- as Gen 1's "full" effects have none: they fall through to the damage path,
-- which is what keeps an unmodelled effect honest.
Battle.MOVE_EFFECT_RECORDS = {}
for id, run in pairs(Battle.MOVE_EFFECTS) do
Battle.MOVE_EFFECT_RECORDS[id] = { kind = "primary", run = run }
end
for id, status in pairs(Battle.STATUS_EFFECTS) do
Battle.MOVE_EFFECT_RECORDS[id] = { kind = "primary", status = status }
end
for id, status in pairs(Battle.SECONDARY_EFFECTS) do
Battle.MOVE_EFFECT_RECORDS[id] = { kind = "secondary", status = status }
end
-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
-- one of these ids collides the way it does on Red and has to say override
function Battle.registerMoveEffectsInto(registry, _, owner)
for id, record in pairs(Battle.MOVE_EFFECT_RECORDS) do
registry:register(id, record, owner)
end
end
-- the merged `move_effects` record for an effect id, the module's own when no
-- loader ran; a plain function over `data` for the same reason
-- Battle.statusRecordFor is one
function Battle.moveEffectRecordFor(data, effect)
if effect == nil then return nil end
local merged = data and data.gen2MoveEffects
return (merged and merged[effect]) or Battle.MOVE_EFFECT_RECORDS[effect]
end
-- ------------------------------------------------------------ the statuses
--
-- Gold's persistent conditions as records, in the shape src/mods/Schemas.lua's
-- `statuses` registry validates. Same registry NAME Gen 1 fills from
-- src/battle/Status.lua, because a mod that adds a status should not have to
-- learn a second noun -- only the ids differ, and they have to: Gold's engine
-- writes "sleep" and "burn" into mon.status where Red writes SLP and BRN.
--
-- The Gen 1 fields keep their Gen 1 meaning:
--
-- label / hudLabel the three-letter code the HUD draws
-- catchBonus what PokeBallEffect adds to the catch rate
-- statPenalty the one stat this status cuts, and by what
-- beforeMove CheckPlayerTurn's arm, run before the move
-- beforeMovePriority above VOLATILE_PRIORITY runs ahead of the
-- flinch/confusion block, at or below after it,
-- which is CheckPlayerTurn's own order
-- residual the end-of-turn chip, HandleStatusOnTurnEnd
--
-- Their SIGNATURES are Gold's, because the two engines carry different
-- objects: Gen 1 hands a battler wrapper and returns message lists, Gold has
-- no battler wrapper and emits its own events, so beforeMove is
-- fn(battle, mon, name) -> canAct, residual is fn(battle, mon, maxHp) ->
-- damage, text, and onInflict is fn(battle, mon) with no return. A record is
-- generation-specific either way -- the ids are disjoint -- so the field names
-- stay shared and the shapes follow the engine that runs them.
--
-- Three fields Gen 2 genuinely carries that Gen 1 does not, added rather than
-- renaming anything (the catalog's top-level records are extensible):
--
-- inflictText the tail of the landing line, spliced after the name
-- catchBonusIntended the bonus the cart MEANT to give: the `and` that
-- tests for sleep/freeze leaves burn, poison and
-- paralysis at zero, so catchBonus is 0 for them and
-- this is the 5 that `fixBugs` asks for
-- (src/battle/gen2/Catching.lua statusBonus)
-- substatus true for confusion, which is SUBSTATUS_CONFUSED and
-- not a status byte at all
-- healClass the StatusHealingActions class that cures it, which
-- is how a mod status becomes curable: src/core/gen2/
-- ItemEffects.lua reads it for any spelling its own
-- STATUS_CLASS fold does not already know
--
-- Every consumer below reads through Battle:statusRecord, so a mod's sixth
-- status inflicts, chips, blocks a turn and cuts a stat like the vanilla six.
Battle.STATUSES = {
sleep = {
id = "sleep", label = "SLP", hudLabel = "SLP", healClass = "slp",
inflictText = " fell asleep!",
catchBonus = 10, catchBonusIntended = 10,
-- BattleCommand_Sleep rolls a 3-bit value, retried until nonzero.
onInflict = function(battle, mon)
mon.statusTurns = rand(battle.random, 7) + 1
end,
beforeMovePriority = 40,
beforeMove = function(battle, mon, name)
mon.statusTurns = (mon.statusTurns or 1) - 1
if mon.statusTurns <= 0 then
mon.status = nil
mon.statusTurns = nil
battle:emit({ kind = "message", text = name .. " woke up!" })
return true
end
battle:emit({ kind = "message", text = name .. " is fast asleep!" })
return false
end,
},
poison = {
id = "poison", label = "PSN", hudLabel = "PSN", healClass = "psn",
inflictText = " was poisoned!",
catchBonus = 0, catchBonusIntended = 5,
residual = function(_, _, maxHp)
return math.max(1, math.floor(maxHp / Battle.POISON_FRACTION)),
" is hurt by poison!"
end,
},
toxic = {
-- SUBSTATUS_TOXIC rides the poison byte, so the HUD says PSN either way.
id = "toxic", label = "PSN", hudLabel = "PSN", healClass = "psn",
inflictText = " was badly poisoned!",
catchBonus = 0, catchBonusIntended = 5,
onInflict = function(_, mon) mon.toxicCounter = 1 end,
-- Toxic ramps: n/16 of max HP on the nth turn.
residual = function(_, mon, maxHp)
local counter = mon.toxicCounter or 1
mon.toxicCounter = counter + 1
return math.max(1, math.floor(maxHp * counter / 16)),
" is hurt by poison!"
end,
},
paralyze = {
id = "paralyze", label = "PAR", hudLabel = "PAR", healClass = "par",
inflictText = " is paralyzed! It may be unable to move!",
catchBonus = 0, catchBonusIntended = 5,
statPenalty = { stat = "speed", div = Battle.PARALYSIS_SPEED_DIVISOR },
-- CheckPlayerTurn's last arm: after the flinch and confusion block.
beforeMovePriority = 10,
beforeMove = function(battle, mon, name)
if rand(battle.random, Battle.PARALYSIS_SKIP_CHANCE) ~= 0 then
return true
end
battle:emit({ kind = "message", text = name .. "'s fully paralyzed!" })
return false
end,
},
burn = {
id = "burn", label = "BRN", hudLabel = "BRN", healClass = "brn",
inflictText = " was burned!",
catchBonus = 0, catchBonusIntended = 5,
statPenalty = { stat = "attack", div = Battle.BURN_ATTACK_DIVISOR },
residual = function(_, _, maxHp)
return math.max(1, math.floor(maxHp / Battle.BURN_FRACTION)),
" is hurt by its burn!"
end,
},
freeze = {
id = "freeze", label = "FRZ", hudLabel = "FRZ", healClass = "frz",
inflictText = " was frozen solid!",
catchBonus = 10, catchBonusIntended = 10,
beforeMovePriority = 30,
beforeMove = function(battle, mon, name)
if rand(battle.random, Battle.THAW_CHANCE) == 0 then
mon.status = nil
battle:emit({ kind = "message", text = name .. " thawed out!" })
return true
end
battle:emit({ kind = "message", text = name .. " is frozen solid!" })
return false
end,
},
-- SUBSTATUS_CONFUSED: it lives in the volatile beside the major status, so
-- applyStatus hands it to applyConfusion rather than writing mon.status.
-- It is a record all the same because its landing line is one of the seven
-- src/core/gen2/ItemEffects.lua is held against.
confuse = {
id = "confuse", label = "CONFUSED", inflictText = " became confused!",
substatus = true,
},
}
-- beforeMovePriority above this runs ahead of the flinch/confusion block,
-- at or below after it -- CheckPlayerTurn's order, and the same constant
-- src/battle/Status.lua uses for the Gen 1 gauntlet.
Battle.VOLATILE_PRIORITY = 20
-- vanilla registrations, engine-owned (Schemas.ENGINE), so a mod's register of
-- one of these ids collides the way it does on Red and has to say override
function Battle.registerStatusesInto(registry, _, owner)
for id, record in pairs(Battle.STATUSES) do
registry:register(id, record, owner)
end
end
-- Kept as the derived view of the records: src/core/gen2/ItemEffects.lua's
-- cross-file contract (every name Battle can write into mon.status resolves to
-- a heal class) is checked against this table, and building it from STATUSES
-- is what stops the two from drifting.
Battle.STATUS_TEXT = {}
for id, record in pairs(Battle.STATUSES) do
Battle.STATUS_TEXT[id] = record.inflictText
end
-- The merged `statuses` record for a status id, the module's own when no
-- loader ran -- src/battle/BattleState.lua:effectRecord is the Gen 1 twin.
-- A plain function over `data` rather than a method on purpose: the tests
-- drive canAct and tickStatus against hand-built actor stubs that carry a mon
-- and an emit and nothing else, and a lookup that needed a method would make
-- every one of those stubs implement it.
function Battle.statusRecordFor(data, status)
if status == nil then return nil end
local merged = data and data.gen2Statuses
return (merged and merged[status]) or Battle.STATUSES[status]
end
-- The one stat this mon's status cuts, applied. Burn halves Attack and
-- paralysis quarters Speed on the cart; both come off statPenalty so a mod
-- status cuts a stat through the same seam.
function Battle.statusPenaltyFor(data, mon, stat, value)
local record = Battle.statusRecordFor(data, mon and mon.status)
local penalty = record and record.statPenalty
if not penalty or penalty.stat ~= stat then return value end
return math.max(1, math.floor(value / math.max(1, penalty.div or 1)))
end
-- `source` is the battler that inflicted it, carried only so
-- battle.status_inflicted can name it the way Gen 1's does.
function Battle:applyStatus(mon, status, source)
if (mon.hp or 0) <= 0 then return false end
-- Confusion is SUBSTATUS_CONFUSED on the cart, not a status byte: it lives
-- in the volatile beside the major status, so a confused mon can still be
-- burned and a switch shakes the confusion off.
if status == "confuse" then return self:applyConfusion(mon) end
-- One major status at a time.
if mon.status then
self:emit({ kind = "message",
text = "But it failed!" })
return false
end
mon.status = status
-- Through the merged record: onInflict is where the sleep roll and the Toxic
-- counter live, so a mod status can arm its own counter here too.
local record = Battle.statusRecordFor(self.data, status)
if record and record.onInflict then record.onInflict(self, mon) end
self:emit({ kind = "status", side = self:sideOf(mon), status = status,
text = self:monName(mon)
.. ((record and record.inflictText) or " is afflicted!") })
-- battle.status_inflicted, the payload src/battle/StatusRegistry.lua emits on
-- Gen 1, for the major status only -- confusion is a substatus in both
-- generations and Gen 1 raises nothing for it either. The `status` VALUE is
-- Gen 2's own spelling ("poison", "burn", "paralyze"), not Gen 1's PSN/BRN
-- code: the key still names the status that landed, and Gold's engine has no
-- three-letter codes to report.
Runtime.emit("battle.status_inflicted", {
battle = self, target = mon, status = status, source = source,
side = self:sideOf(mon),
})
return true
end
-- BattleCommand_FinishConfusingTarget (effect_commands.asm:5734): the
-- SUBSTATUS_CONFUSED bit plus a 2-5 turn count (`and %11` plus two).
-- `turns` is the Berserk Gene's override: HandleBerserkGene sets the bit
-- WITHOUT writing the count (core.asm:301), and the zero count decrements
-- through zero on the cart -- an effectively permanent lock, modelled here
-- as 256 turns. HELD_PREVENT_CONFUSE on the target blocks it outright.
Battle.BERSERK_GENE_CONFUSE_TURNS = 256
function Battle:applyConfusion(mon, turns)
if (mon.hp or 0) <= 0 then return false end
local state = self:volatile(mon)
if (state.substitute or 0) > 0 then return false end
local held = self:heldEffect(mon, "confuse")
if held == "HELD_PREVENT_CONFUSE" then return false end
if state.confuseCount then
self:emit({ kind = "message",
text = self:monName(mon) .. "'s already confused!" })
return false
end
state.confuseCount = turns or (rand(self.random, 4) + 2)
local record = Battle.statusRecordFor(self.data, "confuse")
self:emit({ kind = "message",
text = self:monName(mon)
.. ((record and record.inflictText) or " became confused!") })
return true
end
-- ResidualDamage picks the anim off the status byte, ANIM_BRN for a burn and
-- ANIM_PSN for either poison (engine/battle/core.asm:958-976).
Battle.RESIDUAL_ANIM = {
burn = "ANIM_BRN", poison = "ANIM_PSN", toxic = "ANIM_PSN",
}
-- End of turn: burn and poison chip damage, through the merged record's
-- `residual`. The record computes and advances its own counter; the emit pair
-- stays here because the event shape belongs to this engine, not to the status.
function Battle:tickStatus(mon)
if (mon.hp or 0) <= 0 or not mon.status then return end
local record = Battle.statusRecordFor(self.data, mon.status)
local residual = record and record.residual
if not residual then return end
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
local name = self:monName(mon)
local damage, text = residual(self, mon, maxHp)
if not damage or damage <= 0 then return end
mon.hp = math.max(0, mon.hp - damage)
self:emit({ kind = "message", text = name .. (text or " is hurt!") })
-- Call_PlayBattleAnim_OnlyIfVisible runs on the sufferer's own turn
-- (core.asm:970-976); a mod status the cart never had gets nothing.
self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage,
hp = mon.hp, anim = Battle.RESIDUAL_ANIM[mon.status] or false,
animSide = self:sideOf(mon) })
end
-- Faint bookkeeping and experience. Returns true when the battle ended.
function Battle:resolveFaints()
if (self.enemy.hp or 0) <= 0 then
self:emit({ kind = "faint", side = "enemy",
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
.. " fainted!" })
-- battle.fainted, the payload BattleState:onFaint emits on Gen 1.
-- `battler` is the mon itself here: Gen 2's engine has no battler wrapper.
Runtime.emit("battle.fainted", { battle = self, battler = self.enemy,
side = self:sideRecord(self.enemy) })
self:awardExperience(self.enemy)
local nextIndex = Battle.firstHealthy(self.enemyParty)
if not nextIndex then
if self.trainer then
self:emit({ kind = "message",
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
self:awardPrizeMoney()
end
self:endBattle("win")
return true
end
local previous = self.enemy
self:clearVolatile(self.enemy)
self.enemyIndex = nextIndex
self.enemy = self.enemyParty[nextIndex]
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) and NewEnemyMonStatus
-- clear the move selection and the substatus bytes for the mon coming IN,
-- so a replacement never inherits anything from its last stint.
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
-- `replacement` marks HandleEnemySwitch's send, the only one EnemySwitch
-- can offer a shift on (engine/battle/core.asm:2241-2278).
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
replacement = true,
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = previous,
})
self:breakTrapsOnSend(self.enemy)
-- core.asm runs SpikesDamage on every send-out; the faint replacement
-- is not exempt.
self:spikesDamage(self.enemy)
-- Battle_PlayerFirst reaches HandleEnemyMonFaint with `jp`, not `call`
-- (engine/battle/core.asm:872), so the round's attack phase is over: the
-- mon that just walked in never answers, and the move that was queued for
-- the one it replaced is never spent. Battle:takeTurn reads this.
self.faintInterrupt = true
return false
end
if (self.player.hp or 0) <= 0 then
-- Announce a faint ONCE.
--
-- This branch is the only one that returns without changing whose turn it
-- is: it emits `choose-switch` and waits for the caller to pick, so the
-- caller calls back in with the same mon still at 0 HP and the whole branch
-- ran again. The visible symptom was "TYPHLOSION fainted!" three times in
-- a row, but the real damage is one line lower -- `faintHappiness` was
-- charged once per re-entry, so a single faint cost two or three times the
-- happiness the cart takes (engine/battle/core.asm, HandlePlayerMonFaint
-- runs its happiness arm once).
--
-- Keyed on the mon itself, so the next one in announces normally.
if self.faintAnnounced ~= self.player then
self.faintAnnounced = self.player
self:emit({ kind = "faint", side = "player",
text = self:monName(self.player) .. " fainted!" })
Runtime.emit("battle.fainted", { battle = self, battler = self.player,
side = self:sideRecord(self.player) })
self:faintHappiness(self.player)
end
local nextIndex = Battle.firstHealthy(self.party)
if not nextIndex then
self:emit({ kind = "message", text = "You have no more POKéMON!" })
self:endBattle("lose")
return true
end
-- The player picks the replacement; the caller drives that with :switch.
--
-- Asked for ONCE, keyed the same way the faint line above is: takeTurn
-- reaches resolveFaints up to three times in a round, and every one of
-- them still sees a 0 HP mon because nothing switches until the player
-- answers. HandlePlayerMonFaint runs ForcePlayerMonChoice a single time
-- (engine/battle/core.asm:2543) and the turn loop does not come back for
-- another; three prompts in the queue meant the party menu reopened on top
-- of the pick that had already been made, so the switch looked like it
-- took two or three attempts. Battle:switch releases the guard.
if not self.pendingSwitch then
self.pendingSwitch = true
self:emit({ kind = "choose-switch" })
end
-- Same `jp`, not `call`, as the enemy arm above (core.asm:874): whatever
-- is left of the attack phase is abandoned.
self.faintInterrupt = true
return false
end
return false
end
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
-- and the frontpic slide: the four quarters are dealt between the wallet and
-- Mom's savings and then one StdBattleTextbox names the figure.
--
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
-- PrintWinLossText is the trainer's own after-battle line, which this port
-- runs from the script on the way out of the battle rather than from here.
-- The payout is not gated on it either way.
function Battle:awardPrizeMoney()
local save = self.save
if not (save and save.player) then return nil end
local award = Prize.award(save, {
baseMoney = self.trainer and self.trainer.baseMoney,
-- wCurPartyLevel, left behind by ReadTrainerParty: the LAST row of the
-- roster, whatever order the mons actually fainted in.
level = Prize.rewardLevel(self.enemyParty),
amuletCoin = self.amuletCoin,
})
self.prize = award
self:emit({ kind = "money", award = award,
text = Prize.message(award, save.player and save.player.name) })
return award
end
-- UpdateFaintedPlayerMon (engine/battle/core.asm), the happiness half. Runs
-- on EVERY player faint, not only the whiteout, and picks between two events
-- by how outclassed the mon was:
--
-- ld a, [wBattleMonLevel] / add 30 / ld b, a
-- ld a, [wEnemyMonLevel] / cp b / jr c, .got_param
--
-- `jr c` keeps HAPPINESS_FAINTED while the foe is BELOW yourLevel + 30, so the
-- harsher HAPPINESS_BEATENBYSTRONGFOE needs the foe to be at least thirty
-- levels up -- and the two events differ only in the third tier anyway (-1 for
-- a plain loss against -10 for a beating, at happiness 200 or more).
function Battle:faintHappiness(mon)
if not mon then return end
local event = "FAINTED"
if (self.enemy.level or 0) >= (mon.level or 0) + 30 then
event = "BEATENBYSTRONGFOE"
end
-- ChangeHappiness runs against the party slot, and this mon IS that slot's
-- table, so a fainted mon is still the thing that loses the point.
Happiness.change(mon, event)
end
-- GiveExperiencePoints' traded check: the mon's OT id against wPlayerID. A
-- mon with no recorded OT (the port's native catches and gifts) is the
-- player's own.
function Battle:isOutsider(mon)
local playerId = self.save and self.save.player and self.save.player.id
if mon.otId == nil or playerId == nil then return false end
return mon.otId ~= playerId
end
-- One pass of GiveExperiencePoints over `recipients` (party indices).
-- `count` is the pass's own divisor -- the participant count for the first
-- pass, the holder count for the EXP.SHARE pass -- and `halved` is whether
-- any Share holder taxed the whole pool.
function Battle:giveExperiencePass(loser, def, recipients, count, halved)
for _, index in ipairs(recipients) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 and not mon.isEgg then
local traded = self:isOutsider(mon)
-- `cp LUCKY_EGG` on the mon's item byte: by id, not held effect.
local luckyEgg = mon.item == "LUCKY_EGG"
-- exp.gain, the same hook src/battle/Experience.lua calls on Gen 1 and
-- with the same ctx keys (defeatedDef, level, isTrainer, participants,
-- traded, mon), so a mod that scales exp reads and edits the fields it
-- did on Red. `halved` (the EXP.SHARE tax on the whole pool) and
-- `luckyEgg` are Gen 2's own multipliers and ride beside them.
local amount
if Runtime.wantsHook("exp.gain") then
amount = Runtime.call("exp.gain", function(c)
return Mon.experienceGain(c.defeatedDef, c.level, c.participants,
c.isTrainer, { halved = c.halved, traded = c.traded,
luckyEgg = c.luckyEgg })
end, { defeatedDef = def, level = loser.level,
isTrainer = self.trainer ~= nil, participants = count,
traded = traded, mon = mon,
halved = halved, luckyEgg = luckyEgg,
battle = self, loser = loser })
else
amount = Mon.experienceGain(def, loser.level, count,
self.trainer ~= nil, { halved = halved, traded = traded,
luckyEgg = luckyEgg })
end
-- Stat exp first: GiveExperiencePoints awards it before the exp points,
-- so a mon that levels on this kill recalculates its stats with the
-- effort it just earned already counted. Pokerus (or the immune marker
-- a cured mon keeps) doubles it.
Mon.gainStatExp(mon, def, count, Pokerus.doublesStatExp(mon), halved)
local result = Mon.gainExperience(mon, amount, self.data)
-- battle.exp_gained, the payload BattleState:awardExp emits on Gen 1.
-- `levels` is the LIST of levels reached, the same shape Gen 1's
-- Experience.apply returns, built out of Gen 2's from/to pair -- and
-- built only when something is listening, since a KO in a six-mon party
-- comes through here once per recipient.
if Runtime.wants("battle.exp_gained") then
local levels = {}
for level = (result.from or 0) + 1, result.to or 0 do
levels[#levels + 1] = level
end
Runtime.emit("battle.exp_gained", {
battle = self, mon = mon, gained = amount, levels = levels,
-- Gen 2 addition: the party slot, which is what the engine's own
-- experience event is keyed by.
index = index,
})
end
self:emit({ kind = "experience", index = index, amount = amount,
-- BoostedExpPointsText, keyed on the traded arm alone.
text = self:monName(mon) .. " gained "
.. (traded and "a boosted " or "") .. amount .. " EXP. Points!" })
if result.levels > 0 then
-- "level up happiness mod", the cart's own comment, sitting right
-- after the stat recalc and before the "grew to level" text. It fires
-- ONCE per exp award however many levels the mon jumped, because
-- ChangeHappiness is outside the level loop.
Happiness.change(mon, "GAINLEVEL")
self:emit({ kind = "level", index = index, level = mon.level,
text = self:monName(mon) .. " grew to level " .. mon.level .. "!",
sfx = "Sfx_DexFanfare5079", waitSfx = true })
for _, moveId in ipairs(result.learned) do
local ok, reason, entry = Mon.learnMove(mon, moveId, self.data)
local moveDef = self:moveDef(moveId)
local moveName = (moveDef and moveDef.name) or moveId
if ok then
self:emit({ kind = "message",
text = self:monName(mon) .. " learned " .. moveName .. "!" })
elseif reason == "full" then
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
-- AskForgetMoveText (engine/pokemon/learn.asm:29-34, :121-124).
self:emit({ kind = "choose-forget", index = index, move = entry,
moveName = moveName })
end
end
end
end
end
end
-- PokeBallEffect's captured tail, the battle half of it: the catch site
-- (src/ui/gen2/BattleState.lua:pushCaught) owns the #DEX, the party and the
-- nickname prompt, and this owns what the BATTLE still has to say about the
-- mon that was just taken off the field. The Gen 1 twin is
-- src/battle/BattleState.lua:storeCaughtMon, which opens on exactly these two
-- steps in this order.
--
-- * the reload. `.catch_without_fail` puts wTempEnemyMonSpecies -- the
-- species the mon was SENT OUT as, which no move rewrites -- into
-- wCurPartySpecies before the mon is added, so a DITTO that transformed is
-- caught as a DITTO with its own moves. Gen 1 does the same thing for
-- Mimic (BattleState:restoreMimicked, cited at its own call).
-- * battle.catch_exp. Vanilla catches never grant exp; a mod can flip the
-- hook to true to pay out the same award a faint would have. Same name,
-- same default and the same one-key ctx as the Gen 1 site, so one
-- subscription covers both games (docs/mod-api-gen2-compat.md).
--
-- Safe to call more than once: the reload is a no-op once the identity is
-- back, and `caughtHandled` keeps a second call from paying the exp twice.
function Battle:caught(mon)
mon = mon or self.enemy
if self.caughtHandled then return mon end
self.caughtHandled = true
self:untransform(mon)
if Runtime.wantsHook("battle.catch_exp")
and Runtime.call("battle.catch_exp", function() return false end,
{ battle = self }) then
self:awardExperience(mon)
end
return mon
end
-- GiveExperiencePoints, both calls (engine/battle/core.asm:2116/2130): with
-- any live EXP.SHARE holder in the party the enemy's base exp and base
-- stats are halved up front, the participants split the first pass, and a
-- second pass pays every holder -- participant or not, so a holder that
-- fought collects twice. Holders are found by ITEM id, the way
-- IsAnyMonHoldingExpShare's `cp EXP_SHARE` does, and a fainted holder gets
-- nothing (the pass loop skips fainted mons).
function Battle:awardExperience(loser)
local def = self:speciesDef(loser)
local participants = {}
for index in pairs(self.participants) do
participants[#participants + 1] = index
end
table.sort(participants)
local holders = {}
for index, mon in ipairs(self.party) do
if (mon.hp or 0) > 0 and not mon.isEgg and mon.item == "EXP_SHARE" then
holders[#holders + 1] = index
end
end
local halved = #holders > 0
local function vanillaAward()
self:giveExperiencePass(loser, def, participants, #participants, halved)
if halved then
self:giveExperiencePass(loser, def, holders, #holders, true)
end
end
-- battle.exp_award, the same hook BattleState:awardExp calls on Gen 1 and
-- with the same ctx: the participant COUNT, the live participants, and an
-- applyShare(mon, split) a mod can call to pay one mon its own share. The
-- third applyShare argument is Gen 1's EXP.ALL announcement variant; Gen 2
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
-- accepted and ignored rather than changing what is printed. `recipients`,
-- `holders` and `halved` are the Gen 2 additions.
if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end
local alive = {}
for _, index in ipairs(participants) do
local mon = self.party[index]
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
end
local function applyShare(mon, split)
for index, candidate in ipairs(self.party) do
if candidate == mon then
return self:giveExperiencePass(loser, def, { index },
math.max(1, split or 1), halved)
end
end
end
Runtime.call("battle.exp_award", vanillaAward, {
battle = self, participants = #participants, alive = alive,
applyShare = applyShare, recipients = participants, holders = holders,
halved = halved, loser = loser,
})
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
-- pending one there, then queue the cart's "forgot X / learned Y" lines. The
-- battle slot aliases the party slot the same way Mimic does, so a mon in play
-- picks up the new move immediately.
function Battle:resolveForget(index, slot, entry, moveName)
local mon = self.party[index]
if not (mon and mon.moves and mon.moves[slot] and entry) then return false end
local old = mon.moves[slot]
local oldDef = self:moveDef(old.id)
local oldName = (oldDef and oldDef.name) or old.id
mon.moves[slot] = entry
-- Keep the in-play battler's move list pointing at the same table, so a mon
-- that levelled mid-battle fights the rest of it with the new move.
if self.player == mon and self.player.moves ~= mon.moves then
self.player.moves = mon.moves
end
self:emit({ kind = "message",
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
self:emit({ kind = "message",
text = self:monName(mon) .. " learned "
.. (moveName or (entry and entry.id) or "?") .. "!" })
-- The forget path writes the slot itself rather than going through
-- Mon.learnMove, so pokemon.move_learned is raised here too: a move WAS
-- learned, and a mod counting moves must not miss the four-slot case.
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = entry.id })
return true
end
-- The other answer: keep the four it has. MoveDidntLearn's line.
function Battle:declineForget(index, moveName)
local mon = self.party[index]
self:emit({ kind = "message",
text = (mon and self:monName(mon) or "It") .. " did not learn "
.. (moveName or "the move") .. "." })
end
-- NewBattleMonStatus / the enemy switch tail (core.asm:3864 and 3405): ANY
-- send-out ends BOTH partial traps and drops the CANT_RUN pin that was aimed
-- at the incoming side -- whose holder is the opponent, so it is the
-- opponent's volatile that carries it.
function Battle:breakTrapsOnSend(incoming)
for _, mon in ipairs({ self.player, self.enemy }) do
local state = self:volatile(mon)
state.wrapCount, state.wrapMove, state.wrapMoveId = nil, nil, nil
end
local opponent = (incoming == self.player) and self.enemy or self.player
self:volatile(opponent).trapsTarget = nil
end
-- TryPlayerSwitch's `.check_trapped` (core.asm:4886): a live wrap on the
-- active mon or the enemy's CANT_RUN pin refuses a VOLUNTARY switch with
-- "can't be recalled!". The faint replacement path never asks.
function Battle:switchLocked()
if (self:volatile(self.player).wrapCount or 0) > 0 then return true end
return self:volatile(self.enemy).trapsTarget == true
end
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
-- (engine/battle/core.asm:2959-2961).
function Battle:shiftSwitch(index)
self.participants = {}
return self:switch(index)
end
-- Switch the player's active mon. A switch takes the whole turn.
function Battle:switch(index)
local mon = self.party[index]
if not mon or (mon.hp or 0) <= 0 then return false end
if mon == self.player then return false end
-- Switching out drops every volatile: the Substitute, the charge, the
-- Rollout ramp and the stat stages all go with it. The incoming mon starts
-- from an empty area too (NewBattleMonStatus runs at every send-out), so a
-- mon that comes back in carries nothing from its last stint.
local previous = self.player
self:clearVolatile(self.player)
self:clearVolatile(mon)
-- A mon that comes back (a REVIVE, or a second battle) has to be able to
-- announce its own faint again; see resolveFaints.
self.faintAnnounced = nil
-- ForcePlayerMonChoice has been answered, so the next faint may ask again.
self.pendingSwitch = nil
self.player = mon
self.playerIndex = index
self.participants[index] = true
self.stages.player = Battle.newStages()
self:emit({ kind = "send", side = "player", mon = mon,
text = "Go! " .. self:monName(mon) .. "!" })
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
-- Gen 1: the side record, whoever walked in, and whoever walked out.
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(mon), battler = mon,
previous = previous,
})
self:breakTrapsOnSend(mon)
self:checkAmuletCoin(mon)
self:spikesDamage(mon)
return true
end
-- CheckAmuletCoin (engine/battle/core.asm), which sits in the send-out path
-- rather than in the payout: `ld a, [wBattleMonItem] / GetItemHeldEffect / cp
-- HELD_AMULET_COIN`, then a 1 into wAmuletCoin. Nothing clears the byte for
-- the rest of the battle, so a mon that was sent out holding the coin still
-- doubles the prize after it has fainted or been switched away.
function Battle:checkAmuletCoin(mon)
if mon and mon.item == Prize.AMULET_COIN then self.amuletCoin = true end
end
-- HandleBerserkGene (engine/battle/core.asm:301). Checked by ITEM id, not
-- held effect (`sub BERSERK_GENE` on the item byte; its attribute byte is
-- HELD_NONE on the cart). The gene is consumed, Attack jumps two stages
-- (BattleCommand_AttackUp2) and the holder is confused -- with no count
-- written on the cart, the near-permanent lock the walkthroughs warn about
-- (Battle.BERSERK_GENE_CONFUSE_TURNS).
function Battle:checkBerserkGene(mon)
if not mon or mon.item ~= "BERSERK_GENE" or (mon.hp or 0) <= 0 then
return false
end
local def = self:itemDef(mon.item)
mon.item = nil
self:emit({ kind = "message",
text = self:monName(mon) .. "'s "
.. ((def and def.name) or "BERSERK GENE") .. " activated!" })
self:changeStage(mon, "attack", 2)
self:applyConfusion(mon, Battle.BERSERK_GENE_CONFUSE_TURNS)
return true
end
-- BattleCommand_CheckObedience's badge ladder: the obedience cap by owned
-- Johto badges. MAX_LEVEL + 1 for RISINGBADGE means nothing ever disobeys.
function Battle:obedienceLevel()
if self:hasBadge("badges", "RISING") then return Mon.MAX_LEVEL + 1 end
if self:hasBadge("badges", "STORM") then return 70 end
if self:hasBadge("badges", "FOG") then return 50 end
if self:hasBadge("badges", "HIVE") then return 30 end
return 10
end
-- The cart's `.rand1` / `.rand2`: one byte, re-rolled until it lands under
-- `limit`. Guarded so an injected test roller that never goes low cannot
-- spin forever; the fallback fold keeps the result in range.
function Battle:rollBelow(limit)
for _ = 1, 128 do
local roll = rand(self.random, 256)
if roll < limit then return roll end
end
return rand(self.random, 256) % math.max(1, limit)
end
-- HitConfusion (engine/battle/effect_commands.asm:613): a typeless 40 power
-- physical hit against the user's OWN Defense -- stat stages and the badge
-- boosts apply through wPlayerStats, but there is no crit, no STAB, no type
-- row and no damage variation; DamageCalc's MIN_DAMAGE floor still holds.
-- Shared by the confusion self-hit and the disobedience self-hit.
function Battle:confusionSelfHit(mon)
local stages = self.stages[self:sideOf(mon)]
local attack = Damage.applyStage(self:battleStat(mon, "attack"),
stages.attack or 0)
attack = Battle.statusPenaltyFor(self.data, mon, "attack", attack)
local defense = Damage.applyStage(self:battleStat(mon, "defense"),
stages.defense or 0)
local damage = Damage.base(mon.level or 1, 40, attack, defense)
damage = math.min(damage, Damage.MAX_DAMAGE - Damage.MIN_DAMAGE)
+ Damage.MIN_DAMAGE
self:emit({ kind = "message",
text = "It hurt itself in its confusion!" })
mon.hp = math.max(0, (mon.hp or 0) - damage)
-- HitConfusion flickers with ANIM_HIT_CONFUSION on the self-hitter's own
-- turn, not the move after-anim (effect_commands.asm:624-632, :521-529).
self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage,
hp = mon.hp, anim = "ANIM_HIT_CONFUSION", animSide = self:sideOf(mon) })
return damage
end
-- BattleCommand_CheckObedience (engine/battle/effect_commands.asm:642).
-- Player side only; an outsider mon (OT id differs from the player's) above
-- the badge-gated level cap rolls to obey. Returns true when the mon
-- disobeyed and the turn is spent.
--
-- The outcome ladder, in the asm's order: a first roll under the cap obeys;
-- a second roll under the cap uses a DIFFERENT move instead; past both, the
-- margin above the cap decides between napping, hitting itself and one of
-- the four loafing lines.
function Battle:checkObedience(moveId)
local mon = self.player
if not mon then return false end
-- CheckUserIsCharging: the stored half of a two-turn move is exempt.
if self:volatile(mon).chargeMove then return false end
local save = self.save
local playerId = save and save.player and save.player.id
if mon.otId == nil or playerId == nil or mon.otId == playerId then
return false
end
local cap = self:obedienceLevel()
local level = mon.level or 1
if level <= cap then return false end
local limit = math.min(255, cap + level)
if self:rollBelow(limit) < cap then return false end
local name = self:monName(mon)
if self:rollBelow(limit) < cap then
-- `.UseInstead`: another known move with PP, never the picked one and
-- never a disabled one; with no alternative it falls through to
-- loafing.
local others = {}
for _, move in ipairs(mon.moves or {}) do
if move.id ~= moveId and (move.pp or 0) > 0
and not self:moveDisabled(mon, move.id) then
others[#others + 1] = move.id
end
end
if #others > 0 then
local pick = others[rand(self.random, #others) + 1]
self:useMove(mon, self.enemy, pick)
return true
end
end
local margin = level - cap
local roll = rand(self.random, 256)
if roll < margin then
-- `.Nap`: 1-7 turns of sleep written STRAIGHT into the status byte,
-- over whatever was there.
mon.status = "sleep"
mon.statusTurns = rand(self.random, 7) + 1
mon.toxicCounter = nil
self:emit({ kind = "status", side = self:sideOf(mon), status = "sleep",
text = name .. " began to nap!" })
return true
end
if roll - margin < margin then
self:emit({ kind = "message", text = name .. " won't obey!" })
self:confusionSelfHit(mon)
return true
end
-- `.DoNothing`: one of four lines.
local lines = {
" is loafing around.", " won't obey!", " turned away!",
" ignored orders!",
}
self:emit({ kind = "message",
text = name .. lines[rand(self.random, 4) + 1] })
return true
end
-- The battle half of the PACK's battle items, dispatched by the screen
-- (src/ui/gen2/BattleState.lua): the four X items raise one stage
-- (XItemEffect -> RaiseStat), and X ACCURACY / DIRE HIT / GUARD SPEC set
-- their SUBSTATUS bit, refusing a second use the way
-- WontHaveAnyEffect_NotUsedMessage does -- in that case the item is NOT
-- consumed and the turn not spent, which the false return tells the caller.
function Battle:useBattleItem(itemId)
local stat = Battle.X_ITEM_STATS[itemId]
if stat then
local def = self:itemDef(itemId)
self:emit({ kind = "message",
text = "Used the " .. ((def and def.name) or itemId) .. "." })
self:changeStage(self.player, stat, 1)
return true
end
local field = Battle.SUBSTATUS_ITEMS[itemId]
if not field then return false, "unknown" end
local state = self:volatile(self.player)
if state[field] then return false, "no-effect" end
state[field] = true
local def = self:itemDef(itemId)
self:emit({ kind = "message",
text = "Used the " .. ((def and def.name) or itemId) .. "." })
if itemId == "GUARD_SPEC" then
self:emit({ kind = "message",
text = self:monName(self.player) .. "'s shrouded in MIST!" })
elseif itemId == "DIRE_HIT" then
self:emit({ kind = "message",
text = self:monName(self.player) .. " is getting pumped!" })
end
return true
end
-- SpikesDamage (engine/battle/core.asm): an eighth of max HP the moment a mon
-- walks into them. Gen 2 has one layer -- the stacking is Gen 3 -- but it
-- does have the Flying immunity: the routine reads wBattleMonType / the enemy
-- pair and `cp FLYING / ret z` on BOTH slots before GetEighthMaxHP, so a
-- Flying-type takes nothing and the line is not printed either.
function Battle:spikesDamage(mon)
local side = self:sideOf(mon)
if not self.spikes[side] or (mon.hp or 0) <= 0 then return end
local def = self:speciesDef(mon)
for _, monType in ipairs((def and def.types) or mon.types or {}) do
if monType == "FLYING" then return end
end
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 8
local damage = math.max(1, math.floor(maxHp / 8))
mon.hp = math.max(0, mon.hp - damage)
self:emit({ kind = "message",
text = self:monName(mon) .. " is hurt by SPIKES!" })
-- SpikesDamage is text, HP and a HUD redraw: no anim (core.asm:3902-3910).
self:emit({ kind = "damage", side = side, amount = damage, hp = mon.hp,
anim = false })
end
-- CheckPlayerLockedIn (engine/battle/core.asm:533-556) quits ParsePlayerAction
-- outright for SUBSTATUS_ROLLOUT and SUBSTATUS_RAMPAGE, so a mon partway
-- through a Rollout or a Thrash is offered no menu, spends no PP (both
-- checkrollout and checkrampage skip past doturn) and makes no obedience
-- check. Split out because playerAttack needs the same answer.
function Battle:lockedInMove(mon)
local state = self:volatile(mon)
if state.rolloutLock then return state.rolloutLock end
if state.rampageMove and (state.rampageTurns or 0) > 0 then
return state.rampageMove
end
return nil
end
-- Encore forces the move; Disable forbids one. Both are read by the screen
-- (to grey out the move list) and by the enemy's own choice below.
function Battle:forcedMove(mon)
local locked = self:lockedInMove(mon)
if locked then return locked end
local state = self:volatile(mon)
if not state.encore then return nil end
for _, move in ipairs(mon.moves or {}) do
if move.id == state.encore and (move.pp or 0) > 0 then return state.encore end
end
-- Encore ends early when the move runs out of PP.
state.encore, state.encoreTurns = nil, nil
return nil
end
function Battle:moveDisabled(mon, moveId)
return self:volatile(mon).disabled == moveId
end
-- The moves a side may actually pick this turn.
function Battle:usableMoves(mon)
local forced = self:forcedMove(mon)
-- CheckPlayerLockedIn quits ParsePlayerAction ahead of
-- .CheckPlayerHasUsableMoves (core.asm:533-556), so a Rollout or a rampage
-- that spent its last PP on the opening turn keeps running: no later turn
-- of the lock spends any. Encore is not in this exemption -- forcedMove
-- ends it the moment the encored move runs dry.
local locked = self:lockedInMove(mon)
local out = {}
for _, move in ipairs(mon.moves or {}) do
local ok = (move.pp or 0) > 0 and not self:moveDisabled(mon, move.id)
if move.id == locked then ok = true end
if forced then ok = ok and move.id == forced end
if ok then out[#out + 1] = move end
end
return out
end
-- Running: Gen 2's odds (engine/battle/core.asm TryToRunAwayFromBattle) are
-- based on the speed ratio and how many times you have tried this battle.
-- Trainers never let you run.
function Battle:tryRun(pSpd)
-- .cant_escape and .cant_run_from_trainer leave wBattlePlayerAction alone,
-- which is what BattleMenu_Run reads to decide whether the turn was spent
-- (engine/battle/core.asm:5035); only .cant_escape_2, the failed roll at the
-- bottom, writes BATTLEPLAYERACTION_USEITEM and buys the enemy a move.
self.runRefused = nil
-- The battle-type ladder runs FIRST: BATTLETYPE_TRAP and
-- BATTLETYPE_FORCESHINY jump straight to .cant_escape, ahead of the
-- trainer check and any speed math. Without this, running from the Red
-- Gyarados returned a WIN to the script and forfeited the one-shot shiny.
if self.battleType == Battle.BATTLETYPE_FORCESHINY
or self.battleType == Battle.BATTLETYPE_TRAP then
self:emit({ kind = "message", text = "Can't escape!" })
self.runRefused = true
return false
end
if self.trainer then
self:emit({ kind = "message", text = "No! There's no running from a "
.. "trainer battle!" })
self.runRefused = true
return false
end
-- SUBSTATUS_CANT_RUN held by the ENEMY (its Mean Look pinned the player)
-- and a live wrap count on the player both refuse before the speed math
-- and before the attempt is even counted.
if self:volatile(self.enemy).trapsTarget
or (self:volatile(self.player).wrapCount or 0) > 0 then
self:emit({ kind = "message", text = "Can't escape!" })
self.runRefused = true
return false
end
self.runAttempts = (self.runAttempts or 0) + 1
-- engine/battle/core.asm:2614
if self:runRoll(pSpd or self:effectiveSpeed(self.player),
self:effectiveSpeed(self.enemy)) then
self:emit({ kind = "run", text = "Got away safely!" })
self:endBattle("run")
return true
end
self:emit({ kind = "message", text = "Can't escape!" })
return false
end
-- The escape roll itself, hooked as battle.run -- the same hook
-- BattleState:runRoll calls on Gen 1, with the same ctx keys (pSpd, eSpd,
-- attempts, rng) and the same boolean return. The attempt has already been
-- counted by the caller, exactly as it is on Gen 1, so a mod that refuses the
-- escape still leaves the count raised.
function Battle:runRoll(pSpd, eSpd)
if Runtime.wantsHook("battle.run") then
return Runtime.call("battle.run", function(c)
return c.battle:runRollVanilla(c.pSpd, c.eSpd)
end, { battle = self, pSpd = pSpd, eSpd = eSpd,
attempts = self.runAttempts, rng = self:roller(),
random = self.random })
end
return self:runRollVanilla(pSpd, eSpd)
end
function Battle:runRollVanilla(pSpd, eSpd)
if pSpd >= eSpd then return true end
-- (playerSpeed * 32 / (enemySpeed / 4)) + 30 * attempts, out of 256.
local odds = math.floor(pSpd * 32
/ math.max(1, math.floor(eSpd / 4))) + 30 * (self.runAttempts or 1)
return odds >= 256 or rand(self.random, 256) < odds
end
-- TryEnemyFlee (engine/battle/core.asm), called at the head of the enemy's
-- half of the turn in BOTH orders (Battle_EnemyFirst runs it first thing,
-- Battle_PlayerFirst runs it once the player's move has resolved).
--
-- The gates, in the asm's order:
-- * trainer battles never flee (`ld a, [wBattleMode] / dec a / jr nz`)
-- * SUBSTATUS_CANT_RUN on the PLAYER (Mean Look, Spider Web) pins it
-- * a live wrap count pins it
-- * frozen or asleep pins it
-- * AlwaysFleeMons -> gone, no roll at all. Raikou, Entei and Suicune are
-- that whole list, which is why a beast gets exactly one turn of yours
-- * otherwise one random byte: under 50 percent + 1 lets OftenFleeMons go,
-- and under 10 percent + 1 lets SometimesFleeMons go. ONE byte for both
-- gates, so the two lists are not independent rolls
--
-- `percent` is `* $ff / 100` (macros/data.asm), so those two thresholds are
-- 128 and 26 rather than 128 and 26-ish: 50*255/100 = 127, +1; 10*255/100 =
-- 25, +1.
--
Battle.OFTEN_FLEE_ROLL = 128 -- 50 percent + 1
Battle.SOMETIMES_FLEE_ROLL = 26 -- 10 percent + 1
function Battle:tryEnemyFlee()
if not self.wild then return false end
-- SUBSTATUS_CANT_RUN on the player's side (its Mean Look holds the wild
-- mon) and a live wrap count on the enemy pin it BEFORE the status check
-- -- the pin that makes a roamer catchable at full HP.
if self:volatile(self.player).trapsTarget then return false end
if (self:volatile(self.enemy).wrapCount or 0) > 0 then return false end
local status = self.enemy.status
if status == "freeze" or status == "sleep" then return false end
local species = self.enemy.species
if Roamers.ALWAYS_FLEE[species] then return self:enemyFled() end
local roll = rand(self.random, 256)
if roll >= Battle.OFTEN_FLEE_ROLL then return false end
if Roamers.OFTEN_FLEE[species] then return self:enemyFled() end
if roll >= Battle.SOMETIMES_FLEE_ROLL then return false end
if Roamers.SOMETIMES_FLEE[species] then return self:enemyFled() end
return false
end
-- WildFled_EnemyFled_LinkBattleCanceled. The result it writes is DRAW, the
-- same value the player's own successful run writes, which is what makes
-- BattleEnd_HandleRoamMons bank the beast's HP instead of clearing its slot.
-- The port's outcome name is "fled" so a caller can tell the two apart, and
-- Evolution.runsAfterBattle already treats anything that is not a loss or a
-- draw-by-forfeit as evolvable.
function Battle:enemyFled()
self:endBattle("fled")
self:emit({ kind = "run", side = "enemy",
text = "Wild " .. self:monName(self.enemy) .. " fled!" })
return true
end
-- The AI's move. A trainer class's TRNATTR_AI_MOVE_WEIGHTS decides which
-- scoring layers run (src/battle/gen2/Ai.lua); a wild mon -- and a class with
-- no flags -- picks at random from what it knows, which is what AIChooseMove
-- does when wEnemyTrainerAIFlags is zero.
-- engine/battle/ai/items.asm AI_SwitchOrTryItem. Wild mons never do either;
-- a trainer's class decides how eager it is. Returns true when the turn was
-- spent on the switch or the item.
function Battle:enemyTrySwitchOrItem()
if self.wild or not self.trainer then return false end
local attributes = self.trainer.attributes
if type(attributes) ~= "table" then return false end
-- The AI cannot rotate out of a trap either: a live wrap count on its
-- active mon or the player's CANT_RUN pin close the switch branch the way
-- they close TryPlayerSwitch, leaving only the item check.
local trapped = (self:volatile(self.enemy).wrapCount or 0) > 0
or self:volatile(self.player).trapsTarget == true
-- CheckAbleToSwitch, then the class's own probability.
local bench = {}
local playerTypes = (self:speciesDef(self.player) or {}).types
or self.player.types or {}
for index, mon in ipairs(self.enemyParty) do
if index ~= self.enemyIndex and (mon.hp or 0) > 0 then
local def = self:speciesDef(mon)
local types = (def and def.types) or mon.types or {}
-- "Resists" is the player's own type against the bench mon, which is
-- what FindEnemyMonsThatResistPlayer measures.
local incoming = Damage.typeMultiplier(playerTypes[1], types,
self.data.type_chart and self.data.type_chart.matchups)
local super_ = false
for _, move in ipairs(mon.moves or {}) do
local moveDef = self:moveDef(move.id)
if moveDef and (moveDef.power or 0) > 0 then
local mult = Damage.typeMultiplier(moveDef.type, playerTypes,
self.data.type_chart and self.data.type_chart.matchups)
if (mult or 10) > 10 then super_ = true end
end
end
bench[#bench + 1] = { index = index, mon = mon, healthy = true,
resists = (incoming or 10) < 10, superEffective = super_ }
end
end
-- CheckPlayerMoveTypeMatchups: below BASE_AI_SWITCH_SCORE means the player's
-- moves are beating what is out. Battle:playerMatchupScore owns that loop so
-- this layer and AI_Smart's four readers of it cannot disagree.
local score, target = Ai.switchScore({
bench = bench,
perishCount = self:volatile(self.enemy).perish,
matchupScore = self:playerMatchupScore(),
})
if not trapped and target
and Ai.shouldSwitch(attributes, score, self.random) then
self:clearVolatile(self.enemy)
-- AI_Switch prints EnemyWithdrewText BEFORE it farcalls EnemySwitch
-- (engine/battle/ai/items.asm:685), so a rotation announces the mon
-- coming OFF the field as well as the one coming on; without it a
-- trainer swapping between two of the same species looked like nothing
-- had happened. The line is skipped only when Pursuit hit the mon on
-- its way out, which this port has no analogue for yet.
local outgoing = self.enemy
self:emit({ kind = "message",
text = (self.trainer.name or "TRAINER") .. " withdrew "
.. self:monName(outgoing) .. "!" })
self.enemyIndex = target
self.enemy = self.enemyParty[target]
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears
-- Battle:switch makes for the player's side.
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
text = (self.trainer.name or "TRAINER") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = outgoing,
})
self:breakTrapsOnSend(self.enemy)
self:spikesDamage(self.enemy)
return true
end
-- AI_TryItem: only the trainer's highest-level mon is worth an item.
local highest = 0
for _, mon in ipairs(self.enemyParty) do
highest = math.max(highest, mon.level or 0)
end
local item = Ai.chooseItem({
items = self.trainer.items,
isHighestLevel = (self.enemy.level or 0) >= highest,
hp = self.enemy.hp,
maxHp = self.enemy.maxHp or (self.enemy.stats or {}).hp,
status = self.enemy.status,
enemyTurns = self:volatile(self.enemy).turnsTaken or 0,
})
if not item then return false end
-- Consume it, so a trainer with one Potion cannot drink it every turn.
for index, id in ipairs(self.trainer.items or {}) do
if id == item then table.remove(self.trainer.items, index) break end
end
local heal = Ai.HEAL_ITEMS[item]
if heal then
self:heal(self.enemy, heal == math.huge
and (self.enemy.maxHp or (self.enemy.stats or {}).hp or 1) or heal)
if item == "FULL_RESTORE" then
self.enemy.status = nil
self:volatile(self.enemy).confuseCount = nil
end
elseif item == "FULL_HEAL" then
self.enemy.status = nil
self:volatile(self.enemy).confuseCount = nil
end
self:emit({ kind = "message", text = (self.trainer.name or "TRAINER")
.. " used " .. item .. "!" })
return true
end
-- battle.enemy_action, the same hook BattleState:enemyAction calls on Gen 1:
-- the whole choke point is wrapped, so a mod can rewrite any trainer's choice
-- without registering a brain. Gen 1's chain returns an ACTION table and
-- Gen 2's engine speaks in bare move ids, so a table with an `id` (or a
-- `move`) is unwrapped rather than refused -- which is what lets one mod
-- source answer this hook on both generations.
function Battle:enemyMove()
if Runtime.wantsHook("battle.enemy_action") then
local chosen = Runtime.call("battle.enemy_action", function(battle)
return Battle.vanillaEnemyMove(battle)
end, self)
if type(chosen) == "table" then return chosen.id or chosen.move end
return chosen
end
-- Called through the module rather than the metatable: the charge-lock test
-- drives this against a bare stub table, which is also how the lock is
-- proved to read nothing but the volatile.
return Battle.vanillaEnemyMove(self)
end
function Battle:vanillaEnemyMove()
-- A mon halfway through a two-turn move does not get to choose again.
--
-- On the cart the charge sets SUBSTATUS_CHARGED and `CheckEnemyTurn` reuses
-- wEnemySelectedMove; the AI is never consulted for the second turn. Without
-- that lock the AI picks freely, which skips the stored attack AND -- because
-- `vanished` is only cleared by the branch in useMove that recognises the
-- second half -- leaves the mon semi-invulnerable **for the rest of the
-- battle**.
--
-- Found by the Gold route bot: ELITE FOUR BRUNO's HITMONLEE opens with DIG,
-- then attacks with HI JUMP KICK from underground forever. Every incoming
-- move answers "TYPHLOSION's attack missed!", so Hitmonlee cannot be damaged
-- by anything, at any level. Fifteen straight attempts at the Elite Four
-- died there, and no amount of grinding could ever have got past it.
local charged = self:volatile(self.enemy).chargeMove
if charged then return charged end
-- Encore and Disable narrow the pool before the AI ever scores it.
local moves = self:usableMoves(self.enemy)
if #moves == 0 then
-- `.not_linked`'s encore arm runs ahead of the disable scan
-- (engine/battle/core.asm:5524-5529).
local forced = self:forcedMove(self.enemy)
if forced then return forced end
-- `.disabled` walks off the end into `.struggle` (:5555-5560).
return nil
end
local flags = Ai.flagsOf(self.trainer and self.trainer.attributes)
if flags == 0 then
return moves[rand(self.random, #moves) + 1].id
end
local chosen = Ai.choose({
moves = moves,
moveDef = function(id) return self:moveDef(id) end,
attacker = {
level = self.enemy.level,
stats = self.enemy.stats,
types = (self:speciesDef(self.enemy) or {}).types or self.enemy.types,
},
defender = {
hp = self.player.hp,
stats = self.player.stats,
status = self.player.status,
-- AI_Basic reads SUBSTATUS_CONFUSED for the confusion moves, not the
-- status byte.
confused = self:volatile(self.player).confuseCount ~= nil,
types = (self:speciesDef(self.player) or {}).types or self.player.types,
},
typeChart = self.data.type_chart,
-- The dataset itself, which is where Ai.layersFor reads the merged
-- `ai_classes` records from (data.gen2AiClasses). Without it the module's
-- own ten layers answer, which is the same behaviour a boot with no loader
-- has always had.
data = self.data,
-- Everything the SETUP / OPPORTUNIST / CAUTIOUS / SMART layers read.
enemyHp = self.enemy.hp,
enemyMaxHp = self.enemy.maxHp or (self.enemy.stats or {}).hp,
enemyTurns = self:volatile(self.enemy).turnsTaken or 0,
playerTurns = self:volatile(self.player).turnsTaken or 0,
smart = self:smartAiState(),
-- wLastPlayerCounterMove's base power, which AI_Smart_Encore and
-- AI_Smart_MirrorCoat both take as their fifth argument.
playerLastPower = (function()
local last = self:volatile(self.player).lastMove
local def = last and self:moveDef(last)
return def and def.power or nil
end)(),
attackerStages = self.stages.enemy,
defenderStages = self.stages.player,
flags = flags,
random = function(n) return rand(self.random, n) end,
})
return chosen or moves[1].id
end
-- Run one turn. `action` is:
-- { kind = "move", move = <id> }
-- { kind = "switch", index = n }
-- { kind = "run" }
-- { kind = "item", item = <id>, target = n } (handled by the caller, which
-- applies the effect and then calls this with kind = "item" so the enemy
-- still gets its turn)
local function runTurn(self, action)
if self.over then return self:takeEvents() end
self.turn = self.turn + 1
action = action or { kind = "move" }
-- HandleBerserkGene sits at the top of BattleTurn's loop
-- (engine/battle/core.asm:160), player first then enemy, so a holder
-- fires on its first turn out whether it started the battle or switched
-- in. Consuming the item is what keeps it one-shot.
self:checkBerserkGene(self.player)
self:checkBerserkGene(self.enemy)
-- Counter and Mirror Coat answer damage taken *this* turn, so the tally
-- starts empty (BattleCommand_Counter reads wCurDamage, which the turn
-- clears).
self:volatile(self.player).tookThisTurn = nil
self:volatile(self.enemy).tookThisTurn = nil
-- Set by resolveFaints; per-round, so it can never leak into the next one.
self.faintInterrupt = nil
if action.kind == "run" then
if self:tryRun() then return self:takeEvents() end
-- Only the failed ROLL costs the turn. .cant_escape_2 writes
-- BATTLEPLAYERACTION_USEITEM before printing its line, so BattleMenu_Run's
-- `ld a, [wBattlePlayerAction] / and a / ret nz` lets the round proceed;
-- .cant_escape and .cant_run_from_trainer leave the action at
-- BATTLEPLAYERACTION_USEMOVE and fall into `jp BattleMenu`
-- (engine/battle/core.asm:5035-5038), which reopens the 2x2 menu with the
-- turn unspent -- so a refused RUN never bought the enemy a free attack.
if self.runRefused then return self:takeEvents() end
action = { kind = "skip" }
end
if action.kind == "switch" then
self:switch(action.index)
action = { kind = "skip" }
end
-- XItemEffect's tail: the four X items award HAPPINESS_USEDXITEM to
-- wCurBattleMon, i.e. whoever is out, not whoever the PACK was pointed at.
-- The caller applies the item's own effect and then hands the turn here, so
-- this is where the award lands.
if action.kind == "item" and Battle.X_ITEMS[action.item] then
Happiness.change(self.player, "USEDXITEM")
end
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
-- to rotate or drink a potion spends its whole turn on it.
local enemyActed = self:enemyTrySwitchOrItem()
local enemyMoveId = (not enemyActed) and self:enemyMove() or nil
-- battle.turn_started, where BattleState:resolveTurn raises it on Gen 1:
-- once both sides have chosen and before either acts. Gen 1's action tables
-- key their move as `id` and Gen 2's as `move`, so the payload's copies carry
-- both spellings rather than making a mod know which engine it is in.
if Runtime.wants("battle.turn_started") then
Runtime.emit("battle.turn_started", {
battle = self, turn = self.turn,
playerAction = { kind = action.kind, id = action.move,
move = action.move, index = action.index,
item = action.item },
enemyAction = enemyMoveId
and { kind = "move", id = enemyMoveId, move = enemyMoveId } or nil,
})
end
self.turnOpen = true
-- A switch or item always resolves before the enemy's move; otherwise Speed
-- decides.
local playerFirst
if action.kind == "skip" or action.kind == "item" then
playerFirst = true
elseif Runtime.wantsHook("battle.turn_order") then
-- battle.turn_order, the same hook BattleState:resolveTurn calls on Gen 1
-- and with the same five arguments: both battlers, both move records, and
-- a ctx carrying the rng. Gen 2's ordering reads move IDS rather than
-- records (priority comes off the id), so the ids are in the ctx as
-- playerMove / enemyMove and that is what vanilla resolves.
playerFirst = Runtime.call("battle.turn_order", function(_, _, _, _, c)
return c.battle:orderOf(c.playerMove, c.enemyMove) == "player"
end, self.player, action.move and self:moveDef(action.move) or nil,
self.enemy, enemyMoveId and self:moveDef(enemyMoveId) or nil,
{ battle = self, rng = self:roller(), random = self.random,
playerMove = action.move, enemyMove = enemyMoveId }) and true or false
else
playerFirst = self:orderOf(action.move, enemyMoveId) == "player"
end
-- wEnemyGoesFirst, which BattleCommand_ForceSwitch's two trainer arms
-- read: Roar and Whirlwind only work for a user moving SECOND.
self.firstMover = playerFirst and "player" or "enemy"
local function playerAttack()
if action.kind ~= "move" then return end
if not self:canAct(self.player) then return end
local move = action.move
-- An encored mon has no choice, whatever the menu said.
local forced = self:forcedMove(self.player)
if forced then move = forced end
-- .CheckPlayerHasUsableMoves: a mon with nothing left to spend attacks
-- with STRUGGLE rather than losing the turn. The menu can still hand us a
-- dry move (nothing stops the player picking one), so the substitution is
-- made here, where the cart makes it, rather than in the menu.
--
-- The second half of a two-turn move is exempt: it spends no PP and makes
-- no new choice, so a mon that went dry while charging still lands the
-- attack it stored.
--
-- The lock itself, same as the enemy's in Battle:enemyMove: whatever the
-- menu handed us, a mon with a stored charge move uses THAT. Otherwise
-- the stored attack is skipped and `vanished` is never cleared, and the
-- player's own mon spends the rest of the battle underground.
local stored = self:volatile(self.player).chargeMove
if stored then move = stored end
-- CheckPlayerLockedIn quits before .CheckPlayerHasUsableMoves and before
-- checkobedience, so a locked Rollout or Thrash is exempt from the
-- Struggle substitution and the obedience roll the same way the second
-- half of a charge move is.
local charging = self:volatile(self.player).chargeMove == move
or self:lockedInMove(self.player) == move
if not charging and not self:hasUsableMoves(self.player) then
self:emit({ kind = "message",
text = self:monName(self.player) .. " has no moves left!" })
move = Battle.STRUGGLE
end
-- CheckPlayerTurn's disabled arm spends the turn, whatever was selected
-- (engine/battle/effect_commands.asm:314-326).
if self:moveDisabled(self.player, move) then
-- MoveDisabled fails the stored charge (:599-603) and CantMove brings a
-- vanished FLY/DIG user back (:364-368).
local state = self:volatile(self.player)
state.chargeMove, state.vanished = nil, nil
self:emit({ kind = "message",
text = self:monName(self.player) .. "'s " .. move .. " is DISABLED!" })
return
end
-- BattleCommand_CheckObedience runs at the head of the move's effect
-- list, after the status gates and before PP is spent; the second half
-- of a charge move is exempt (CheckUserIsCharging).
if not charging and self:checkObedience(move) then return end
self:useMove(self.player, self.enemy, move)
end
local function enemyAttack()
if enemyActed then return end
if (self.enemy.hp or 0) <= 0 then return end
-- TryEnemyFlee sits here in both of the cart's turn orders, ahead of the
-- enemy's move and behind the faint checks.
if self:tryEnemyFlee() then return end
if not enemyMoveId then
-- `.struggle` (engine/battle/core.asm:5630-5632) sets STRUGGLE and
-- finishes silently: BattleText_MonHasNoMovesLeft is text_ram
-- wBattleMonNickname (data/text/battle.asm:325-329) and only
-- .force_struggle (core.asm:5311-5317) ever prints it. Returning here
-- instead -- which is what this did -- left a dry enemy unable to act at
-- all, so a battle where both sides had run out could never end and,
-- against a trainer, could not be escaped either.
enemyMoveId = Battle.STRUGGLE
end
if not self:canAct(self.enemy) then return end
-- CheckEnemyTurn's disabled arm (engine/battle/effect_commands.asm:562-574):
-- the AI chose before the player's Disable landed, so the turn is spent here.
if self:moveDisabled(self.enemy, enemyMoveId) then
local state = self:volatile(self.enemy)
state.chargeMove, state.vanished = nil, nil
self:emit({ kind = "message",
text = self:monName(self.enemy) .. "'s " .. enemyMoveId .. " is DISABLED!" })
return
end
self:useMove(self.enemy, self.player, enemyMoveId)
end
if playerFirst then
playerAttack()
-- A wild Roar or Whirlwind ends the battle from THIS half of the turn the
-- same way a flee ends it from the other: `.wild_force_flee` writes DRAW
-- into wBattleResult and the turn loop's `.quit` takes the round with it,
-- so the mon that was blown away never answers.
if self.over then return self:takeEvents() end
if self:resolveFaints() then return self:takeEvents() end
-- A faint ends the attack phase. Battle_PlayerFirst reaches both faint
-- handlers with `jp`, not `call` (engine/battle/core.asm:871-874), so the
-- enemy's half of the round is never run: the replacement the trainer
-- just sent out does not attack on the turn it walked in, and the move
-- picked for the mon it replaced is dropped rather than fired by whoever
-- happens to be standing there now. The end-of-turn block below still
-- runs -- HandleEnemyMonFaint returns into BattleTurn's `.proceed`, which
-- calls HandleBetweenTurnEffects (core.asm:196).
if self.faintInterrupt then
self.faintInterrupt = nil
elseif (self.player.hp or 0) > 0 then
enemyAttack()
end
else
enemyAttack()
-- A flee ends the battle where it stands: the cart jumps straight to
-- WildFled_EnemyFled_LinkBattleCanceled and never reaches the player's
-- half of the turn or the residual damage.
if self.over then return self:takeEvents() end
if self:resolveFaints() then return self:takeEvents() end
-- Same `jp` (core.asm:834-837): a mon that fainted to the enemy's move
-- takes the rest of the attack phase with it.
if self.faintInterrupt then
self.faintInterrupt = nil
elseif (self.player.hp or 0) > 0 then
playerAttack()
end
end
if self.over then return self:takeEvents() end
if self:resolveFaints() then return self:takeEvents() end
self.faintInterrupt = nil
-- A successful Roar or Whirlwind ends the ROUND: the turn loop's `.quit`
-- on wForcedSwitch skips HandleBetweenTurnEffects, so nothing ticks on
-- the turn a mon was dragged out.
if self.forcedSwitch then
self.forcedSwitch = nil
return self:takeEvents()
end
-- End of turn, in the cart's own order (HandleWeather runs before the
-- residual damage, and the counters that end a mon come last):
-- weather, then status chip and the Leech Seed / Curse residuals, then
-- the wrap ticks, then held items, then Future Sight and Perish Song,
-- then the screens and the per-turn counters.
self:tickWeather()
self:tickStatus(self.player)
self:tickSeedAndCurse(self.player)
self:tickStatus(self.enemy)
self:tickSeedAndCurse(self.enemy)
self:tickWrap(self.player)
self:tickWrap(self.enemy)
self:tickHeldItem(self.player)
self:tickHeldItem(self.enemy)
self:tickFutureSight(self.player)
self:tickFutureSight(self.enemy)
self:tickPerish(self.player)
self:tickPerish(self.enemy)
self:tickScreens()
self:tickCounters(self.player)
self:tickCounters(self.enemy)
self:resolveFaints()
return self:takeEvents()
end
-- battle.turn_ended closes the round battle.turn_started opened, whichever of
-- runTurn's exits was taken -- a faint, a flee, a forced switch or the ordinary
-- residual sweep. A round that never happened (a refused RUN, a battle that
-- was already over) opened nothing and so closes nothing, which is what keeps
-- the two events paired the way Gen 1's endOfTurn keeps them.
function Battle:takeTurn(action)
local events = runTurn(self, action)
if self.turnOpen then
self.turnOpen = nil
if Runtime.wants("battle.turn_ended") then
Runtime.emit("battle.turn_ended", { battle = self, turn = self.turn })
end
end
return events
end
-- HandleWeather: the count ticks down every turn and the weather ends the turn
-- it reaches zero. Sandstorm chips an eighth off everything that is not Rock,
-- Ground or Steel.
function Battle:tickWeather()
if not self.weather then return end
self.weatherTurns = self.weatherTurns - 1
if self.weatherTurns <= 0 then
self:emit({ kind = "weather", weather = nil,
text = Effects.WEATHER_END_TEXT[self.weather] })
self.weather = nil
return
end
self:emit({ kind = "message",
text = Effects.WEATHER_TURN_TEXT[self.weather] })
if self.weather ~= "sandstorm" then return end
for _, mon in ipairs({ self.player, self.enemy }) do
if (mon.hp or 0) > 0 and not self:volatile(mon).vanished then
local def = self:speciesDef(mon)
local types = (def and def.types) or mon.types
if Effects.sandstormHits(types) then
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 8
local damage = Effects.sandstormDamage(maxHp)
mon.hp = math.max(0, mon.hp - damage)
self:emit({ kind = "message",
text = self:monName(mon) .. " is buffeted by the sandstorm!" })
-- .SandstormDamage plays ANIM_IN_SANDSTORM between two SwitchTurnCore
-- calls, so it runs from the OTHER side (core.asm:1688-1693).
self:emit({ kind = "damage", side = self:sideOf(mon),
amount = damage, hp = mon.hp, anim = "ANIM_IN_SANDSTORM" })
end
end
end
end
-- BattleCommand_CheckFutureSight: the stored damage lands when the counter
-- reaches one, on whoever is standing on the target's side by then.
function Battle:tickFutureSight(mon)
local state = self:volatile(mon)
if not state.futureSight then return end
state.futureSight = state.futureSight - 1
if state.futureSight > 0 then return end
local target = state.futureSightSide == "player" and self.player or self.enemy
local damage = state.futureSightDamage or 1
state.futureSight, state.futureSightDamage, state.futureSightSide =
nil, nil, nil
if (target.hp or 0) <= 0 then return end
self:emit({ kind = "message", text = self:monName(target)
.. " took the FUTURE SIGHT attack!" })
self:dealDamage(mon, target, damage, {})
end
-- The perish count ticks at the end of every turn and the mon faints on zero.
function Battle:tickPerish(mon)
local state = self:volatile(mon)
if not state.perish or (mon.hp or 0) <= 0 then return end
state.perish = state.perish - 1
if state.perish > 0 then
self:emit({ kind = "message", text = self:monName(mon)
.. "'s PERISH count is " .. state.perish .. "!" })
return
end
state.perish = nil
mon.hp = 0
-- HandlePerishSong just zeroes both HP bytes (core.asm:1119-1135).
self:emit({ kind = "damage", side = self:sideOf(mon), amount = 0, hp = 0,
anim = false })
end
-- ResidualDamage's Leech Seed and Curse arms (engine/battle/core.asm:1010
-- and 1054): an eighth of the seeded mon's max HP crosses to whoever stands
-- on the OTHER side by now, then a quarter for the curse. Both run only
-- while the sufferer still stands, and both survive the trapper leaving --
-- the flags sit on the suffering mon itself.
function Battle:tickSeedAndCurse(mon)
local state = self:volatile(mon)
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 8
if state.leechSeed and (mon.hp or 0) > 0 then
local damage = math.min(math.max(1, math.floor(maxHp / 8)), mon.hp)
mon.hp = mon.hp - damage
self:emit({ kind = "message",
text = "LEECH SEED saps " .. self:monName(mon) .. "!" })
-- ANIM_SAP plays between two SwitchTurnCore calls, from the seeder's side
-- (core.asm:1013-1021).
self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage,
hp = mon.hp, anim = "ANIM_SAP" })
local other = mon == self.player and self.enemy or self.player
if (other.hp or 0) > 0 then self:heal(other, damage) end
end
if state.cursed and (mon.hp or 0) > 0 then
local damage = math.max(1, math.floor(maxHp / 4))
mon.hp = math.max(0, mon.hp - damage)
self:emit({ kind = "message",
text = self:monName(mon) .. "'s hurt by the CURSE!" })
-- The curse arm borrows ANIM_IN_NIGHTMARE, on the sufferer's own turn
-- (core.asm:1057-1060).
self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage,
hp = mon.hp, anim = "ANIM_IN_NIGHTMARE", animSide = self:sideOf(mon) })
end
end
-- HandleWrap (engine/battle/core.asm:1153): the count on the trapped mon
-- decrements FIRST -- release at zero, else a sixteenth of max HP. A
-- Substitute suspends the whole tick, count included.
function Battle:tickWrap(mon)
local state = self:volatile(mon)
if not state.wrapCount or (mon.hp or 0) <= 0 then return end
if (state.substitute or 0) > 0 then return end
state.wrapCount = state.wrapCount - 1
local moveName = state.wrapMove or "the trap"
if state.wrapCount <= 0 then
state.wrapCount, state.wrapMove, state.wrapMoveId = nil, nil, nil
self:emit({ kind = "message",
text = self:monName(mon) .. " was released from " .. moveName .. "!" })
return
end
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 16
local damage = math.max(1, math.floor(maxHp / 16))
mon.hp = math.max(0, mon.hp - damage)
self:emit({ kind = "message",
text = self:monName(mon) .. "'s hurt by " .. moveName .. "!" })
-- The trapping move's own anim, played from the trapper's side between two
-- SwitchTurnCore calls (core.asm:1198-1203).
self:emit({ kind = "damage", side = self:sideOf(mon), amount = damage,
hp = mon.hp, anim = false, animMove = state.wrapMoveId })
end
-- HandleScreens (engine/battle/core.asm:1564): each side's five-turn counts
-- tick down and the screen falls the turn its count reaches zero.
Battle.SCREEN_SIDE_LABEL = { player = "Your", enemy = "Enemy" }
Battle.SCREEN_FALL_TEXT = {
lightScreen = " POKéMON's LIGHT SCREEN fell!",
reflect = " POKéMON's REFLECT faded!",
}
function Battle:tickScreens()
for _, side in ipairs({ "player", "enemy" }) do
local screens = self.screens[side]
for _, field in ipairs({ "lightScreen", "reflect" }) do
if (screens[field] or 0) > 0 then
screens[field] = screens[field] - 1
if screens[field] <= 0 then
screens[field] = nil
self:emit({ kind = "message",
text = Battle.SCREEN_SIDE_LABEL[side]
.. Battle.SCREEN_FALL_TEXT[field] })
end
end
end
end
end
-- Encore, Disable and the two one-turn braces. Protect and Endure last only
-- the turn they are used, which is why they are cleared here rather than by
-- whatever they blocked.
function Battle:tickCounters(mon)
local state = self:volatile(mon)
state.protect, state.endure = nil, nil
-- A flinch lasts only the turn it was inflicted; a leftover one (the
-- target moved first, or fainted) must not eat next turn.
state.flinched = nil
if state.encoreTurns then
state.encoreTurns = state.encoreTurns - 1
if state.encoreTurns <= 0 then
state.encore, state.encoreTurns = nil, nil
self:emit({ kind = "message",
text = self:monName(mon) .. "'s ENCORE ended!" })
end
end
if state.disabledTurns then
state.disabledTurns = state.disabledTurns - 1
if state.disabledTurns <= 0 then
state.disabled, state.disabledTurns = nil, nil
self:emit({ kind = "message",
text = self:monName(mon) .. "'s move is no longer disabled!" })
end
end
end
-- Held items with an end-of-turn effect.
--
-- HELD_LEFTOVERS heals maxHP / 16 every turn (HandleLeftovers)
-- HELD_BERRY heals its parameter once the holder drops below half
-- (HandleHealingItems), and is consumed
-- HELD_HEAL_* cures the status it names, and is consumed
--
-- The rest of the held effects act inside a hit rather than at the end of a
-- turn, so they are not this function's business.
-- Confusion is a volatile, not a status byte, so HELD_HEAL_CONFUSION is not
-- in this table: its cure (and HELD_HEAL_STATUS's catch-all) reads the
-- confuseCount volatile in tickHeldItem's own arm below.
Battle.HELD_STATUS_CURES = {
HELD_HEAL_POISON = "poison",
HELD_HEAL_SLEEP = "sleep",
HELD_HEAL_BURN = "burn",
HELD_HEAL_FREEZE = "freeze",
HELD_HEAL_PARALYZE = "paralyze",
}
function Battle:itemDef(itemId)
local items = self.data.items
return itemId and items and items[itemId] or nil
end
function Battle:tickHeldItem(mon)
if (mon.hp or 0) <= 0 then return end
local def = self:itemDef(mon.item)
if not def then return end
-- Through Battle:heldEffect rather than off the record, so the end-of-turn
-- arm is one more held_item.trigger site and not a hole in it. `def` stays
-- the item's own record: the messages below name the ITEM the mon is
-- holding, which a substituted effect does not change.
local effect, parameter = self:heldEffect(mon, "residual")
if not effect then return end
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
local name = self:monName(mon)
if effect == "HELD_LEFTOVERS" then
if (mon.hp or 0) >= maxHp then return end
local healed = self:heal(mon, math.max(1, math.floor(maxHp / 16)))
if healed > 0 then
self:emit({ kind = "message",
text = name .. "'s " .. (def.name or "item") .. " restored health!" })
end
return
end
if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
self:heal(mon, parameter > 0 and parameter or 10, { anim = "RECOVER" })
mon.item = nil
self:emit({ kind = "message",
text = name .. " ate the " .. (def.name or "BERRY") .. "!" })
return
end
local cure = Battle.HELD_STATUS_CURES[effect]
if effect == "HELD_HEAL_STATUS" then cure = mon.status end
if cure and mon.status == cure then
mon.status = nil
mon.statusTurns = nil
mon.toxicCounter = nil
mon.item = nil
self:emit({ kind = "status", side = self:sideOf(mon), status = nil,
text = name .. "'s " .. (def.name or "item") .. " cured its status!" })
end
-- UseConfusionHealingItem: HELD_HEAL_CONFUSION (a Bitter Berry) and the
-- catch-all HELD_HEAL_STATUS also clear the confusion volatile, and are
-- consumed doing it.
if (effect == "HELD_HEAL_CONFUSION" or effect == "HELD_HEAL_STATUS")
and self:volatile(mon).confuseCount then
self:volatile(mon).confuseCount = nil
mon.item = nil
self:emit({ kind = "message",
text = name .. "'s " .. (def.name or "item")
.. " cured its confusion!" })
end
end
Battle.Damage = Damage
Battle.Mon = Mon
return Battle