Files
gen1recomp/tests/gen2_battle_end_test.lua
T
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

310 lines
12 KiB
Lua

-- What ends a round, a hit, and a battle in Gen 2.
--
-- luajit tests/gen2_battle_end_test.lua
--
-- ROM-free. Four rules the turn loop and ExitBattle carry, each one only
-- visible at the seam between two halves of a turn:
--
-- * `.wild_force_flee` ends the ROUND wherever it lands: a wild mon blown
-- away by a faster player's ROAR does not get to answer.
-- * BattleCommand_ApplyDamage runs the Endure clamp unconditionally, so a
-- mon braced at exactly 1 HP takes nothing and holds.
-- * SpikesDamage checks both type slots against FLYING before it chips.
-- * CleanUpBattleRAM: the substatus area is battle RAM, and this port keeps
-- it on the party record the save file owns, so a battle may not leave a
-- single bit behind on the way out.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle end")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
require("src.core.Logger").warn = function() end
local Battle = require("src.battle.gen2.Battle")
local BattleState = require("src.ui.gen2.BattleState")
local Input = require("src.core.Input")
local Mon = require("src.battle.gen2.Mon")
-- ---------------------------------------------------------------- fixtures
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
FLYING = { id = "FLYING", index = 2, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" },
ROAR = { id = "ROAR", name = "ROAR", power = 0, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" },
ENDURE = { id = "ENDURE", name = "ENDURE", power = 0, type = "NORMAL",
accuracy = 100, pp = 10, effect = "EFFECT_ENDURE" },
SPIKES = { id = "SPIKES", name = "SPIKES", power = 0, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_SPIKES" },
}
local GROWTH = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
}
local POKEMON = {
growthRates = GROWTH,
CYNDAQUIL = {
id = "CYNDAQUIL", index = 155, name = "CYNDAQUIL",
baseStats = { hp = 39, attack = 52, defense = 43, speed = 65,
specialAttack = 60, specialDefense = 50 },
types = { "NORMAL", "NORMAL" }, catchRate = 45, baseExp = 65,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 31,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
RATTATA = {
id = "RATTATA", index = 19, name = "RATTATA",
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
-- The Flying half of SpikesDamage's two `cp FLYING` tests: PIDGEY carries it
-- in the second slot, MURKROW in the first.
PIDGEY = {
id = "PIDGEY", index = 16, name = "PIDGEY",
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "FLYING" }, catchRate = 255, baseExp = 55,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
MURKROW = {
id = "MURKROW", index = 198, name = "MURKROW",
baseStats = { hp = 60, attack = 85, defense = 42, speed = 91,
specialAttack = 85, specialDefense = 42 },
types = { "FLYING", "NORMAL" }, catchRate = 30, baseExp = 107,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local ITEMS = {
DIRE_HIT = { id = "DIRE_HIT", name = "DIRE HIT", pocket = "ITEM" },
X_ACCURACY = { id = "X_ACCURACY", name = "X ACCURACY", pocket = "ITEM" },
GUARD_SPEC = { id = "GUARD_SPEC", name = "GUARD SPEC.", pocket = "ITEM" },
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = ITEMS,
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function mon(species, level, moves)
local m = Mon.new(DATA, species, level, { dvs = perfect })
m.moves = {}
for _, id in ipairs(moves or { "TACKLE" }) do
m.moves[#m.moves + 1] = { id = id, pp = MOVES[id].pp,
maxPp = MOVES[id].pp }
end
return m
end
-- The smallest roll that neither crits nor misses, and that lets a
-- ProtectChance roll through.
local function detRandom(n)
if (n or 1) <= 1 then return 0 end
return 1
end
local function textsOf(events)
local out = {}
for _, event in ipairs(events) do
if event.text then out[#out + 1] = event.text end
end
return out
end
local function saidSomethingLike(events, fragment)
for _, text in ipairs(textsOf(events)) do
if text:find(fragment, 1, true) then return true end
end
return false
end
-- ---- a wild Roar ends the round in the player's half ----------------------
do
local player = mon("CYNDAQUIL", 20, { "ROAR", "TACKLE" })
local wild = mon("PIDGEY", 5)
local save = { party = { player }, player = { id = 1, badges = {} } }
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
save = save, random = detRandom })
check(battle:effectiveSpeed(player) > battle:effectiveSpeed(wild),
"the player's mon is the faster of the two")
local hpBefore = player.hp
local events = battle:takeTurn({ kind = "move", move = "ROAR" })
check(saidSomethingLike(events, "fled in fear!"),
"FledInFearText: the wild mon is blown away")
check(not saidSomethingLike(events, "used TACKLE!"),
"and the mon that left never takes its half of the turn")
eq(player.hp, hpBefore, "so nothing came back the other way")
eq(battle.over, true, "the battle is over")
eq(battle.outcome, "fled", "as the cart's DRAW")
end
-- ---- Endure holds at exactly 1 HP -----------------------------------------
do
local player = mon("CYNDAQUIL", 10, { "ENDURE", "TACKLE" })
local wild = mon("RATTATA", 10)
local save = { party = { player }, player = { id = 1, badges = {} } }
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
save = save, random = detRandom })
player.hp = 1
battle:useMove(player, wild, "ENDURE")
eq(battle:volatile(player).endure, true, "the brace is up")
battle:takeEvents()
battle:useMove(wild, player, "TACKLE")
local events = battle:takeEvents()
eq(player.hp, 1, "FalseSwipe's clamp leaves the holder on its last point")
check(saidSomethingLike(events, "endured the hit!"), "with EnduredText")
-- The same brace at 2 HP still ends on 1: the clamp is MonHP - 1 either way.
local other = mon("CYNDAQUIL", 10, { "ENDURE", "TACKLE" })
local save2 = { party = { other }, player = { id = 1, badges = {} } }
local battle2 = Battle.new({ data = DATA, party = { other },
wild = mon("RATTATA", 10), save = save2, random = detRandom })
other.hp = 2
battle2:useMove(other, battle2.enemy, "ENDURE")
battle2:useMove(battle2.enemy, other, "TACKLE")
eq(other.hp, 1, "and a 2 HP brace ends on 1")
end
-- ---- Spikes: both type slots are checked against FLYING -------------------
do
local lead = mon("CYNDAQUIL", 10)
local flyer = mon("PIDGEY", 10)
local firstSlot = mon("MURKROW", 10)
local grounded = mon("RATTATA", 10)
local party = { lead, flyer, firstSlot, grounded }
local save = { party = party, player = { id = 1, badges = {} } }
local battle = Battle.new({ data = DATA, party = party,
wild = mon("RATTATA", 10, { "SPIKES", "TACKLE" }), save = save,
random = detRandom })
-- The enemy lays them on the side that will be switching into them.
battle:useMove(battle.enemy, lead, "SPIKES")
eq(battle.spikes.player, true, "SCREENS_SPIKES is on the player's side")
battle:takeEvents()
local flyerHp = flyer.hp
battle:switch(2)
local events = battle:takeEvents()
eq(flyer.hp, flyerHp, "a second-slot FLYING type takes nothing")
check(not saidSomethingLike(events, "hurt by SPIKES!"),
"and the line is not printed either")
local crowHp = firstSlot.hp
battle:switch(3)
battle:takeEvents()
eq(firstSlot.hp, crowHp, "a first-slot FLYING type takes nothing either")
local ratHp = grounded.hp
battle:switch(4)
local grounding = battle:takeEvents()
eq(grounded.hp, ratHp - math.max(1, math.floor(grounded.maxHp / 8)),
"and anything else takes GetEighthMaxHP")
check(saidSomethingLike(grounding, "hurt by SPIKES!"),
"with BattleText_UserHurtBySpikes")
end
-- ---- CleanUpBattleRAM: nothing rides the party out of the battle ----------
do
Input:init()
local player = mon("CYNDAQUIL", 20)
local party = { player }
local save = { party = party, player = { id = 1, name = "GOLD", badges = {} },
inventory = { DIRE_HIT = 2, X_ACCURACY = 1, GUARD_SPEC = 1 } }
local pushed = {}
local game = {
data = DATA, save = save, input = Input, options = {},
stack = {
push = function(_, screen) pushed[#pushed + 1] = screen end,
pop = function() table.remove(pushed) end,
top = function() return pushed[#pushed] end,
},
}
local wild = mon("RATTATA", 3)
-- One byte under 50 percent + 1 is the confusion self-hit, and this leg is
-- confused on purpose: a high byte still hits and never crits, and it lets
-- the mon act.
local battle = Battle.new({ data = DATA, party = party, wild = wild,
save = save,
random = function(n)
if n == 256 then return 200 end
if (n or 1) <= 1 then return 0 end
return 1
end })
local screen = BattleState.new(game, { battle = battle, save = save })
local outcome
screen.onDone = function(result) outcome = result end
local function drain(cap)
for _ = 1, (cap or 3000) do
Input:step()
screen:update(1 / 60)
if screen.phase == "menu" or screen.phase == "done" then return true end
end
return false
end
check(drain(), "the intro drains to the battle menu")
screen:useItem("DIRE_HIT")
check(drain(), "DIRE HIT resolves")
screen:useItem("X_ACCURACY")
check(drain(), "X ACCURACY resolves")
screen:useItem("GUARD_SPEC")
check(drain(), "GUARD SPEC resolves")
battle:volatile(player).confuseCount = 4
eq(battle:volatile(player).focusEnergy, true, "the three bits are up")
-- Win it: the enemy is one hit from fainting, and ExitBattle runs on the way
-- out of the queue.
wild.hp = 1
screen:submit({ kind = "move", move = "TACKLE" })
check(drain(), "the win drains out")
eq(screen.phase, "done", "ExitBattle finished")
eq(outcome, "win", "with the battle won")
eq(player.volatile, nil, "and the party mon carries no substatus out")
-- Which is the whole point: the next battle starts clean, and the item that
-- refuses a repeat use is usable again.
local next_ = Battle.new({ data = DATA, party = party,
wild = mon("RATTATA", 3), save = save, random = detRandom })
eq(next_:volatile(player).focusEnergy, nil, "no DIRE HIT rung carried over")
eq(next_:volatile(player).xAccuracy, nil, "no accuracy bypass either")
eq(next_:volatile(player).mist, nil, "and no Mist")
eq(next_:volatile(player).confuseCount, nil, "nor a confusion count")
eq(next_:useBattleItem("DIRE_HIT"), true,
"so a DIRE HIT is accepted in the next battle")
end
-- ---- and a battle built over a party that WAS dirty cleans it up ----------
do
local player = mon("CYNDAQUIL", 10)
-- A save written before the cleanup existed, or a battle torn down without
-- its screen: the bits are on the party record when the battle opens.
player.volatile = { focusEnergy = true, confuseCount = 3, wrapCount = 2 }
local save = { party = { player }, player = { id = 1, badges = {} } }
local battle = Battle.new({ data = DATA, party = { player },
wild = mon("RATTATA", 3), save = save, random = detRandom })
eq(player.volatile, nil,
"NewBattleMonStatus opens the battle on an empty area")
eq(battle:volatile(player).confuseCount, nil, "nothing survived the send-out")
end
S.finish()