mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
420 lines
18 KiB
Lua
420 lines
18 KiB
Lua
-- Gen 2 battle core: where the weather multiplier sits in the damage chain,
|
|
-- which failures suppress the attack animation, and the two move lock-ins
|
|
-- (Rollout and the EFFECT_RAMPAGE pair, Thrash and Petal Dance).
|
|
--
|
|
-- luajit tests/gen2_battle_lockin_test.lua
|
|
--
|
|
-- ROM-free. Every expectation names the pokegold routine it asserts:
|
|
-- engine/battle/misc.asm DoWeatherModifiers, engine/battle/effect_commands.asm
|
|
-- BattleCommand_Stab / _MoveAnimNoSub / _CheckRampage / _Rampage / _StatDown /
|
|
-- _Poison, engine/battle/move_effects/rollout.asm and leech_seed.asm, and
|
|
-- engine/battle/core.asm CheckPlayerLockedIn.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 battle lock-in")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Battle = require("src.battle.gen2.Battle")
|
|
local Damage = require("src.battle.gen2.Damage")
|
|
local Effects = require("src.battle.gen2.Effects")
|
|
local Mon = require("src.battle.gen2.Mon")
|
|
|
|
-- ---------------------------------------------------------------- fixtures
|
|
|
|
local TYPES = {
|
|
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
|
|
GHOST = { id = "GHOST", index = 8, category = "physical" },
|
|
ROCK = { id = "ROCK", index = 5, category = "physical" },
|
|
GRASS = { id = "GRASS", index = 22, category = "special" },
|
|
WATER = { id = "WATER", index = 21, category = "special" },
|
|
POISON = { id = "POISON", index = 3, category = "physical" },
|
|
}
|
|
|
|
-- data/type_matchups.asm's own row: NORMAL does nothing at all to GHOST.
|
|
local MATCHUPS = {
|
|
{ attacker = "NORMAL", defender = "GHOST", multiplier = 0 },
|
|
}
|
|
|
|
local MOVES = {
|
|
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
|
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
|
ROLLOUT = { id = "ROLLOUT", name = "ROLLOUT", power = 30, type = "ROCK",
|
|
accuracy = 90, pp = 20, effect = "EFFECT_ROLLOUT" },
|
|
THRASH = { id = "THRASH", name = "THRASH", power = 90, type = "NORMAL",
|
|
accuracy = 100, pp = 20, effect = "EFFECT_RAMPAGE" },
|
|
LEECH_SEED = { id = "LEECH_SEED", name = "LEECH SEED", power = 0,
|
|
type = "GRASS", accuracy = 90, pp = 10, effect = "EFFECT_LEECH_SEED" },
|
|
TOXIC = { id = "TOXIC", name = "TOXIC", power = 0, type = "POISON",
|
|
accuracy = 85, pp = 10, effect = "EFFECT_TOXIC" },
|
|
POISONPOWDER = { id = "POISONPOWDER", name = "POISONPOWDER", power = 0,
|
|
type = "GRASS", accuracy = 75, pp = 35, effect = "EFFECT_POISON" },
|
|
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
|
|
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
|
|
-- An *_HIT twin: the drop rides a hit that already animated, so a refused
|
|
-- drop must leave the move event unmarked (data/moves/effects.asm,
|
|
-- AttackDownHit puts `attackdown` after `moveanim`).
|
|
AURORA_BEAM = { id = "AURORA_BEAM", name = "AURORA BEAM", power = 65,
|
|
type = "WATER", accuracy = 100, pp = 20,
|
|
effect = "EFFECT_ATTACK_DOWN_HIT", effectChance = 100 },
|
|
}
|
|
|
|
local GROWTH = {
|
|
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
|
linear = 0, constant = 0 },
|
|
}
|
|
|
|
local POKEMON = {
|
|
growthRates = GROWTH,
|
|
MACHOP = {
|
|
id = "MACHOP", index = 66, name = "MACHOP",
|
|
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
|
|
specialAttack = 35, specialDefense = 35 },
|
|
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
|
|
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
|
|
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
|
},
|
|
GASTLY = {
|
|
id = "GASTLY", index = 92, name = "GASTLY",
|
|
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
|
|
specialAttack = 100, specialDefense = 35 },
|
|
types = { "GHOST", "GHOST" }, catchRate = 190, baseExp = 95,
|
|
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
|
|
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
|
},
|
|
TANGELA = {
|
|
id = "TANGELA", index = 114, name = "TANGELA",
|
|
baseStats = { hp = 65, attack = 55, defense = 115, speed = 60,
|
|
specialAttack = 100, specialDefense = 40 },
|
|
types = { "GRASS", "GRASS" }, catchRate = 45, baseExp = 166,
|
|
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
|
|
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
|
},
|
|
}
|
|
|
|
local DATA = {
|
|
pokemon = POKEMON,
|
|
moves = MOVES,
|
|
type_chart = { types = TYPES, matchups = MATCHUPS },
|
|
items = {},
|
|
}
|
|
|
|
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
|
perfect.hp = Mon.hpDV(perfect)
|
|
|
|
local function rolls(queue, fill)
|
|
local at = 0
|
|
return function(n)
|
|
at = at + 1
|
|
local value = queue[at]
|
|
if value == nil then value = fill or 0 end
|
|
return value % math.max(1, n or 1)
|
|
end
|
|
end
|
|
|
|
local function newBattle(opts)
|
|
opts = opts or {}
|
|
local player = Mon.new(DATA, opts.playerSpecies or "MACHOP",
|
|
opts.playerLevel or 15, { dvs = perfect })
|
|
player.moves = opts.playerMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
|
local wild = Mon.new(DATA, opts.wildSpecies or "MACHOP",
|
|
opts.wildLevel or 15, { dvs = perfect })
|
|
wild.moves = opts.wildMoves or { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
|
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
|
|
random = opts.random })
|
|
return battle, player, wild
|
|
end
|
|
|
|
-- The screen animates off the move event and skips a marked one
|
|
-- (src/ui/gen2/BattleState.lua reads `event.missed`), so "did the attack
|
|
-- animation play" is exactly "is this flag nil".
|
|
local function moveEvent(events)
|
|
for _, event in ipairs(events or {}) do
|
|
if event.kind == "move" then return event end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function findText(events, text)
|
|
for _, event in ipairs(events or {}) do
|
|
if event.kind == "message" and event.text == text then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ---- DoWeatherModifiers sits FIRST in BattleCommand_Stab -------------------
|
|
--
|
|
-- effect_commands.asm:1254 farcalls DoWeatherModifiers before DoBadgeTypeBoosts
|
|
-- (:1261), before the STAB x1.5 and before .TypesLoop (:1299), and long before
|
|
-- BattleCommand_DamageVariation. Every one of those steps floors, so the
|
|
-- multiply has to land where the cart puts it, not on the finished number.
|
|
do
|
|
-- data/battle/weather_modifiers.asm pairs rain with MORE_EFFECTIVE, and
|
|
-- MORE_EFFECTIVE is 15, not 20 (constants/battle_constants.asm:22).
|
|
eq(Effects.weatherModifier("rain", "WATER"), 1.5,
|
|
"rain is MORE_EFFECTIVE (15), a half again -- not the chart's x2")
|
|
eq(Effects.weatherModifier("sun", "WATER"), 0.5,
|
|
"sun is NOT_VERY_EFFECTIVE (05) against Water")
|
|
|
|
-- Chosen so the two orders disagree. Base damage floors to 1, the tail
|
|
-- adds MIN_DAMAGE for 3, and the mon is Water so STAB applies:
|
|
-- cart: 3 -> weather 4 -> STAB 6 -> variation 85% -> 5
|
|
-- port's old order: 3 -> STAB 4 -> variation 85% -> 3 -> weather -> 4
|
|
local opts = {
|
|
level = 5, power = 20, moveType = "WATER",
|
|
attacker = { attack = 10, specialAttack = 10, types = { "WATER" },
|
|
stages = {} },
|
|
defender = { defense = 10, specialDefense = 10, types = {}, stages = {} },
|
|
types = TYPES, matchups = {}, variation = 85,
|
|
}
|
|
opts.weatherPercent = nil
|
|
local clear = Damage.calc(opts)
|
|
opts.weatherPercent = 15
|
|
local rain = Damage.calc(opts)
|
|
eq(clear, 3, "clear weather: the plain chain")
|
|
eq(rain, 5, "rain multiplies ahead of STAB and DamageVariation")
|
|
check(rain ~= 4,
|
|
"and not on the finished number, which would have floored to 4")
|
|
|
|
-- The .ApplyModifier zero-quotient arm forces the result back to 1, so a
|
|
-- halved hit never falls to nothing (misc.asm:129-136).
|
|
opts.weatherPercent = 5
|
|
opts.variation = 100
|
|
local weak = Damage.calc(opts)
|
|
check(weak >= 1, "a weather-halved hit still deals at least 1")
|
|
end
|
|
|
|
-- ---- immunity suppresses the animation -----------------------------------
|
|
--
|
|
-- BattleCommand_Stab's `.GotMatchup` writes wAttackMissed when the matchup
|
|
-- byte is 0 (effect_commands.asm:1337), `stab` runs ahead of `moveanim` in
|
|
-- every damaging effect list (data/moves/effects.asm:5), and
|
|
-- BattleCommand_MoveAnimNoSub early-outs on wAttackMissed (:1958), so an
|
|
-- immune move plays MoveDelay and nothing else.
|
|
do
|
|
local battle, player, wild = newBattle({
|
|
wildSpecies = "GASTLY", random = rolls({}, 0) })
|
|
battle:useMove(player, wild, "TACKLE")
|
|
local events = battle:takeEvents()
|
|
check(findText(events, "It doesn't affect GASTLY..."), "DoesntAffectText")
|
|
eq(moveEvent(events).missed, true,
|
|
"and wAttackMissed is set, so no attack animation plays")
|
|
eq(wild.hp, wild.maxHp, "an immune hit deals nothing")
|
|
end
|
|
|
|
-- ---- the other AnimateFailedMove arms -------------------------------------
|
|
do
|
|
-- leech_seed.asm `.grass`: AnimateFailedMove, then PrintDoesntAffect.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } },
|
|
wildSpecies = "TANGELA", random = rolls({}, 0) })
|
|
battle:useMove(player, wild, "LEECH_SEED")
|
|
local events = battle:takeEvents()
|
|
check(findText(events, "It doesn't affect TANGELA..."), "PrintDoesntAffect")
|
|
eq(moveEvent(events).missed, true, "a Grass target plays no animation")
|
|
eq(wild.volatile and wild.volatile.leechSeed, nil, "and nothing is seeded")
|
|
end
|
|
|
|
do
|
|
-- leech_seed.asm `.evaded`: a repeat seeds nothing and animates nothing.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "LEECH_SEED", pp = 10, maxPp = 10 } },
|
|
random = rolls({}, 0) })
|
|
battle:useMove(player, wild, "LEECH_SEED")
|
|
eq(moveEvent(battle:takeEvents()).missed, nil,
|
|
"the seeding arm reaches AnimateCurrentMove")
|
|
battle:useMove(player, wild, "LEECH_SEED")
|
|
local events = battle:takeEvents()
|
|
check(findText(events, "MACHOP evaded the attack!"), "EvadedText")
|
|
eq(moveEvent(events).missed, true, "and the repeat plays nothing")
|
|
end
|
|
|
|
do
|
|
-- BattleCommand_StatDown's `.CantLower` sets wAttackMissed
|
|
-- (effect_commands.asm:4380-4390) and `statdownanim` reads it.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "GROWL", pp = 40, maxPp = 40 } },
|
|
random = rolls({}, 0) })
|
|
battle.stages.enemy.attack = -Effects.MAX_STAGE
|
|
battle:useMove(player, wild, "GROWL")
|
|
local events = battle:takeEvents()
|
|
check(findText(events, "MACHOP's ATTACK won't drop anymore!"),
|
|
"the refusal line")
|
|
eq(moveEvent(events).missed, true, "a capped drop plays no animation")
|
|
end
|
|
|
|
do
|
|
-- The negative regression that matters: AttackDownHit's `attackdown` runs
|
|
-- AFTER `moveanim`, so a refused SECONDARY drop must leave the event alone.
|
|
-- Battle:markMissed marks the move event retroactively, and the screen only
|
|
-- reads the flag when the queue drains, so marking here would delete an
|
|
-- animation the cart played.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "AURORA_BEAM", pp = 20, maxPp = 20 } },
|
|
random = rolls({}, 0) })
|
|
battle.stages.enemy.attack = -Effects.MAX_STAGE
|
|
battle:useMove(player, wild, "AURORA_BEAM")
|
|
local events = battle:takeEvents()
|
|
eq(moveEvent(events).missed, nil,
|
|
"a refused secondary drop keeps the animation the hit already played")
|
|
check(wild.hp < wild.maxHp, "and the hit still landed")
|
|
end
|
|
|
|
do
|
|
-- BattleCommand_Poison's already-statused arm loads its text and jumps to
|
|
-- `.failed`, which is AnimateFailedMove (effect_commands.asm:3748-3750).
|
|
-- AnimateCurrentMove only runs on `.apply_poison` (:3752).
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "POISONPOWDER", pp = 35, maxPp = 35 },
|
|
{ id = "TOXIC", pp = 10, maxPp = 10 } },
|
|
random = rolls({}, 0) })
|
|
battle:useMove(player, wild, "POISONPOWDER")
|
|
eq(wild.status, "poison", "the first one lands")
|
|
eq(moveEvent(battle:takeEvents()).missed, nil, "and animates")
|
|
battle:useMove(player, wild, "TOXIC")
|
|
local events = battle:takeEvents()
|
|
eq(moveEvent(events).missed, true,
|
|
"a status move aimed at an already-statused target plays nothing")
|
|
eq(wild.status, "poison", "and changes nothing")
|
|
end
|
|
|
|
-- ---- Rollout locks the user in --------------------------------------------
|
|
--
|
|
-- BattleCommand_RolloutPower sets SUBSTATUS_ROLLOUT while the counter is short
|
|
-- of MAX_ROLLOUT_COUNT (move_effects/rollout.asm), and CheckPlayerLockedIn
|
|
-- quits ParsePlayerAction while it is set (core.asm:546), so no menu is
|
|
-- offered, no PP is spent and no obedience check is made.
|
|
do
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 },
|
|
{ id = "TACKLE", pp = 35, maxPp = 35 } },
|
|
wildLevel = 60, random = rolls({}, 0) })
|
|
eq(battle:forcedMove(player), nil, "nothing forces the first Rollout")
|
|
for turn = 1, 4 do
|
|
battle:useMove(player, wild, "ROLLOUT")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), "ROLLOUT",
|
|
"turn " .. turn .. " of five leaves SUBSTATUS_ROLLOUT set")
|
|
end
|
|
eq(player.moves[1].pp, 19, "only the first turn spent PP (checkrollout skips doturn)")
|
|
battle:useMove(player, wild, "ROLLOUT")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), nil,
|
|
"MAX_ROLLOUT_COUNT reached: the fifth hit clears the bit")
|
|
eq(player.moves[1].pp, 19, "and still no extra PP was spent")
|
|
end
|
|
|
|
do
|
|
-- A SECOND sequence, started after the first ran its five hits out.
|
|
-- BattleCommand_CheckRollout takes its `.reset` arm whenever
|
|
-- SUBSTATUS_ROLLOUT is clear as the move starts, and zeroes
|
|
-- wPlayerRolloutCount there (move_effects/rollout.asm), so a spent counter
|
|
-- never feeds the next sequence: the new ROLLOUT opens at base power, locks
|
|
-- the menu again, and pays its one PP through the doturn the `.reset` arm
|
|
-- falls through to.
|
|
--
|
|
-- The port used to decide "continue the ramp?" by asking whether the LAST
|
|
-- move was also ROLLOUT, which has no cart equivalent: it held the counter at
|
|
-- the cap, so the second sequence opened at 16x power and left
|
|
-- CheckPlayerLockedIn (core.asm:546) with no bit to read.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 } },
|
|
wildLevel = 60, random = rolls({}, 0) })
|
|
|
|
-- Topped up each time so the target never faints out from under the ramp.
|
|
local function rolloutDamage()
|
|
wild.hp = wild.maxHp
|
|
battle:useMove(player, wild, "ROLLOUT")
|
|
battle:takeEvents()
|
|
return wild.maxHp - wild.hp
|
|
end
|
|
|
|
local opening = rolloutDamage()
|
|
eq(battle:forcedMove(player), "ROLLOUT", "the opening hit locks")
|
|
local capped
|
|
for _ = 2, 5 do capped = rolloutDamage() end
|
|
eq(battle:forcedMove(player), nil, "the fifth hit clears SUBSTATUS_ROLLOUT")
|
|
eq(player.moves[1].pp, 19, "the whole first sequence cost one PP")
|
|
check(capped > opening, "the ramp really did double along the way")
|
|
|
|
local restarted = rolloutDamage()
|
|
eq(restarted, opening, "the restarted ROLLOUT is back at base power")
|
|
eq(battle:forcedMove(player), "ROLLOUT", "and it locks the menu again")
|
|
eq(player.moves[1].pp, 18, "and pays PP, because .reset falls through to doturn")
|
|
end
|
|
|
|
do
|
|
-- `.skip_set_rampage` reads wAttackMissed before the counter and clears
|
|
-- SUBSTATUS_ROLLOUT outright, so a miss releases the lock.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "ROLLOUT", pp = 20, maxPp = 20 } },
|
|
wildLevel = 60, random = rolls({}, 0) })
|
|
battle:useMove(player, wild, "ROLLOUT")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), "ROLLOUT", "the first hit locks")
|
|
-- 99 against ROLLOUT's 90 accuracy is a miss on every roll of the turn.
|
|
battle.random = rolls({}, 99)
|
|
battle:useMove(player, wild, "ROLLOUT")
|
|
check(findText(battle:takeEvents(), "MACHOP's attack missed!"), "a miss")
|
|
eq(battle:forcedMove(player), nil, "and the miss releases the lock")
|
|
end
|
|
|
|
-- ---- Thrash / Petal Dance: EFFECT_RAMPAGE ---------------------------------
|
|
--
|
|
-- BattleCommand_Rampage rolls 1 or 2 MORE turns (effect_commands.asm:4886), so
|
|
-- the move runs for two or three turns in all; BattleCommand_CheckRampage
|
|
-- (:4851) runs the counter down and, at zero, clears the bit and writes
|
|
-- SUBSTATUS_CONFUSED with its own `and %00000001` plus two count.
|
|
do
|
|
-- rolls: the rampage length (0 -> one MORE turn, two in all), then the
|
|
-- confusion count (0 -> `and 1` plus two, two turns).
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 },
|
|
{ id = "TACKLE", pp = 35, maxPp = 35 } },
|
|
wildLevel = 80, random = rolls({ 0 }, 0) })
|
|
battle:useMove(player, wild, "THRASH")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), "THRASH", "the opening turn locks the user in")
|
|
eq(player.moves[1].pp, 19, "the opening turn spends PP")
|
|
eq(player.volatile.confuseCount, nil, "and nothing is confused yet")
|
|
|
|
battle:useMove(player, wild, "THRASH")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), nil, "the count runs out on the second turn")
|
|
eq(player.moves[1].pp, 19, "a continuing rampage skips doturn, so no PP")
|
|
eq(player.volatile.confuseCount, 2,
|
|
"CheckRampage writes the confusion count itself, `and 1` plus two")
|
|
check(wild.hp < wild.maxHp, "and the last turn still attacked")
|
|
end
|
|
|
|
do
|
|
-- The long roll: 2 more turns, so three in all.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 } },
|
|
wildLevel = 80, random = rolls({ 1 }, 0) })
|
|
battle:useMove(player, wild, "THRASH")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), "THRASH", "turn one")
|
|
battle:useMove(player, wild, "THRASH")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), "THRASH", "turn two")
|
|
battle:useMove(player, wild, "THRASH")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), nil, "turn three ends it")
|
|
check(player.volatile.confuseCount ~= nil, "and leaves the user confused")
|
|
end
|
|
|
|
do
|
|
-- Switching out is CleanUpBattleRAM: every substatus goes, lock included.
|
|
local battle, player, wild = newBattle({
|
|
playerMoves = { { id = "THRASH", pp = 20, maxPp = 20 } },
|
|
wildLevel = 80, random = rolls({ 1 }, 0) })
|
|
battle:useMove(player, wild, "THRASH")
|
|
battle:takeEvents()
|
|
eq(battle:forcedMove(player), "THRASH", "locked")
|
|
battle:clearVolatile(player)
|
|
eq(battle:forcedMove(player), nil, "a send-out zeroes the substatus")
|
|
end
|
|
|
|
S.finish()
|