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

506 lines
20 KiB
Lua

-- The PACK inside a Gen 2 battle: every family engine/items/item_effects.asm
-- can spend on a party mon, driven through the real BattleState:useItem.
--
-- luajit tests/gen2_battle_items_test.lua
--
-- ROM-free. The HP line already had its own flow (gen2_battle_ui_test pins
-- it); what is asserted here is the rest of UseItem_SelectMon's fan-out --
-- StatusHealingEffect and its berries, ReviveEffect on a fainted BENCHED mon,
-- RestorePPEffect's move pick for the ETHER pair and its no-pick ELIXER pair,
-- BitterBerryEffect's substatus-only cure -- plus the battle-only arm the
-- field routine has no way to reach (HealStatus clearing SUBSTATUS_CONFUSED on
-- whoever is out).
--
-- The tail is the fishing path: FishFunction writes BATTLETYPE_FISH beside the
-- hooked species, which is the single condition LureBallMultiplier reads, so
-- the rod's own encounter is walked end to end and a LURE BALL thrown in it.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 battle items")
local check, eq = S.check, S.eq
love = require("tests.love_stub")
-- The rod leg puts a real TextBox up over a stub font, which has no glyphs to
-- measure; the warning per character is noise, not a finding.
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")
local MoveDeleter = require("src.ui.gen2.MoveDeleter")
local PackMenu = require("src.ui.gen2.PackMenu")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local World = require("src.world.gen2.World")
-- ---------------------------------------------------------------- fixtures
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
WATER = { id = "WATER", index = 21, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SCRATCH = { id = "SCRATCH", name = "SCRATCH", power = 40, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
}
local GROWTH = {
GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15,
linear = 100, constant = 140 },
}
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_SLOW", genderRatio = 31,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
TOTODILE = {
id = "TOTODILE", index = 158, name = "TOTODILE",
baseStats = { hp = 50, attack = 65, defense = 64, speed = 43,
specialAttack = 44, specialDefense = 48 },
types = { "WATER", "WATER" }, catchRate = 45, baseExp = 66,
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
PIDGEY = {
id = "PIDGEY", index = 16, name = "PIDGEY",
baseStats = { hp = 40, attack = 45, defense = 40, speed = 56,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 55,
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
MAGIKARP = {
id = "MAGIKARP", index = 129, name = "MAGIKARP",
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
specialAttack = 15, specialDefense = 20 },
types = { "WATER", "WATER" }, catchRate = 45, baseExp = 20,
growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local function item(id, pocket, name)
return { id = id, pocket = pocket or "ITEM", name = name or id }
end
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = {
POTION = item("POTION"),
ANTIDOTE = item("ANTIDOTE"),
BURN_HEAL = item("BURN_HEAL"),
FULL_HEAL = item("FULL_HEAL"),
PSNCUREBERRY = item("PSNCUREBERRY"),
BITTER_BERRY = item("BITTER_BERRY"),
REVIVE = item("REVIVE"),
MAX_REVIVE = item("MAX_REVIVE"),
ETHER = item("ETHER"),
ELIXER = item("ELIXER"),
MAX_ELIXER = item("MAX_ELIXER"),
POKE_BALL = item("POKE_BALL", "BALL", "POKe BALL"),
LURE_BALL = item("LURE_BALL", "BALL", "LURE BALL"),
-- The two nibbles of data/items/attributes.asm that disagree, spelled the
-- way the extractor spells them.
RARE_CANDY = { id = "RARE_CANDY", pocket = "ITEM", name = "RARE CANDY",
fieldMenu = "ITEMMENU_PARTY", battleMenu = "ITEMMENU_NOUSE" },
OLD_ROD = { id = "OLD_ROD", pocket = "KEY", name = "OLD ROD",
fieldMenu = "ITEMMENU_CURRENT", battleMenu = "ITEMMENU_NOUSE" },
},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
-- The smallest roll that neither crits nor misses, so a spent turn resolves
-- the same way every run.
local function detRandom(n)
if (n or 1) <= 1 then return 0 end
return 1
end
local function newScreen(opts)
opts = opts or {}
Input:init()
local pushed = {}
local player = opts.player or Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
if not player.moves or #player.moves == 0 then
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
end
local party = opts.party or { player }
local wild = opts.wild or Mon.new(DATA, "PIDGEY", 5, { dvs = perfect })
wild.moves = wild.moves or { { id = "TACKLE", pp = 35, maxPp = 35 } }
local save = { party = party, inventory = opts.inventory or {} }
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 battle = Battle.new({ data = DATA, party = party, wild = wild,
save = save, random = opts.random or detRandom })
local screen = BattleState.new(game, { battle = battle, save = save })
return screen, battle, player, save, pushed
end
local function runToMenu(screen, cap)
for _ = 1, (cap or 3000) do
Input:step()
screen:update(1 / 60)
if screen.phase == "menu" then return true end
end
return false
end
-- ---- StatusHealingEffect on a BENCHED mon ---------------------------------
do
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect })
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
bench.status = "poison"
bench.toxicCounter = 3
local screen, battle, _, save, pushed = newScreen({
player = lead, party = { lead, bench },
inventory = { ANTIDOTE = 2, BURN_HEAL = 1, PSNCUREBERRY = 1 },
})
check(runToMenu(screen), "the intro drains to the battle menu")
local turn0 = battle.turn
screen:useItem("ANTIDOTE")
eq(screen.phase, "submenu", "a status cure opens UseItem_SelectMon first")
local picker = pushed[#pushed]
eq(getmetatable(picker), PartyMenu, "and the pick is the party screen")
eq(picker.prompt, PartyMenu.PROMPTS.useItem, "under the cart's own question")
picker.onChoose(2, bench)
eq(bench.status, nil, "the ANTIDOTE cured the BENCHED mon")
eq(bench.toxicCounter, nil, "and the toxic ramp went with the status byte")
eq(save.inventory.ANTIDOTE, 1, "one ANTIDOTE left the bag")
eq(battle.turn, turn0 + 1, "a cure that lands spends the turn")
check(runToMenu(screen), "the enemy's answer drains out")
-- UseStatusHealer's mask test: a clean mon, or the wrong class of item,
-- refuses without spending anything.
local turn1 = battle.turn
screen:useItem("ANTIDOTE")
pushed[#pushed].onChoose(2, bench)
eq(screen.message, "It won't have any\neffect.",
"a clean target answers the cart's refusal")
eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE still in the bag")
eq(battle.turn, turn1, "and the turn not spent")
check(runToMenu(screen), "back to the menu")
bench.status = "poison"
screen:useItem("BURN_HEAL")
pushed[#pushed].onChoose(2, bench)
eq(screen.message, "It won't have any\neffect.",
"a BURN HEAL does not answer poison")
eq(bench.status, "poison", "the status is untouched")
eq(save.inventory.BURN_HEAL, 1, "and nothing spent")
check(runToMenu(screen), "back to the menu")
-- The berries carry the same StatusHealingActions rows as the shop cures.
screen:useItem("PSNCUREBERRY")
pushed[#pushed].onChoose(2, bench)
eq(bench.status, nil, "PSNCUREBERRY cures poison like an ANTIDOTE")
eq(save.inventory.PSNCUREBERRY, nil, "and the berry is eaten")
check(runToMenu(screen), "back to the menu")
-- Backing out of the picker returns to the pack with nothing spent.
screen:useItem("ANTIDOTE")
pushed[#pushed].onCancel()
eq(getmetatable(pushed[#pushed]), PackMenu,
"cancelling the picker reopens the PACK (.SelectMon's carry path)")
eq(save.inventory.ANTIDOTE, 1, "with the ANTIDOTE untouched")
end
-- ---- IsItemUsedOnConfusedMon: the battle-only arm --------------------------
do
local screen, battle, player, save, pushed = newScreen({
inventory = { FULL_HEAL = 2, ANTIDOTE = 1 },
})
check(runToMenu(screen), "reached the menu")
-- A mon whose only complaint is the confusion volatile: the field routine
-- refuses (the status byte is clean), the battle one spends the item.
battle:volatile(player).confuseCount = 3
local turn0 = battle.turn
screen:useItem("FULL_HEAL")
pushed[#pushed].onChoose(1, player)
eq(battle:volatile(player).confuseCount, nil,
"a $ff-mask item clears SUBSTATUS_CONFUSED on whoever is out")
eq(save.inventory.FULL_HEAL, 1, "and it costs the FULL HEAL")
eq(battle.turn, turn0 + 1, "and the turn")
check(runToMenu(screen), "the turn drains")
-- The mask is what gates it: an ANTIDOTE is 1 << PSN, so it answers poison
-- and leaves the confusion standing.
player.status = "poison"
battle:volatile(player).confuseCount = 3
screen:useItem("ANTIDOTE")
pushed[#pushed].onChoose(1, player)
eq(player.status, nil, "the ANTIDOTE still cures the poison")
eq(battle:volatile(player).confuseCount, 3,
"but a masked cure leaves the confusion alone")
check(runToMenu(screen), "back to the menu")
-- BitterBerryEffect never opens the party list: it acts on the mon that is
-- out, and one that is not confused refuses.
save.inventory.BITTER_BERRY = 2
local depth = #pushed
screen:useItem("BITTER_BERRY")
eq(#pushed, depth, "a BITTER BERRY opens no picker")
eq(battle:volatile(player).confuseCount, nil, "it cured the confusion")
eq(save.inventory.BITTER_BERRY, 1, "and was eaten doing it")
check(runToMenu(screen), "the turn drains")
screen:useItem("BITTER_BERRY")
eq(screen.message, "It won't have any\neffect.",
"a clear-headed mon refuses the berry")
eq(save.inventory.BITTER_BERRY, 1, "with the berry still in the bag")
end
-- ---- ReviveEffect on a fainted BENCHED mon --------------------------------
do
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect })
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
bench.hp = 0
bench.status = "faint"
local screen, battle, _, save, pushed = newScreen({
player = lead, party = { lead, bench },
inventory = { REVIVE = 2, MAX_REVIVE = 1 },
})
check(runToMenu(screen), "reached the menu")
-- A healthy target is RevivePokemon's `ret nz`: nothing spent.
screen:useItem("REVIVE")
pushed[#pushed].onChoose(1, lead)
eq(screen.message, "It won't have any\neffect.",
"a live mon refuses the REVIVE")
eq(save.inventory.REVIVE, 2, "with both REVIVEs still in the bag")
check(runToMenu(screen), "back to the menu")
local maxHp = bench.maxHp or bench.stats.hp
local turn0 = battle.turn
screen:useItem("REVIVE")
pushed[#pushed].onChoose(2, bench)
eq(bench.hp, math.max(1, math.floor(maxHp / 2)),
"REVIVE stands the BENCHED mon up at half max HP (ReviveHalfHP)")
eq(bench.status, nil, "with its status byte cleared")
eq(save.inventory.REVIVE, 1, "one REVIVE spent")
eq(battle.turn, turn0 + 1, "and the turn spent with it")
check(runToMenu(screen), "the turn drains")
bench.hp = 0
screen:useItem("MAX_REVIVE")
pushed[#pushed].onChoose(2, bench)
eq(bench.hp, maxHp, "MAX REVIVE takes ReviveFullHP instead")
eq(save.inventory.MAX_REVIVE, nil, "and is spent")
check(runToMenu(screen), "the turn drains")
-- The revived mon is a mon again as far as the engine is concerned: it can
-- be switched to, which is the whole point of reviving mid-battle.
check(battle:switch(2), "the revived mon can be sent out")
eq(battle.player, bench, "and it is the one in play")
end
-- ---- RestorePPEffect: the ETHER pick and the ELIXER sweep ------------------
do
local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
player.moves = {
{ id = "TACKLE", pp = 35, maxPp = 35 },
{ id = "SCRATCH", pp = 4, maxPp = 35 },
}
local screen, battle, _, save, pushed = newScreen({
player = player,
inventory = { ETHER = 2, ELIXER = 1, MAX_ELIXER = 1 },
})
check(runToMenu(screen), "reached the menu")
screen:useItem("ETHER")
eq(getmetatable(pushed[#pushed]), PartyMenu, "the ETHER picks a mon first")
pushed[#pushed].onChoose(1, player)
local moveList = pushed[#pushed]
eq(getmetatable(moveList), MoveDeleter,
"then the move list (MoveSelectionScreen shares SetUpMoveList with it)")
eq(#pushed, 2, "stacked over the party list, which is still standing")
-- Backing out of the move list drops only that screen, which is
-- RestorePPEffect's own `jr nz, .loop` back to the pick.
moveList.onCancel()
eq(#pushed, 1, "cancelling the move list leaves the party list up")
eq(getmetatable(pushed[#pushed]), PartyMenu, "on the party list")
eq(save.inventory.ETHER, 2, "with nothing spent")
pushed[#pushed].onChoose(1, player)
local turn0 = battle.turn
pushed[#pushed].onChoose(2)
eq(player.moves[2].pp, 14, "the ETHER put 10 PP back into the chosen slot")
eq(player.moves[1].pp, 35, "and left the other slot alone")
eq(save.inventory.ETHER, 1, "one ETHER spent")
eq(battle.turn, turn0 + 1, "and the turn with it")
check(runToMenu(screen), "the turn drains")
-- A slot already at max is `cp b / jr nc, .dont_restore`: nothing spent.
screen:useItem("ETHER")
pushed[#pushed].onChoose(1, player)
pushed[#pushed].onChoose(1)
eq(screen.message, "It won't have any\neffect.",
"a full slot answers the refusal")
eq(save.inventory.ETHER, 1, "with the ETHER still in the bag")
check(runToMenu(screen), "back to the menu")
-- Elixer_RestorePPofAllMoves needs no pick at all.
player.moves[1].pp = 30
player.moves[2].pp = 14
local depth = #pushed
screen:useItem("ELIXER")
pushed[#pushed].onChoose(1, player)
eq(#pushed, depth, "the ELIXER opened no move list")
eq(player.moves[1].pp, 35, "it filled the first slot (capped at max)")
eq(player.moves[2].pp, 24, "and walked on to the second")
eq(save.inventory.ELIXER, nil, "spending the ELIXER")
check(runToMenu(screen), "the turn drains")
screen:useItem("MAX_ELIXER")
pushed[#pushed].onChoose(1, player)
eq(player.moves[2].pp, 35, "MAX ELIXER takes the .restore_all arm")
eq(save.inventory.MAX_ELIXER, nil, "and is spent")
end
-- ---- an EGG refuses every family before anything runs ---------------------
do
local screen, _, _, save, pushed = newScreen({
inventory = { ANTIDOTE = 1, REVIVE = 1, ETHER = 1 },
})
check(runToMenu(screen), "reached the menu")
for _, id in ipairs({ "ANTIDOTE", "REVIVE", "ETHER" }) do
screen:useItem(id)
pushed[#pushed].onChoose(3, { isEgg = true, hp = 0, maxHp = 12,
moves = { { id = "TACKLE", pp = 0, maxPp = 35 } } })
eq(screen.message, "That can't be used\non an EGG.",
id .. " answers CantUseOnEggMessage")
eq(save.inventory[id], 1, "and is not spent")
check(runToMenu(screen), "back to the menu")
end
end
-- ---- ITEMMENU_NOUSE in a battle is .Oak -----------------------------------
do
local screen, battle, player, save, pushed = newScreen({
inventory = { RARE_CANDY = 1, OLD_ROD = 1 },
})
check(runToMenu(screen), "reached the menu")
local level, turn0, depth = player.level, battle.turn, #pushed
screen:useItem("RARE_CANDY")
eq(#pushed, depth, "a RARE CANDY opens no party list in a battle")
eq(player.level, level, "and levels nothing")
eq(save.inventory.RARE_CANDY, 1, "with the candy still in the bag")
eq(battle.turn, turn0, "and the turn not spent")
screen:useItem("OLD_ROD")
eq(save.inventory.OLD_ROD, 1, "a rod is ITEMMENU_NOUSE in battle too")
end
-- ---- the rod's own battle carries BATTLETYPE_FISH --------------------------
do
local pushed = {}
local save = { player = {}, party = {}, inventory = { LURE_BALL = 1,
POKE_BALL = 1 } }
local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
save.party[1] = player
-- The rod's line rides a real TextBox, so the world needs an input its
-- update can read; `press` is the A that turns the page.
local fakeInput = { pressed = {} }
function fakeInput:press(button) self.pressed[button] = true end
function fakeInput:wasPressed(button)
if self.pressed[button] then
self.pressed[button] = nil
return true
end
return false
end
function fakeInput:isDown() return false end
local game = {
data = DATA, save = save, input = fakeInput, options = {},
stack = {
push = function(_, screen) pushed[#pushed + 1] = screen end,
pop = function() table.remove(pushed) end,
top = function() return pushed[#pushed] end,
},
}
local world = World.new(game)
-- Script_GotABite's tail, with the state the rod itself left behind: the
-- hooked mon, the bite bob already served, the rod being put away. No map
-- is mounted, so DoBattleTransition has nothing to wipe and the battle comes
-- straight in, which is the same arm a headless run takes.
local hooked = Mon.new(DATA, "MAGIKARP", 10, { dvs = perfect })
hooked.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
world.fishing = { phase = "bite", timer = 0, outcome = "battle",
wild = hooked }
world:updateFishing()
-- RodBiteText is up; A pages it, and its dismissal is what runs startbattle.
local screen
for _ = 1, 600 do
local top = pushed[#pushed]
if top and getmetatable(top) == BattleState then screen = top break end
fakeInput:press("a")
if top and top.update then top:update(1 / 60) end
end
eq(world.fishing, nil, "PutTheRodAway clears the rod state before the battle")
check(screen ~= nil, "the rod's encounter pushed a battle")
eq(screen.battle.battleType, "fish",
"carrying BATTLETYPE_FISH out of FishFunction's .goodtofish")
-- LureBallMultiplier's x3, through the real throw. A full-HP MAGIKARP at
-- catch rate 45 computes to 15; the LURE BALL's tripled rate is 45, so a
-- fixed roll of 30 catches with one ball and not with the other.
local function throw(ball, type_)
Input:init()
local wild = Mon.new(DATA, "MAGIKARP", 10, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
local bagSave = { party = { lead }, inventory = { [ball] = 1 } }
local shot = {
data = DATA, save = bagSave, input = Input, options = {},
stack = { push = function() end, pop = function() end,
top = function() return nil end },
}
local battle = Battle.new({ data = DATA, party = { lead }, wild = wild,
save = bagSave, battleType = type_,
random = function(n) if n == 256 then return 30 end return 0 end })
local state = BattleState.new(shot, { battle = battle, save = bagSave })
state:useItem(ball)
return battle.outcome
end
eq(throw("LURE_BALL", screen.battle.battleType), "caught",
"a LURE BALL thrown in the rod's battle catches on that roll")
eq(throw("LURE_BALL", nil), nil,
"the same ball and the same roll off a rod does not")
eq(throw("POKE_BALL", screen.battle.battleType), nil,
"and neither does a plain ball in the rod's battle")
end
S.finish()