Files
gen1recomp/src/battle/gen2/Damage.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

329 lines
13 KiB
Lua

-- Gen 2 damage.
--
-- Ported from engine/battle/effect_commands.asm, in the order the cart's move
-- sequence runs them: damagestats -> damagecalc -> stab -> damagevariation.
--
-- What differs from Gen 1 (src/battle/Damage.lua), and why this is its own
-- module rather than a flag on that one:
-- * Special is split into Special Attack and Special Defense, so a special
-- move reads the attacker's SpA against the defender's SpD instead of both
-- sides' single `special`.
-- * Critical hits are a *chance ladder* (data/battle/critical_hit_chances.asm
-- 1/15, 1/8, 1/4, 1/3, 1/2) indexed by a "critical level" that Focus
-- Energy, a high-crit move, Scope Lens, and the Lucky Punch / Stick raise.
-- Gen 1 instead derived the chance from base Speed.
-- * A critical hit is a flat x2 and, unlike Gen 1, ignores the attacker's
-- *negative* stat stages rather than all stages.
-- * Type-boost held items (Charcoal, Mystic Water, ...) multiply before the
-- crit, and Steel and Dark exist in the matchup table.
--
-- Whether a move is physical or special is still decided by its *type*, not
-- per-move as in Gen 4: type ids below FIRE are physical. type_chart.lua's
-- records carry that as `category`.
local Damage = {}
-- data/battle/critical_hit_chances.asm, as "1 in N".
Damage.CRITICAL_CHANCES = { [0] = 15, 8, 4, 3, 2, 2, 2 }
-- Gen 2's damage spread: 85% to 100% inclusive.
Damage.MIN_VARIATION = 85
Damage.MAX_VARIATION = 100
-- The cart caps a single hit at 999 (DAMAGE_CAP + MIN_DAMAGE in
-- BattleCommand_DamageCalc).
Damage.MAX_DAMAGE = 999
-- BattleCommand_DamageCalc's tail (engine/battle/effect_commands.asm) caps the
-- computed damage at DAMAGE_CAP (997) and then adds MIN_DAMAGE (2) back, so
-- every damaging hit leaves DamageCalc worth at least 2 -- which is what keeps
-- a resisted hit from flooring to zero once the type matchup halves it.
Damage.MIN_DAMAGE = 2
-- Stat stage multipliers (numerator, denominator), -6..+6. Same table as
-- Gen 1; a critical hit skips only the negative half for the attacker.
local STAGE = {
[-6] = { 25, 100 }, [-5] = { 28, 100 }, [-4] = { 33, 100 },
[-3] = { 40, 100 }, [-2] = { 50, 100 }, [-1] = { 66, 100 },
[0] = { 1, 1 },
[1] = { 15, 10 }, [2] = { 2, 1 }, [3] = { 25, 10 },
[4] = { 3, 1 }, [5] = { 35, 10 }, [6] = { 4, 1 },
}
function Damage.stageMultiplier(stage)
local entry = STAGE[math.max(-6, math.min(6, stage or 0))]
return entry[1], entry[2]
end
-- Apply a stat stage, flooring like the cart's Multiply/Divide pair, and never
-- letting a stat reach 0 (a 0 defence would divide by zero).
function Damage.applyStage(value, stage)
local numerator, denominator = Damage.stageMultiplier(stage)
local out = math.floor(value * numerator / denominator)
return math.max(1, out)
end
-- Is this move physical? `types` is type_chart.lua's `types` table.
function Damage.isPhysical(moveType, types)
local record = types and types[moveType]
if record and record.category then return record.category == "physical" end
-- Without the table, fall back to the Gen 1/2 boundary: the physical block
-- runs NORMAL..GROUND, and the special block starts at FIRE.
local PHYSICAL = {
NORMAL = true, FIGHTING = true, FLYING = true, POISON = true,
GROUND = true, ROCK = true, BUG = true, GHOST = true, STEEL = true,
}
return PHYSICAL[moveType] == true
end
-- The 1-in-N chance for a critical level.
function Damage.criticalChance(level)
local capped = math.max(0, math.min(6, level or 0))
return Damage.CRITICAL_CHANCES[capped]
end
-- BattleCommand_Critical, as a level rather than a roll:
-- +1 Focus Energy, +2 a high-crit move, +1 Scope Lens,
-- +2 Lucky Punch on Chansey / Stick on Farfetch'd.
function Damage.criticalLevel(opts)
local level = 0
if opts.focusEnergy then level = level + 1 end
if opts.highCritMove then level = level + 2 end
if opts.scopeLens then level = level + 1 end
if opts.speciesItemBonus then level = level + 2 end
return math.min(6, level)
end
-- Roll a critical hit. `random(n)` must return 0..n-1 (the cart compares a
-- BattleRandom byte against the chance), and defaults to love/math random.
function Damage.rollCritical(criticalLevel, random)
local chance = Damage.criticalChance(criticalLevel)
local roll
if random then
roll = random(chance)
elseif love and love.math then
roll = love.math.random(chance) - 1
else
roll = math.random(chance) - 1
end
return roll == 0
end
-- The x10 type multiplier of a move against a defender, applying each matchup
-- row separately and flooring in between -- the same rule Gen 1 follows, which
-- is why a dual type can land on 4x or 0.25x.
function Damage.typeMultiplier(moveType, defenderTypes, matchups)
local multiplier = 10
for _, row in ipairs(matchups or {}) do
if row.attacker == moveType then
for _, defenderType in ipairs(defenderTypes or {}) do
if row.defender == defenderType then
multiplier = math.floor(multiplier * row.multiplier / 10)
break
end
end
end
end
return multiplier
end
-- The core formula (BattleCommand_DamageCalc):
--
-- (((2 * Level / 5 + 2) * Power * Attack / Defense) / 50)
--
-- every step floored, defence clamped to at least 1.
function Damage.base(level, power, attack, defense)
if (power or 0) <= 0 then return 0 end
defense = math.max(1, defense or 1)
local value = math.floor(level * 2 / 5) + 2
value = value * power
value = value * attack
value = math.floor(value / defense)
value = math.floor(value / 50)
return value
end
-- Every `info` table Damage.calc returns carries BOTH generations' names for
-- the same two facts: Gen 2 calls them `critical` and `effectiveness`, Gen 1
-- (src/battle/Damage.lua) calls them `crit` and `typeMult`. The battle.damage
-- hook hands this table to whatever wrapped it, and a mod written against Red
-- must be able to read what it wrapped.
local function withGen1Names(info)
info.crit = info.critical
info.typeMult = info.effectiveness
return info
end
-- opts:
-- level, power, moveType
-- attacker { attack, specialAttack, types, stages = { attack =, ... } }
-- defender { defense, specialDefense, types, stages }
-- types, matchups -- type_chart.lua's `types` / `matchups`
-- critical -- boolean (roll it with rollCritical first)
-- itemBoostPercent -- type-boost held item, e.g. 10 for Charcoal
-- weatherPercent -- DoWeatherModifiers in tenths (15 / 5 / nil),
-- applied ahead of the badge boost and STAB
-- badgeTypeBoost -- DoBadgeTypeBoosts: the player owns the badge
-- matching the move's type, +1/8 before STAB
-- variation -- 85..100; omit to roll
-- random(n) -- 0..n-1, for the variation roll
-- screen -- Reflect/Light Screen active on the defender
-- defenseHalved -- EFFECT_SELFDESTRUCT's srl c
--
-- Returns damage, info where info carries the pieces a battle message needs:
-- effectiveness (x10), critical, physical, variation.
function Damage.calc(opts)
local physical = Damage.isPhysical(opts.moveType, opts.types)
local attacker = opts.attacker or {}
local defender = opts.defender or {}
local stagesA = attacker.stages or {}
local stagesD = defender.stages or {}
local rawAttack = physical and (attacker.attack or 1)
or (attacker.specialAttack or attacker.special or 1)
local rawDefense = physical and (defender.defense or 1)
or (defender.specialDefense or defender.special or 1)
local stageA = physical and (stagesA.attack or 0)
or (stagesA.specialAttack or 0)
local stageD = physical and (stagesD.defense or 0)
or (stagesD.specialDefense or 0)
-- A critical hit ignores stat changes that would *lower* the damage: the
-- attacker's negative stages and the defender's positive ones.
if opts.critical then
if stageA < 0 then stageA = 0 end
if stageD > 0 then stageD = 0 end
end
local attack = Damage.applyStage(rawAttack, stageA)
local defense = Damage.applyStage(rawDefense, stageD)
-- Reflect and Light Screen double the matching defence, and are the one
-- multiplier a critical hit also ignores.
if opts.screen and not opts.critical then
defense = defense * 2
end
-- BattleCommand_DamageCalc (effect_commands.asm:2905-2913): Selfdestruct and
-- Explosion halve the defence, never below 1.
if opts.defenseHalved then defense = math.max(1, math.floor(defense / 2)) end
if (opts.power or 0) <= 0 then
return 0, withGen1Names({ effectiveness = 10, critical = false,
physical = physical })
end
local damage = Damage.base(opts.level or 1, opts.power or 0, attack, defense)
-- Type-boost held items multiply before the crit (.NextItem / .DoneItem).
if opts.itemBoostPercent and opts.itemBoostPercent > 0 then
damage = math.floor(damage * (100 + opts.itemBoostPercent) / 100)
end
if opts.critical then damage = damage * 2 end
-- BattleCommand_DamageCalc's tail: cap at DAMAGE_CAP, then add MIN_DAMAGE
-- back, so even a hit whose stat math floored to nothing leaves with 2.
damage = math.min(damage, Damage.MAX_DAMAGE - Damage.MIN_DAMAGE)
+ Damage.MIN_DAMAGE
-- DoWeatherModifiers (engine/battle/misc.asm:102-140), farcalled by
-- BattleCommand_Stab as its very FIRST act (effect_commands.asm:1254): it
-- sits ahead of the badge boost, the STAB x1.5, the type rows and
-- DamageVariation, so every later step floors on top of it. The table's
-- values are tenths (weather_modifiers.asm: MORE_EFFECTIVE 15,
-- NOT_VERY_EFFECTIVE 05), and .ApplyModifier's zero-quotient arm forces the
-- result back to 1, so a weather-halved hit never falls to nothing.
if opts.weatherPercent and opts.weatherPercent ~= 10 then
damage = math.max(1, math.floor(damage * opts.weatherPercent / 10))
end
-- DoBadgeTypeBoosts (engine/battle/misc.asm:146), farcalled from
-- BattleCommand_Stab ahead of the STAB multiply: a matching owned badge
-- adds an eighth of the running damage, at least 1, on the player's turn.
if opts.badgeTypeBoost then
damage = damage + math.max(1, math.floor(damage / 8))
end
-- STAB, then each type row. BattleCommand_Stab does STAB first, so a
-- resisted same-type move floors after the x1.5.
local stab = false
for _, attackerType in ipairs(attacker.types or {}) do
if attackerType == opts.moveType then stab = true break end
end
if stab then damage = math.floor(damage * 15 / 10) end
-- Each matchup row multiplies the running damage separately, the way
-- BattleCommand_Stab's .TypesLoop does -- and its zero-quotient check forces
-- the damage back to 1 whenever a non-immune row floors it to nothing, so a
-- resisted hit that lands always deals at least 1 HP.
local effectiveness = Damage.typeMultiplier(
opts.moveType, defender.types, opts.matchups)
for _, row in ipairs(opts.matchups or {}) do
if row.attacker == opts.moveType then
for _, defenderType in ipairs(defender.types or {}) do
if row.defender == defenderType then
damage = math.floor(damage * row.multiplier / 10)
if damage == 0 and row.multiplier > 0 then damage = 1 end
break
end
end
end
end
if effectiveness <= 0 or damage <= 0 then
return 0, withGen1Names({
effectiveness = effectiveness, critical = opts.critical or false,
physical = physical, stab = stab,
})
end
-- Damage variation last, and only when the running damage is 2 or more
-- (BattleCommand_DamageVariation returns early below that).
local variation = opts.variation
if not variation then
if opts.random then
variation = Damage.MIN_VARIATION
+ opts.random(Damage.MAX_VARIATION - Damage.MIN_VARIATION + 1)
elseif love and love.math then
variation = love.math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION)
else
variation = math.random(Damage.MIN_VARIATION, Damage.MAX_VARIATION)
end
end
if damage >= 2 then
damage = math.floor(damage * variation / 100)
end
damage = math.max(1, math.min(Damage.MAX_DAMAGE, damage))
return damage, withGen1Names({
effectiveness = effectiveness,
critical = opts.critical or false,
physical = physical,
stab = stab,
variation = variation,
})
end
-- Accuracy check. Gen 2 rolls one byte against accuracy scaled by the
-- attacker's accuracy stage and the defender's evasion stage; accuracy of 0 in
-- the data means "never misses" (Swift and friends).
function Damage.rollHit(accuracy, accuracyStage, evasionStage, random)
if not accuracy or accuracy <= 0 then return true end
local numerator, denominator = Damage.stageMultiplier(accuracyStage or 0)
local value = math.floor(accuracy * numerator / denominator)
numerator, denominator = Damage.stageMultiplier(-(evasionStage or 0))
value = math.floor(value * numerator / denominator)
value = math.max(1, math.min(100, value))
local roll
if random then
roll = random(100)
elseif love and love.math then
roll = love.math.random(100) - 1
else
roll = math.random(100) - 1
end
return roll < value
end
return Damage