mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
270 lines
10 KiB
Lua
270 lines
10 KiB
Lua
-- Gen 1 damage calculation, ported from engine/battle/core.asm
|
|
-- (GetDamage / CriticalHitTest / AdjustDamageForMoveType / RandomizeDamage).
|
|
--
|
|
-- Battlers carry curStats/curTypes (Transform/Conversion can override the
|
|
-- species values) plus reflect/lightScreen/focusEnergy volatile flags.
|
|
-- Battlers built by makeBattler also carry the merged badgeBoosts rows and
|
|
-- statuses records; hand-built battlers fall back to the vanilla tables.
|
|
|
|
local Logger = require("src.core.Logger")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local Stats = require("src.pokemon.Stats")
|
|
local Status = require("src.battle.Status")
|
|
local TypeChart = require("src.battle.TypeChart")
|
|
|
|
local Damage = {}
|
|
|
|
-- Moves with a boosted critical-hit rate (engine/battle/core.asm
|
|
-- CriticalHitTest checks these move ids explicitly). The move-record
|
|
-- highCrit field wins; this list covers pre-existing imported caches.
|
|
local HIGH_CRIT = {
|
|
KARATE_CHOP = true, RAZOR_LEAF = true, CRABHAMMER = true, SLASH = true,
|
|
}
|
|
|
|
-- ApplyBadgeStatBoosts (engine/battle/core.asm): x9/8 per badge on the
|
|
-- named battle stat. Data.constants.badgeBoosts replaces this via the
|
|
-- battler's badgeBoosts field; these rows are the vanilla values.
|
|
Damage.BADGE_BOOSTS = {
|
|
{ badge = "BOULDERBADGE", stat = "attack", num = 9, den = 8 },
|
|
{ badge = "THUNDERBADGE", stat = "defense", num = 9, den = 8 },
|
|
{ badge = "SOULBADGE", stat = "speed", num = 9, den = 8 },
|
|
{ badge = "VOLCANOBADGE", stat = "special", num = 9, den = 8 },
|
|
}
|
|
|
|
-- the boost a battler's badge set applies to one battle stat, or nil
|
|
local function badgeBoost(battler, stat)
|
|
local badges = battler.badges
|
|
if not badges then return nil end
|
|
for _, row in ipairs(battler.badgeBoosts or Damage.BADGE_BOOSTS) do
|
|
if row.stat == stat and badges[row.badge] then return row end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- the merged status record for a battler's persistent condition, or nil
|
|
local function statusRecord(battler)
|
|
return Status.recordFor(battler.statuses, battler.mon.status)
|
|
end
|
|
|
|
-- Critical chance test, following CriticalHitTest's shift chain exactly
|
|
-- (each left shift caps at 255): b = speed/2, then x2 (or /2 with
|
|
-- Focus Energy's famous right-shift bug), then x4 for high-crit moves
|
|
-- or /2 for normal ones. Net rates: normal = speed/512, high-crit =
|
|
-- speed*4/256 (capped), Focus Energy bug = 1/4 the usual.
|
|
-- critUsesBaseSpeed (default true, the Gen 1 rule) reads the species
|
|
-- base speed; a ruleset that sets it false uses the current in-battle
|
|
-- speed with stages applied.
|
|
function Damage.critRoll(ruleset, attacker, moveId, rng, highCrit)
|
|
rng = rng or love.math.random
|
|
local function shl(x) return math.min(255, x * 2) end
|
|
local speed
|
|
if ruleset.critUsesBaseSpeed == false then
|
|
speed = Stats.applyStage(attacker.curStats.speed,
|
|
attacker.stages and attacker.stages.speed or 0)
|
|
else
|
|
speed = attacker.def.baseStats.speed
|
|
end
|
|
local b = math.floor(speed / 2)
|
|
if attacker.focusEnergy then
|
|
if ruleset.focusEnergyBug then
|
|
b = math.floor(b / 2) -- srl instead of sla
|
|
else
|
|
b = shl(shl(shl(b))) -- intended: x4 the usual rate
|
|
end
|
|
else
|
|
b = shl(b)
|
|
end
|
|
if highCrit == nil then highCrit = HIGH_CRIT[moveId] end
|
|
if highCrit then
|
|
b = shl(shl(b))
|
|
else
|
|
b = math.floor(b / 2)
|
|
end
|
|
return rng(0, 255) < b
|
|
end
|
|
|
|
-- Exact number of the 256 RNG outcomes that pass MoveHitTest. Keeping the
|
|
-- threshold public lets read-only UIs preview the same rules the roll uses.
|
|
function Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
|
-- X ACCURACY sets USING_X_ACCURACY: the move simply never misses
|
|
-- (MoveHitTest returns before any accuracy math, 1/256 included)
|
|
if attacker.xAccuracy then return 256 end
|
|
local acc = math.floor(move.accuracy * 255 / 100)
|
|
local accuracyStage = attacker.stages and attacker.stages.accuracy or 0
|
|
local evasionStage = defender.stages and defender.stages.evasion or 0
|
|
-- CalcHitChance scales by the accuracy stage and the evasion stage as
|
|
-- two separate ratio multiplications, clamping each result
|
|
acc = math.min(255, Stats.applyStage(acc, accuracyStage))
|
|
acc = math.min(255, Stats.applyStage(acc, -evasionStage))
|
|
if not ruleset.oneIn256Miss and move.accuracy >= 100
|
|
and accuracyStage >= evasionStage then
|
|
return 256
|
|
end
|
|
return acc
|
|
end
|
|
|
|
function Damage.accuracyChance(ruleset, move, attacker, defender)
|
|
return Damage.accuracyThreshold(ruleset, move, attacker, defender) * 100 / 256
|
|
end
|
|
|
|
-- Accuracy test: rand(0..255) < the shared ruleset-aware threshold.
|
|
function Damage.accuracyRoll(ruleset, move, attacker, defender, rng)
|
|
rng = rng or love.math.random
|
|
local acc = Damage.accuracyThreshold(ruleset, move, attacker, defender)
|
|
if acc == 256 then return true end
|
|
return rng(0, 255) < acc
|
|
end
|
|
|
|
local warnedTypes = {}
|
|
|
|
-- Gen 1 splits physical from special by TYPE: the move's own category
|
|
-- field wins, then the merged type record's, then physical (with one
|
|
-- warning per unknown type).
|
|
local function categoryOf(move)
|
|
local category = move.category or TypeChart.category(move.type)
|
|
if category == nil then
|
|
if move.type ~= nil and not warnedTypes[move.type] then
|
|
warnedTypes[move.type] = true
|
|
Logger.warn("move type %s has no category; treated as physical",
|
|
tostring(move.type))
|
|
end
|
|
category = "physical"
|
|
end
|
|
return category
|
|
end
|
|
|
|
function Damage.isSpecial(moveType)
|
|
return TypeChart.category(moveType) == "special"
|
|
end
|
|
|
|
-- Compute damage. attacker/defender are battler tables.
|
|
-- opts: rng, forceCrit, explode (halves defense), typeless (confusion
|
|
-- self-hit: no STAB/type/random factor), screens (battler whose
|
|
-- Reflect/Light Screen apply when it isn't the defender -- the
|
|
-- self-hit reads the opponent's screens).
|
|
-- Returns damage, {crit=bool, typeMult=x10}.
|
|
function Damage.compute(ruleset, attacker, defender, move, opts)
|
|
opts = opts or {}
|
|
local rng = opts.rng or love.math.random
|
|
if move.power == 0 or move.category == "status" then
|
|
return 0, { crit = false, typeMult = 10 }
|
|
end
|
|
|
|
local crit = opts.forceCrit
|
|
if crit == nil then
|
|
if Runtime.wantsHook("battle.crit") then
|
|
crit = Runtime.call("battle.crit", function(c)
|
|
return Damage.critRoll(c.ruleset, c.attacker, c.moveId, c.rng, c.highCrit)
|
|
end, { ruleset = ruleset, attacker = attacker, moveId = move.id,
|
|
rng = rng, highCrit = move.highCrit })
|
|
else
|
|
crit = Damage.critRoll(ruleset, attacker, move.id, rng, move.highCrit)
|
|
end
|
|
end
|
|
|
|
local special = categoryOf(move) == "special"
|
|
local atkStat = special and "special" or "attack"
|
|
local defStat = special and "special" or "defense"
|
|
|
|
local atk, dfn
|
|
if crit and ruleset.critIgnoresStages then
|
|
atk = attacker.curStats[atkStat]
|
|
dfn = defender.curStats[defStat]
|
|
else
|
|
atk = Stats.applyStage(attacker.curStats[atkStat],
|
|
attacker.stages and attacker.stages[atkStat] or 0)
|
|
dfn = Stats.applyStage(defender.curStats[defStat],
|
|
defender.stages and defender.stages[defStat] or 0)
|
|
-- badge boosts (x9/8), engine/battle/core.asm ApplyBadgeStatBoosts:
|
|
-- Boulder -> attack, Thunder -> defense, Soul -> speed (TurnOrder),
|
|
-- Volcano -> special
|
|
local atkBoost = badgeBoost(attacker, atkStat)
|
|
if atkBoost then
|
|
atk = math.floor(atk * (atkBoost.num or 9) / (atkBoost.den or 8))
|
|
end
|
|
local defBoost = badgeBoost(defender, defStat)
|
|
if defBoost then
|
|
dfn = math.floor(dfn * (defBoost.num or 9) / (defBoost.den or 8))
|
|
end
|
|
-- burn halves physical attack (applied as part of the stat in Gen 1;
|
|
-- the status record's statPenalty names the stat it cuts).
|
|
-- hazeStatReset suppresses it: Haze (haze.asm ResetStats) copied the
|
|
-- unmodified attack over the burn-halved battle stat, lifting the
|
|
-- penalty until the next stat recompute.
|
|
local record = statusRecord(attacker)
|
|
local penalty = record and record.statPenalty
|
|
if penalty and penalty.stat == atkStat and not attacker.hazeStatReset then
|
|
atk = math.max(1, math.floor(atk / penalty.div))
|
|
end
|
|
-- screens double the effective defense (crits bypass them). The
|
|
-- confusion self-hit is the quirk case: HandleSelfConfusionDamage
|
|
-- swaps the user's own defense in but leaves the screen check
|
|
-- reading the OPPONENT's battle status, so the typeless path takes
|
|
-- the screen flags from opts.screens (the opponent) and never from
|
|
-- the user itself.
|
|
if not crit then
|
|
local screens = opts.screens
|
|
if screens == nil and not opts.typeless then screens = defender end
|
|
if screens then
|
|
if special and screens.lightScreen then dfn = dfn * 2 end
|
|
if not special and screens.reflect then dfn = dfn * 2 end
|
|
end
|
|
end
|
|
end
|
|
-- GetDamageVars .scaleStats: when either stat no longer fits a byte,
|
|
-- BOTH are quartered (losing low bits), each bumped to at least 1
|
|
if atk > 255 or dfn > 255 then
|
|
atk = math.max(1, math.floor(atk / 4))
|
|
dfn = math.max(1, math.floor(dfn / 4))
|
|
end
|
|
if opts.explode then
|
|
dfn = math.max(1, math.floor(dfn / 2))
|
|
end
|
|
|
|
local level = attacker.mon.level
|
|
if crit then level = level * 2 end
|
|
|
|
local d = math.floor(math.floor(2 * level / 5) + 2)
|
|
d = math.floor(math.floor(d * move.power * atk / math.max(1, dfn)) / 50)
|
|
d = math.min(d, 997) + 2
|
|
|
|
local mult = 10
|
|
if not opts.typeless then
|
|
-- STAB
|
|
local stab = false
|
|
for _, t in ipairs(attacker.curTypes) do
|
|
if t == move.type then stab = true break end
|
|
end
|
|
if stab then
|
|
d = math.floor(d * 3 / 2)
|
|
end
|
|
|
|
-- type effectiveness: each TypeEffects row is applied to the
|
|
-- running damage separately with its own floor (0.5*0.5 lands on
|
|
-- floor(floor(d/2)/2), not d*0.25)
|
|
mult = TypeChart.effectiveness(move.type, defender.curTypes)
|
|
if mult == 0 then
|
|
return 0, { crit = false, typeMult = 0 }
|
|
end
|
|
for _, m in ipairs(TypeChart.rows(move.type, defender.curTypes)) do
|
|
d = math.floor(d * m / 10)
|
|
end
|
|
if d == 0 then
|
|
-- a 2-3 damage hit at 0.25x floors to zero: the original flags
|
|
-- the move as missed rather than dealing a minimum 1
|
|
return 0, { crit = false, typeMult = mult, missed = true }
|
|
end
|
|
end
|
|
|
|
-- random factor; the typeless confusion self-hit skips RandomizeDamage
|
|
-- along with AdjustDamageForMoveType (HandleSelfConfusionDamage calls
|
|
-- CalculateDamage directly), so it is fully deterministic
|
|
if d > 1 and not opts.typeless then
|
|
local r = rng(ruleset.randMin, ruleset.randMax)
|
|
d = math.floor(d * r / 255)
|
|
end
|
|
return math.max(d, 1), { crit = crit, typeMult = mult }
|
|
end
|
|
|
|
return Damage
|