mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
4550 lines
199 KiB
Lua
4550 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 (rng(n) → 1..n, rng(lo,hi) → lo..hi).
|
|
-- Gen 2's cart BattleRandom is random(n) → 0..n-1. Both live on the
|
|
-- battle: `random` / `roller()` are BattleRandom for damage, accuracy,
|
|
-- Magnitude, etc.; `rng` is the Gen 1 / love-style view of the same stream.
|
|
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
|
|
|
|
-- Gen 1 / love.math view of BattleRandom: rng(n) → 1..n, rng(lo,hi) → lo..hi.
|
|
local function loveStyleRng(random)
|
|
return function(lo, hi)
|
|
if hi == nil then
|
|
local n = lo or 1
|
|
if n < 1 then n = 1 end
|
|
return (rand(random, n) or 0) + 1
|
|
end
|
|
if hi < lo then lo, hi = hi, lo end
|
|
return lo + (rand(random, hi - lo + 1) or 0)
|
|
end
|
|
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 (BattleRandom)
|
|
-- rng(lo,hi) / rng(n) Gen 1 / love.math contract; defaults over `random`
|
|
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
|
|
-- Same stream as `random`, Gen 1 / love.math calling convention.
|
|
self.rng = opts.rng or loveStyleRng(self.random)
|
|
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
|
|
|
|
-- Never-nil BattleRandom (0..n-1) for call sites that want the cart byte.
|
|
-- `battle.rng` is the Gen 1 / love.math view of the same stream.
|
|
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
|