-- The Gen 2 battle screen against the cart's own HUD and pacing rules: -- minimum damage out of BattleCommand_DamageCalc's tail, the -6/+6 stat stage -- wall, the HP bar chase (engine/battle/anim_hp_bar.asm), the status tag in -- the level's spot (PlaceNonFaintStatus), BattleMenu's empty textbox, the -- intro slide's one-piece back pic, and ItemRestoreHP's pick-a-mon flow. -- -- GOLD_CACHE="..." luajit tests/gen2_battle_ui_test.lua -- -- ROM-free: fixtures below are the extractor's shapes; the one cache-fed -- section (the GROWL animation's cry) skips cleanly when there is no cache. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 battle ui") local check, eq = S.check, S.eq love = require("tests.love_stub") local Battle = require("src.battle.gen2.Battle") local BattleAnimView = require("src.ui.gen2.BattleAnimView") local BattleState = require("src.ui.gen2.BattleState") local Damage = require("src.battle.gen2.Damage") local Input = require("src.core.Input") local Mon = require("src.battle.gen2.Mon") local PackMenu = require("src.ui.gen2.PackMenu") local PartyMenu = require("src.ui.gen2.PartyMenu") local Sound = require("src.core.Sound") -- ---------------------------------------------------------------- fixtures local TYPES = { NORMAL = { id = "NORMAL", index = 0, category = "physical" }, FLYING = { id = "FLYING", index = 2, category = "physical" }, GROUND = { id = "GROUND", index = 4, category = "physical" }, ROCK = { id = "ROCK", index = 5, category = "physical" }, FIRE = { id = "FIRE", index = 20, category = "special" }, WATER = { id = "WATER", index = 21, category = "special" }, ELECTRIC = { id = "ELECTRIC", index = 23, category = "special" }, } local MATCHUPS = { { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, { attacker = "FIRE", defender = "WATER", multiplier = 5 }, { attacker = "ELECTRIC", defender = "GROUND", multiplier = 0 }, } local MOVES = { TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", accuracy = 95, pp = 35, effect = "EFFECT_NORMAL_HIT" }, GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL", accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" }, THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDERWAVE", power = 0, type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" }, } 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 = { "FIRE", "FIRE" }, 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", "FLYING" }, catchRate = 255, baseExp = 55, growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 127, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, GEODUDE = { id = "GEODUDE", index = 74, name = "GEODUDE", baseStats = { hp = 40, attack = 80, defense = 100, speed = 20, specialAttack = 30, specialDefense = 30 }, types = { "ROCK", "GROUND" }, catchRate = 255, baseExp = 73, growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {}, }, } local DATA = { pokemon = POKEMON, moves = MOVES, type_chart = { types = TYPES, matchups = MATCHUPS }, items = { -- `index` is the ROM item id, which is what wBattleAnimParam carries. MASTER_BALL = { id = "MASTER_BALL", pocket = "BALL", name = "MASTER BALL", index = 1 }, POKE_BALL = { id = "POKE_BALL", pocket = "BALL", name = "POKe BALL", index = 5 }, FAST_BALL = { id = "FAST_BALL", pocket = "BALL", name = "FAST BALL", index = 160 }, POTION = { id = "POTION", pocket = "ITEM", name = "POTION" }, }, } local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } perfect.hp = Mon.hpDV(perfect) -- Deterministic and range-respecting: the smallest roll that is neither a -- critical hit (rollCritical wants 0) nor a miss. local function detRandom(n) if (n or 1) <= 1 then return 0 end return 1 end -- ---- DamageCalc's minimum (engine/battle/effect_commands.asm) ------------- do -- Stats weak enough that the raw formula floors to 0: the cart still walks -- out of DamageCalc with MIN_DAMAGE (2). local weak = Damage.calc({ level = 5, power = 35, moveType = "NORMAL", attacker = { attack = 6, types = { "FIRE" } }, defender = { defense = 40, types = { "WATER" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) eq(weak, Damage.MIN_DAMAGE, "a hit whose stat math floors to nothing still deals MIN_DAMAGE") -- The same hit into a resist: BattleCommand_Stab's zero-quotient arm forces -- the row's floor back up to 1, so not-very-effective never reads 0. local resisted = Damage.calc({ level = 5, power = 35, moveType = "NORMAL", attacker = { attack = 6, types = { "FIRE" } }, defender = { defense = 40, types = { "ROCK" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) check(resisted >= 1, "a resisted hit that lands deals at least 1") local _, info = Damage.calc({ level = 5, power = 35, moveType = "NORMAL", attacker = { attack = 6, types = { "FIRE" } }, defender = { defense = 40, types = { "ROCK" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) eq(info.effectiveness, 5, "and still reports not-very-effective") -- Immunity is the one zero left: the row's multiplier IS 0. local immune = Damage.calc({ level = 50, power = 95, moveType = "ELECTRIC", attacker = { specialAttack = 100, types = { "ELECTRIC" } }, defender = { specialDefense = 50, types = { "GROUND" } }, types = TYPES, matchups = MATCHUPS, variation = 100, }) eq(immune, 0, "an immune matchup still deals nothing at all") end -- ---- the same minimum through the real turn engine ------------------------ do local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local wild = Mon.new(DATA, "GEODUDE", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local battle = Battle.new({ data = DATA, party = { player }, wild = wild, random = detRandom }) local before = wild.hp local events = battle:takeTurn({ kind = "move", move = "TACKLE" }) local dealt, sawNve = nil, false for _, event in ipairs(events) do if event.kind == "damage" and event.side == "enemy" and not dealt then dealt = event.amount end if event.kind == "message" and event.text == "It's not very effective..." then sawNve = true end end check(sawNve, "TACKLE into GEODUDE reads as not very effective") check((dealt or 0) >= 1, "and the landed hit dealt at least 1 HP, not 0") eq(wild.hp, before - dealt, "with the HP moving by exactly that much") end -- ---- the -6 stage wall (BattleCommand_StatDown's .CantLower) -------------- do local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) player.moves = { { id = "GROWL", pp = 40, maxPp = 40 } } player.hp = 200 player.maxHp = 200 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local battle = Battle.new({ data = DATA, party = { player }, wild = wild, random = detRandom }) for turn = 1, 6 do battle:takeTurn({ kind = "move", move = "GROWL" }) eq(battle.stages.enemy.attack, -turn, "GROWL " .. turn .. " lands one stage") end local events = battle:takeTurn({ kind = "move", move = "GROWL" }) eq(battle.stages.enemy.attack, -6, "the seventh GROWL moves nothing") local refused = false for _, event in ipairs(events) do if event.kind == "message" and event.text == "PIDGEY's ATTACK won't drop anymore!" then refused = true end end check(refused, "and the cart's refusal line is emitted instead") -- The stage -6 multiplier is 25/100 with a floor of 1: a stat can shrink to -- a quarter but never to zero. eq(Damage.applyStage(11, -6), math.max(1, math.floor(11 * 25 / 100)), "stage -6 is a quarter of the stat") eq(Damage.applyStage(1, -6), 1, "and never zero") eq(Damage.applyStage(11, -9), Damage.applyStage(11, -6), "stages below -6 read as -6") end -- ---- BattleState with a stub game ----------------------------------------- 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 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, } 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 { POTION = 2, POKE_BALL = 3 } } 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, random = detRandom }) local screen = BattleState.new(game, { battle = battle, save = save }) return screen, battle, player, wild, save, pushed end local function run(screen, frames) for _ = 1, frames do Input:step() screen:update(1 / 60) end 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 -- ---- BattleMenu empties the textbox --------------------------------------- do local screen = newScreen() eq(screen.shownHp.enemy, screen.battle.enemy.hp, "the bar opens on the real HP") check(runToMenu(screen), "the intro drains to the battle menu") eq(screen.message, nil, "BattleMenu runs EmptyBattleTextbox: no prompt beside the 2x2 menu") end -- ---- the HP bar chase ----------------------------------------------------- do local screen, battle, player, wild = newScreen() check(runToMenu(screen), "reached the menu") local shown0 = screen.shownHp.enemy eq(shown0, wild.hp, "the enemy bar sits on the wild mon's real HP") screen:submit({ kind = "move", move = "TACKLE" }) check(wild.hp < shown0, "the engine has already resolved the whole turn") eq(screen.shownHp.enemy, shown0, "but the drawn bar has not spoiled it: it still shows the pre-turn HP") local sawIntermediate = false for _ = 1, 3000 do Input:step() screen:update(1 / 60) local shown = screen.shownHp.enemy if shown < shown0 and shown > wild.hp then sawIntermediate = true end if screen.phase == "menu" then break end end eq(screen.phase, "menu", "the turn drains back to the menu") check(sawIntermediate, "the bar walked down through the middle values, one tick a frame") eq(screen.shownHp.enemy, wild.hp, "and settled on the real enemy HP") eq(screen.shownHp.player, player.hp, "the player's bar caught its own hit too") end -- ---- the drain is sized by the mon whose bar is ON SCREEN ----------------- do -- wCurHPAnimMaxHP is loaded from the battle struct of the mon being drawn -- (wEnemyMonMaxHP -> wHPBuffer1, engine/battle/effect_commands.asm:3399-3404), -- and after a faint that is still the OUTGOING mon: the engine rebinds -- battle.enemy inside takeTurn, a whole queue before the replacement's -- `send` event is dequeued. local screen, battle = newScreen() check(runToMenu(screen), "reached the menu") local outgoing = { maxHp = 240, hp = 240 } local replacement = { maxHp = 24, hp = 24 } screen.shownMon.enemy = outgoing battle.enemy = replacement screen.shownHp.enemy = 240 screen.hpAnim = { side = "enemy", to = 0 } local ticks = 0 while screen.hpAnim and ticks < 400 do screen:stepHpAnim() ticks = ticks + 1 end eq(screen.shownHp.enemy, 0, "the outgoing mon's bar drained to zero") eq(ticks, 240 / math.ceil(240 / 48), "one PIXEL a tick out of the OUTGOING mon's 240 max HP, not one hit " .. "point a tick out of the replacement's 24") end -- ---- the exp bar crawls, and the level waits for it ----------------------- do -- AnimateExpBar (engine/battle/core.asm:7191) runs BEFORE the exp is -- committed and walks the bar one pixel at a time, topping out at 64 and -- restarting at 0 for each level crossed, with wBattleMonLevel advanced only -- as each segment fills (:7267-7274). local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW -- One point short of level 6, so whatever the kill pays levels the mon. player.experience = Mon.experienceForLevel(growth, 6) - 1 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } wild.hp = 1 local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") eq(screen.shownLevel, 5, "the HUD opens on the mon's real level") local startExp = screen.shownExp check(startExp > 0 and startExp < 64, "and on its real place along the bar (CalcExpBar's 64 pixels)") screen:submit({ kind = "move", move = "TACKLE" }) eq(wild.hp, 0, "the wild mon went down on the first TACKLE") eq(player.level, 6, "and the engine has already levelled the player") eq(screen.shownLevel, 5, "but the HUD still prints the pre-kill level, several boxes before the " .. "grew-to-level line") eq(screen.shownExp, startExp, "and the bar has not moved yet either") local crawled, restarted = false, false for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then crawled = true end if screen.shownLevel == 6 and (screen.shownExp or 0) == 0 then restarted = true end if screen.phase == "done" then break end end eq(screen.phase, "done", "the win drains out") check(crawled, "the bar walked up through the middle pixels") check(restarted, "topped out, took the level with it and restarted the segment at 0") eq(screen.shownLevel, player.level, "the HUD level caught up") eq(screen.shownExp, screen:expPixels(player, player.level, player.experience), "and the bar landed on the mon's real place in level 6") end -- ---- a level-up REDRAWS the HP bar, it does not animate it ---------------- do -- GiveExperiencePoints' `.skip_active_mon_update` guard -- (engine/battle/core.asm:6999-7003) copies the recalculated HP and max HP -- into the battle struct for the mon that is OUT and calls UpdatePlayerHUD -- (:7034), which DRAWS the bar rather than chasing it. local player = Mon.new(DATA, "CYNDAQUIL", 5, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local growth = DATA.pokemon.growthRates.GROWTH_MEDIUM_SLOW player.experience = Mon.experienceForLevel(growth, 6) - 1 local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } wild.hp = 1 local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") local maxBefore = player.maxHp screen:submit({ kind = "move", move = "TACKLE" }) for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.phase == "done" then break end end check(player.maxHp > maxBefore, "the level-up raised the maximum") eq(screen.shownHp.player, player.hp, "and the bar carries the HP the level-up added, without waiting for the " .. "next damage or heal event") end -- ---- the status tag takes the level's spot -------------------------------- do local screen, battle, _, wild = newScreen() check(runToMenu(screen), "reached the menu") screen:submit({ kind = "move", move = "THUNDER_WAVE" }) eq(wild.status, "paralyze", "THUNDER WAVE paralyzed the wild mon") eq(screen:statusTag(wild), "PAR", "so the enemy HUD wears PAR where the level was") eq(screen:statusTag({ status = "poison" }), "PSN", "PSN for poison") eq(screen:statusTag({ status = "toxic" }), "PSN", "toxic wears the same PSN bit") eq(screen:statusTag({ status = "burn" }), "BRN", "BRN for burn") eq(screen:statusTag({ status = "freeze" }), "FRZ", "FRZ for freeze") eq(screen:statusTag({ status = "sleep" }), "SLP", "SLP for sleep") eq(screen:statusTag({ status = "confuse" }), nil, "confusion is a substatus on the cart and shows no tag") eq(screen:statusTag({}), nil, "no status, no tag: the level prints") end -- ---- the intro slide's one-piece back pic --------------------------------- do -- CopyBackpic's OAM copy crosses 144px in the 72 frames, 2 a frame. eq(BattleAnimView.slideBackpicOffset(0), 144, "the back pic starts just off the right edge") eq(BattleAnimView.slideBackpicOffset(36), 72, "halfway at frame 36") eq(BattleAnimView.slideBackpicOffset(72), 0, "and lands on its column") local top, middle = BattleAnimView.slideOffsets(72) eq(top, 0, "the enemy band finishes in place") eq(middle, 0, "so does the player band") -- While the bands are baked, the player-side pic is withheld from them and -- drawn only by the overlay, so it cannot tear at the $40 boundary. local screen = newScreen() screen.slidingBackpic = true local ok = pcall(screen.drawPic, screen, screen.battle.player, true) check(ok, "drawPic returns cleanly while the overlay owns the back pic") end -- ---- ItemRestoreHP: pick a mon, heal that 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 = bench.hp - 15 local screen, battle, _, _, save, pushed = newScreen({ player = lead, party = { lead, bench } }) check(runToMenu(screen), "reached the menu") local turn0 = battle.turn screen:useItem("POTION") eq(screen.phase, "submenu", "a healing item opens a submenu, not a heal") local picker = pushed[#pushed] eq(getmetatable(picker), PartyMenu, "and the submenu is the party screen (UseItem_SelectMon)") eq(picker.prompt, PartyMenu.PROMPTS.useItem, "asking the cart's own question") local benchBefore = bench.hp picker.onChoose(2, bench) eq(bench.hp, math.min(bench.maxHp or bench.stats.hp, benchBefore + 20), "the POTION landed on the BENCHED mon") eq(save.inventory.POTION, 1, "and one POTION left the bag") eq(battle.turn, turn0 + 1, "a heal that lands spends the turn") check(runToMenu(screen), "the enemy's answer drains out") -- A full-HP target refuses without costing the item or the turn -- (IsMonAtFullHealth's arm of ItemRestoreHP). local turn1 = battle.turn screen:useItem("POTION") pushed[#pushed].onChoose(2, bench) eq(screen.message, "It won't have any\neffect.", "a full-HP target answers the cart's refusal") eq(save.inventory.POTION, 1, "with the POTION still in the bag") eq(battle.turn, turn1, "and the turn not spent") check(runToMenu(screen), "back to the menu") -- An egg is refused before the health check (UseItem_SelectMon .not_egg). screen:useItem("POTION") pushed[#pushed].onChoose(3, { isEgg = true, hp = 1, maxHp = 12 }) eq(screen.message, "That can't be used\non an EGG.", "an EGG answers CantUseOnEggMessage") eq(save.inventory.POTION, 1, "still nothing spent") check(runToMenu(screen), "back to the menu") -- Backing out of the picker returns to the pack with nothing spent. screen:useItem("POTION") pushed[#pushed].onCancel() eq(getmetatable(pushed[#pushed]), PackMenu, "cancelling the picker reopens the PACK (.SelectMon's carry path)") eq(save.inventory.POTION, 1, "with the POTION untouched") end -- ---- BattleMenu_PKMN opens BattleMonMenu over the list -------------------- do -- `callfar BattleMonMenu`, then SWITCH -> TryPlayerSwitch, CANCEL -> -- .Cancel -> BattleMenu (engine/battle/core.asm:4810-4816); the voluntary -- list is PARTYMENUACTION_CHOOSE_POKEMON and the forced one -- PARTYMENUACTION_SWITCH (:4795, :2702). 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 } } local screen, battle, _, _, _, pushed = newScreen({ player = lead, party = { lead, bench } }) check(runToMenu(screen), "reached the menu") check(screen:openParty(), "the voluntary list opens") local list = pushed[#pushed] eq(list.prompt, PartyMenu.PROMPTS.choose, "asking ChooseAMonString") check(list.wantsBattleSubmenu, "and it carries BattleMonMenu") list.index = 2 list:openSubmenu() eq(#list.submenu.items, 3, "BattleMonMenu has three rows") eq(list.submenu.items[1].id, "SWITCH", "SWITCH first") eq(list.submenu.items[2].id, "STATS", "then STATS") eq(list.submenu.items[3].id, "CANCEL", "then CANCEL") -- CANCEL leaves the list entirely and lands back on the battle menu. list.submenu.index = 3 list:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) eq(screen.phase, "menu", ".Cancel returns to BattleMenu") -- SWITCH takes the pick through to the engine. check(screen:openParty(), "the list reopens") local list2 = pushed[#pushed] list2.index = 2 list2:openSubmenu() list2:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) check(runToMenu(screen), "the switch drains back to the menu") eq(battle.player, bench, "SWITCH reached TryPlayerSwitch") -- BattleText_MonIsAlreadyOut, and the turn is NOT spent (:4863-4870). local turn0 = battle.turn check(screen:openParty(), "the list opens again") local list3 = pushed[#pushed] list3.index = 2 list3:openSubmenu() list3:updateSubmenu({ wasPressed = function(_, b) return b == "a" end }) eq(screen.phase, "refuse-switch", "the mon already out is refused") check((screen.message or ""):find("is already out"), "with BattleText_MonIsAlreadyOut") eq(battle.turn, turn0, "and no turn is spent on it") -- The forced list is PickPartyMonInBattle: WhichPKMNString, no submenu. check(screen:openParty(true), "the forced list opens") local forced = pushed[#pushed] eq(forced.prompt, PartyMenu.PROMPTS.which, "asking WhichPKMNString") eq(forced.wantsBattleSubmenu, false, "with no BattleMonMenu over it") end -- ---- SHIFT offers a free swap when the trainer sends its next mon --------- do -- EnemySwitch's shift arm: OfferSwitch, then the enemy's send-out, then -- `jp PlayerSwitch` with both participant bitfields zeroed -- (engine/battle/core.asm:2941-2963); CheckWhetherToAskSwitch reads the -- BATTLE_SHIFT bit CLEAR as SHIFT (:3269-3295). local function shiftScreen(style) Input:init() local pushed = {} local lead = Mon.new(DATA, "CYNDAQUIL", 30, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local bench = Mon.new(DATA, "TOTODILE", 30, { dvs = perfect }) bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local foe1 = Mon.new(DATA, "PIDGEY", 3, { dvs = perfect }) foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local foe2 = Mon.new(DATA, "GEODUDE", 3, { dvs = perfect }) foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local party = { lead, bench } local save = { party = party, inventory = {} } local game = { data = DATA, save = save, input = Input, options = { battleStyle = style }, 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, random = detRandom, trainer = { class = "FALKNER", name = "FALKNER", party = { foe1, foe2 } } }) local screen = BattleState.new(game, { battle = battle, save = save }) return screen, battle, party, pushed end local function runToPhase(screen, phase, cap) for _ = 1, (cap or 4000) do Input:step() screen:update(1 / 60) if screen.phase == phase then return true end end return false end -- SET never asks (`bit BATTLE_SHIFT, a / jr nz, .return_nc`, :3280-3282). local setScreen, setBattle = shiftScreen("SET") check(runToMenu(setScreen), "the SET battle reaches its menu") setBattle.enemy.hp = 1 setScreen:submit({ kind = "move", move = "TACKLE" }) check(runToMenu(setScreen), "the KO drains without stopping") eq(setScreen.phase, "menu", "SET is never offered a shift") -- SHIFT stops on the prompt before the enemy's send-out line. local noScreen, noBattle, noParty = shiftScreen("SHIFT") check(runToMenu(noScreen), "the SHIFT battle reaches its menu") noBattle.enemy.hp = 1 local noTurn = noBattle.turn noScreen:submit({ kind = "move", move = "TACKLE" }) check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch") check((noScreen.message or ""):find("is about to use"), "with BattleText_EnemyIsAboutToUseWillPlayerChangeMon") -- NO falls through to the send-out with nothing switched and nothing spent. noScreen.messageTimer = 0 noScreen.shiftIndex = 2 Input:overlayPressed("a") Input:step() noScreen:update(1 / 60) Input:overlayReleased("a") check(runToMenu(noScreen), "NO drains back to the menu") eq(noBattle.player, noParty[1], "the lead is still out") eq(noBattle.turn, noTurn + 1, "and only the attacking turn was spent") -- YES picks a mon, and the switch lands AFTER the enemy's send-out. local yesScreen, yesBattle, yesParty, pushed = shiftScreen("SHIFT") check(runToMenu(yesScreen), "the second SHIFT battle reaches its menu") yesBattle.enemy.hp = 1 yesScreen:submit({ kind = "move", move = "TACKLE" }) check(runToPhase(yesScreen, "ask-shift"), "it stops on OfferSwitch too") yesScreen.messageTimer = 0 yesScreen.shiftIndex = 1 Input:overlayPressed("a") Input:step() yesScreen:update(1 / 60) Input:overlayReleased("a") eq(yesScreen.phase, "submenu", "YES opens PickSwitchMonInBattle") local picker = pushed[#pushed] eq(picker.prompt, PartyMenu.PROMPTS.which, "asking WhichPKMNString") eq(picker.wantsBattleSubmenu, false, "with no BattleMonMenu over it") picker.onChoose(2, yesParty[2]) check(runToMenu(yesScreen), "the shift drains back to the menu") eq(yesBattle.player, yesParty[2], "the picked mon is out") eq(yesBattle.participants[1], nil, "and both participant bitfields were zeroed first") eq(yesBattle.participants[2], true, "leaving only the incoming slot") end -- ---- healing the active mon refills its bar on screen --------------------- do local screen, battle, player = newScreen() check(runToMenu(screen), "reached the menu") -- Take a hit so there is something to refill. screen:submit({ kind = "move", move = "TACKLE" }) check(runToMenu(screen), "first exchange drains") check(player.hp < (player.maxHp or player.stats.hp), "the player took damage") eq(screen.shownHp.player, player.hp, "and the bar followed it down") local screenSave = screen.save screenSave.inventory.POTION = 1 screen:useItem("POTION") local picker = screen.game.stack.top() picker.onChoose(1, player) check(screen.hpAnim ~= nil and screen.hpAnim.side == "player", "healing the ACTIVE mon arms the bar chase (HealHP_SFX_GFX's AnimateHPBar)") check(runToMenu(screen), "the heal turn drains") eq(screen.shownHp.player, player.hp, "and the bar refilled to the new HP") end -- ---- GROWL's sound comes from its own anim script (cache-fed) ------------- do local cache = os.getenv("GOLD_CACHE") if not cache then local home = os.getenv("HOME") or "" cache = home .. "/Library/Application Support/LOVE/gold-dev/gold" end local path = cache .. "/data/generated/battle_anims.lua" local file = io.open(path, "r") if not file then check(true, "no Gold cache at " .. path .. " (SKIP)") else file:close() local anims = assert(loadfile(path))() if not (anims.scripts and anims.moves and anims.moves.GROWL) then check(true, "battle_anims.lua has no GROWL script (SKIP)") else local AnimRunner = require("src.battle.gen2.AnimRunner") local constants = assert(loadfile(cache .. "/data/generated/constants.lua"))() local cried, sounds = nil, 0 local runner = AnimRunner.new({ data = anims, constants = constants, battleTurn = 0, animId = "GROWL", hooks = { cry = function(side, pitch, length) cried = side end, sound = function() sounds = sounds + 1 end, }, }) runner:start(anims.moves.GROWL) local frames = 0 while runner:step() and frames < 600 do frames = frames + 1 end -- BattleAnim_Growl (data/moves/animations.asm): `anim_cry $0` plays the -- USER's cry, which is the move's whole sound. eq(cried, "player", "GROWL's script asks for the user's own cry") check(frames > 0 and frames < 600, "and the script terminates") -- The chain the SCREEN's cry hook walks, end to end: the same GROWL -- script through BattleState:animForMove must reach Sound.playCry with -- the user's SPECIES, because audio.cries is keyed by species (the -- def.cry field the hook once read does not exist in a Gen 2 cache). local audio = assert(loadfile(cache .. "/data/generated/audio.lua"))() check(audio.cries and audio.cries.CYNDAQUIL ~= nil, "audio.lua keys the cries by species") local screen = newScreen() screen.game.data.gen2BattleAnims = anims screen.game.data.gen2Constants = constants screen.game.data.audio = audio screen.anims = anims screen.animConstants = constants check(runToMenu(screen), "reached the menu with the anim runtime live") local Sound = require("src.core.Sound") local realPlayCry = Sound.playCry local played = nil Sound.playCry = function(_, species) played = species end check(screen:animForMove("GROWL", "player"), "the extracted GROWL animation starts") for _ = 1, 600 do if not screen.anim then break end Input:step() screen:update(1 / 60) end Sound.playCry = realPlayCry eq(screen.anim, nil, "the animation ran out") eq(played, "CYNDAQUIL", "and GROWL played the user's cry through Sound.playCry") end end end -- ---- the cursor bytes that live across menu openings ---------------------- -- -- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1 -- (engine/battle/core.asm:5111) and the A-press writes the picked row back, so -- the FIGHT list reopens on the move used last turn. SendOutPlayerMon -- (core.asm:3809) is what zeroes it, together with wBattleMenuCursorPosition. do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } } local screen = newScreen({ player = lead }) check(runToMenu(screen), "reached the menu") local function tap(button) Input:overlayPressed(button) Input:step() screen:update(1 / 60) Input:overlayReleased(button) Input:step() screen:update(1 / 60) end tap("a") -- FIGHT eq(screen.phase, "moves", "A on FIGHT opens the move list") tap("down") eq(screen.moveIndex, 2, "down moves onto the second move") tap("a") check(runToMenu(screen), "the turn drains back to the menu") eq(screen.moveIndex, 2, "wCurMoveNum survived the turn") tap("a") eq(screen.phase, "moves", "FIGHT reopens") eq(screen.moveIndex, 2, "on the move used last turn, not on the first") -- A moveset that shrank clamps rather than pointing past the end. tap("b") screen.moveIndex = 4 screen.battle.player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } tap("a") eq(screen.phase, "moves", "FIGHT opens again") eq(screen.moveIndex, 1, "and the row is clamped to the moves that are left") end -- SendOutPlayerMon zeroes both cursor bytes, so a switched-in mon opens on -- FIGHT and on its first move. do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GROWL", pp = 40, maxPp = 40 } } local bench = Mon.new(DATA, "TOTODILE", 8, { dvs = perfect }) bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen = newScreen({ player = lead, party = { lead, bench } }) check(runToMenu(screen), "reached the menu") screen.menuIndex = 3 screen.moveIndex = 2 screen:submit({ kind = "switch", index = 2 }) check(runToMenu(screen), "the switch resolves back to the menu") eq(screen.menuIndex, 1, "wBattleMenuCursorPosition is back on FIGHT") eq(screen.moveIndex, 1, "and wCurMoveNum on the first move") end -- ---- no HUD rides in with the intro bands --------------------------------- -- -- InitBattleDisplay blanks the whole tilemap before BattleIntroSlidingPics -- (core.asm:8554/8564); UpdateEnemyHUD runs only after BattleStartMessage -- returns (:7815) and UpdatePlayerHUD at the tail of SendOutPlayerMon (:3838). do local screen = newScreen() eq(screen.showEnemyHud, false, "the enemy HUD does not exist at construction") eq(screen.showPlayerHud, false, "nor the player's") run(screen, BattleAnimView.SLIDE_FRAMES) eq(screen.showEnemyHud, false, "and neither rode in with the bands") eq(screen.showPlayerHud, false, "on either side") -- The appeared line is read against an empty HUD area. for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.message == "Wild PIDGEY appeared!" then break end end eq(screen.message, "Wild PIDGEY appeared!", "BattleStartMessage's own line") eq(screen.showEnemyHud, false, "with no enemy HUD under it yet") check(runToMenu(screen), "the intro drains to the menu") eq(screen.showEnemyHud, true, "UpdateEnemyHUD has run by the menu") eq(screen.showPlayerHud, true, "and UpdatePlayerHUD after the send-out") end -- ---- the wild mon cries with BattleStartMessage ---------------------------- -- -- `.not_shiny`: `ld a, [wTempEnemyMonSpecies] / call PlayStereoCry` runs before -- WildPokemonAppearedText (engine/battle/core.asm:8718-8721). do local screen = newScreen() local realAudio = screen.game.data.audio screen.game.data.audio = { cries = { PIDGEY = { pitch = 0 } } } local realPlayCry = Sound.playCry local played Sound.playCry = function(_, species) played = species end for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.message == "Wild PIDGEY appeared!" then break end end Sound.playCry = realPlayCry screen.game.data.audio = realAudio eq(played, "PIDGEY", "the wild mon cries with its appeared line") end -- ---- the message box's two fixed rows ------------------------------------- -- -- LineChar reloads the cursor at TEXTBOX_INNERY + 2 (home/text.asm:397), so a -- two-line battle string sits on rows 14 and 16 with row 15 blank. do local Chrome = require("src.ui.gen2.Chrome") local screen = newScreen() local realPrint = Chrome.print local rows = {} Chrome.print = function(text, tx, ty) rows[#rows + 1] = { text, tx, ty } end screen.message = "CYNDAQUIL used TACKLE on the wild PIDGEY!" screen:printMessage() Chrome.print = realPrint check(#rows >= 2, "a long line wraps onto a second row") eq(rows[1][2], 1, "TEXTBOX_INNERX is column 1") eq(rows[1][3], 14, "TEXTBOX_INNERY is row 14") eq(rows[2][3], 16, "and LineChar's row is 16, not 15") eq(#rows, 2, "with no third row: Paragraph clears only 14-16") end -- ---- PokeBallEffect's throw -------------------------------------------- do local screen = newScreen() -- `cp POKE_BALL + 1 / jr c, .not_kurt_ball / ld a, POKE_BALL`. eq(screen:ballAnimParam("MASTER_BALL"), 1, "MASTER BALL throws as itself") eq(screen:ballAnimParam("POKE_BALL"), 5, "so does a POKe BALL") eq(screen:ballAnimParam("FAST_BALL"), 5, "and a Kurt ball throws with POKE_BALL's param") -- data/battle_anims/ball_colors.asm. eq(screen:ballPalette("MASTER_BALL"), "PAL_BATTLE_OB_GREEN", "green") eq(screen:ballPalette("POKE_BALL"), "PAL_BATTLE_OB_RED", "red") eq(screen:ballPalette("FAST_BALL"), "PAL_BATTLE_OB_BLUE", "blue") eq(screen:ballPalette("NEST_BALL"), "PAL_BATTLE_OB_GRAY", "and anything off the table takes the terminator row's gray") -- GetPokeBallWobble: three wobbles, then the verdict. screen.battle.random = function() return 0 end screen.ballThrow = { caught = true, rate = 255, wobble = 0 } eq(screen:pokeballWobble(), 0, "a caught mon wobbles once") eq(screen:pokeballWobble(), 0, "twice") eq(screen:pokeballWobble(), 0, "three times") eq(screen:pokeballWobble(), 1, "and the fourth call is the click") screen.ballThrow = { caught = false, rate = 255, wobble = 0 } eq(screen:pokeballWobble(), 0, "a doomed throw can still wobble") eq(screen:pokeballWobble(), 0, "and again") eq(screen:pokeballWobble(), 0, "and again") eq(screen:pokeballWobble(), 2, "before it breaks free on the fourth") eq(screen:ballFailureText(), "Shoot! It was so close too!", "four wobbles is BallSoCloseText") -- A roll that never comes in under the probability breaks free at once. screen.battle.random = function(n) return (n or 256) - 1 end screen.ballThrow = { caught = false, rate = 1, wobble = 0 } eq(screen:pokeballWobble(), 2, "the first wobble already fails") eq(screen:ballFailureText(), "Oh no! The POKéMON broke free!", "one wobble is BallBrokeFreeText") screen.ballThrow = { caught = false, rate = 1, wobble = 3 } eq(screen:ballFailureText(), "Aargh! Almost had it!", "three is BallAlmostHadItText") end -- ---- a full party sends the catch to the PC, and a full box refuses ------- do -- `.SendToPC` / `predef SendMonIntoBox` (engine/items/item_effects.asm:548). local party = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party[i] = mon end local screen, battle, _, wild, save = newScreen({ player = party[1], party = party, inventory = { MASTER_BALL = 1 } }) check(runToMenu(screen), "reached the menu") screen:useItem("MASTER_BALL") eq(battle.outcome, "caught", "the Master Ball catch lands") eq(#save.party, 6, "the party is untouched") eq((save.boxes and save.boxes[1] or {})[1], wild, "and the catch went into the current box, not into nothing") -- Ball_BoxIsFullMessage: party full AND box full refuses the throw outright -- and reports the item unused, so neither the ball nor the turn goes -- (item_effects.asm:217-226). local party2 = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party2[i] = mon end local screen2, battle2, _, _, save2 = newScreen({ player = party2[1], party = party2, inventory = { MASTER_BALL = 1 } }) save2.boxes = { {} } for i = 1, 20 do save2.boxes[1][i] = { species = "PIDGEY" } end check(runToMenu(screen2), "reached the menu") local turn0 = battle2.turn screen2:useItem("MASTER_BALL") eq(battle2.outcome, nil, "the throw is refused") eq(save2.inventory.MASTER_BALL, 1, "the ball stays in the bag") eq(battle2.turn, turn0, "and the turn is not spent") check((screen2.message or ""):find("BOX"), "BallBoxFullText is what shows") end -- ---- SendMonIntoBox inserts at the HEAD, and reports a box it filled ------ do -- The species loop cascades every entry one slot down and ShiftBoxMon does -- the same for the OT names, nicknames and mon structs -- (engine/pokemon/move_mon.asm:954-968), so the catch is slot 1 -- which is -- what lets the FRIEND_BALL arm write sBoxMon1Happiness unconditionally -- ("The captured mon is now first in the box", item_effects.asm:624). local party = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party[i] = mon end local screen, battle, _, wild, save = newScreen({ player = party[1], party = party, inventory = { MASTER_BALL = 1 } }) local resident = { species = "PIDGEY" } save.boxes = { { resident } } check(runToMenu(screen), "reached the menu") screen:useItem("MASTER_BALL") eq(battle.outcome, "caught", "the Master Ball catch lands") eq(save.boxes[1][1], wild, "the catch is FIRST in the box") eq(save.boxes[1][2], resident, "and the mon already there shifted down one") eq(#save.boxes[1], 2, "with nothing lost off the end") eq(battle.boxFilled, nil, "a box with room left flags nothing") -- `.SendToPC` re-reads sBoxCount after the insert and sets -- BATTLERESULT_BOX_FULL when the catch is the one that filled it -- (item_effects.asm:612-619), which is what rings Bill on the first step -- back in the overworld. local party2 = {} for i = 1, 6 do local mon = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } party2[i] = mon end local screen2, battle2, _, _, save2 = newScreen({ player = party2[1], party = party2, inventory = { MASTER_BALL = 1 } }) save2.boxes = { {} } for i = 1, 19 do save2.boxes[1][i] = { species = "PIDGEY" } end check(runToMenu(screen2), "reached the menu") screen2:useItem("MASTER_BALL") eq(battle2.outcome, "caught", "the twentieth catch still goes in") eq(#save2.boxes[1], 20, "and fills the box") eq(battle2.boxFilled, true, "which is flagged on the battle for the reload") end -- ---- a new species opens NewPokedexEntry, gated on CheckReceivedDex ------- do -- `predef NewPokedexEntry` sits between NewDexDataText and the party add -- (engine/items/item_effects.asm:535-542, :570). local function findEvent(screen, kind) for index, event in ipairs(screen.queue or {}) do if event.kind == kind then return index, event end end end local screen, _, _, _, save = newScreen({ inventory = { MASTER_BALL = 1 } }) save.engineFlags = { [11] = true } save.pokedex = { seen = {}, caught = {} } check(runToMenu(screen), "reached the menu") screen:useItem("MASTER_BALL") local dexAt, dexEvent = findEvent(screen, "dex-entry") check(dexEvent ~= nil, "the catch queues a dex-entry event") eq(dexEvent and dexEvent.species, "PIDGEY", "on the species just caught") local nickAt = findEvent(screen, "ask-nickname") check(nickAt and dexAt and dexAt < nickAt, "and it runs ahead of AskGiveNicknameText") -- CheckReceivedDex: no dex, no line and no screen (item_effects.asm:532-533). local screen2, _, _, _, save2 = newScreen({ inventory = { MASTER_BALL = 1 } }) save2.engineFlags = {} save2.pokedex = { seen = {}, caught = {} } check(runToMenu(screen2), "reached the menu") screen2:useItem("MASTER_BALL") eq(findEvent(screen2, "dex-entry"), nil, "a dexless player gets no entry") -- `jr nz, .skip_pokedex`: a row already owned skips both (:528-530). local screen3, _, _, _, save3 = newScreen({ inventory = { MASTER_BALL = 1 } }) save3.engineFlags = { [11] = true } save3.pokedex = { seen = {}, caught = { PIDGEY = true } } check(runToMenu(screen3), "reached the menu") screen3:useItem("MASTER_BALL") eq(findEvent(screen3, "dex-entry"), nil, "an owned row opens nothing") end -- ---- the low-HP siren dies with the enemy, and with a healing item -------- do -- StopDangerSound plus the wBattleLowHealthAlarm latch on every wild enemy -- faint (engine/battle/core.asm:2071-2074), read first by CheckDanger -- (:4396-4399); and wLowHealthAlarm zeroed by the healing items before the -- HP even moves (engine/items/item_effects.asm:1657-1658). local siren = false local realStart, realStop = Sound.startLoop, Sound.stopLoop Sound.startLoop = function(_, name) if name == "Low_Health_Alarm" then siren = true end end Sound.stopLoop = function(name) if name == "Low_Health_Alarm" then siren = false end end local player = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local wild = Mon.new(DATA, "PIDGEY", 5, { dvs = perfect }) wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen = newScreen({ player = player, wild = wild }) check(runToMenu(screen), "reached the menu") player.hp = 2 screen.shownHp.player = 2 run(screen, 1) check(siren, "a red bar raises the siren") wild.hp = 1 screen:submit({ kind = "move", move = "TACKLE" }) -- The drain to zero still rings: AnimateHPBar runs before -- UpdateBattleStateAndExperienceAfterEnemyFaint is even reached. What may -- not ring is anything from the faint onward. local latched, rangAfterFaint, sawExpLine = false, false, false for _ = 1, 3000 do Input:step() screen:update(1 / 60) if screen.lowHealthAlarmDisabled then latched = true end if latched and siren then rangAfterFaint = true end if latched and (screen.message or ""):find("EXP") then sawExpLine = true end if screen.phase == "done" then break end end eq(screen.phase, "done", "the win drains out") check(latched, "the enemy's faint latched wBattleLowHealthAlarm") check(sawExpLine, "and the exp lines ran after it with the bar still red") check(not rangAfterFaint, "and the siren is cut the instant the enemy goes down, not left blaring " .. "under the exp bar and the level-up lines") -- The item exception: silent from the frame it is used, all through the -- climb, with no latch behind it. local player2 = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) player2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } local screen2 = newScreen({ player = player2, inventory = { POTION = 1 } }) check(runToMenu(screen2), "reached the menu") player2.hp = 2 screen2.shownHp.player = 2 run(screen2, 1) check(siren, "the second battle's siren is up too") screen2:useItem("POTION") screen2.game.stack.top().onChoose(1, player2) check(not siren, "the POTION silences it where UseDisposableItem sits") check(screen2.hpAnim and screen2.hpAnim.side == "player", "with the bar only starting to climb") local rangDuringClimb = false for _ = 1, 600 do Input:step() screen2:update(1 / 60) if screen2.hpAnim and siren then rangDuringClimb = true end if screen2.phase == "menu" then break end end check(not rangDuringClimb, "and it stays silent for the whole climb") Sound.startLoop, Sound.stopLoop = realStart, realStop end -- ---- the move list refuses a dry or disabled row -------------------------- -- -- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen` -- (engine/battle/core.asm:5213-5246), so neither spends the turn. local function tapper(screen) return function(button) Input:overlayPressed(button) Input:step() screen:update(1 / 60) Input:overlayReleased(button) Input:step() screen:update(1 / 60) end end do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "GROWL", pp = 40, maxPp = 40 }, { id = "TACKLE", pp = 5, maxPp = 35 } } local screen, _, _, wild = newScreen({ player = lead }) check(runToMenu(screen), "reached the menu") screen.battle:volatile(screen.battle.player).disabled = "GROWL" local tap = tapper(screen) tap("a") eq(screen.phase, "moves", "A on FIGHT still opens the list") local before = wild.hp tap("a") eq(screen.phase, "refuse-move", "the disabled row is refused") eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled") eq(wild.hp, before, "and the enemy got no free turn") run(screen, 120) eq(screen.phase, "moves", "the list comes back") -- The same for a spent row. screen.battle:volatile(screen.battle.player).disabled = nil screen.battle.player.moves[2].pp = 0 tap("down") eq(screen.moveIndex, 2, "onto the spent move") tap("a") eq(screen.phase, "refuse-move", "a 0 PP row is refused too") eq(screen.message, "There's no PP left for this move!", "with BattleText_TheresNoPPLeftForThisMove") eq(wild.hp, before, "and still no enemy turn") run(screen, 120) eq(screen.phase, "moves", "and the list comes back again") end -- .CheckPlayerHasUsableMoves runs at MoveSelectionScreen's head -- (engine/battle/core.asm:5058-5059): nothing usable never opens the list. do local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "GROWL", pp = 40, maxPp = 40 }, { id = "TACKLE", pp = 0, maxPp = 35 } } local screen = newScreen({ player = lead }) check(runToMenu(screen), "reached the menu") screen.battle:volatile(screen.battle.player).disabled = "GROWL" local messages = {} local realPush = screen.pushAll screen.pushAll = function(self, events) for _, event in ipairs(events or {}) do if event.kind == "message" then messages[#messages + 1] = event.text end end return realPush(self, events) end tapper(screen)("a") check(screen.phase ~= "moves", "the list never opened") local struggled = false for _, text in ipairs(messages) do if text:find("has no moves left!", 1, true) then struggled = true end end check(struggled, "and the turn resolved as STRUGGLE") end -- ---- the move-learn prompts (engine/pokemon/learn.asm) ------------------- -- -- ForgetMove asks first (:123-127), the picker's B is LearnMove's .cancel -- (:104-108) and a NO there is `jp c, .loop`, back to the ask. local function learnScreen() local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }) lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GROWL", pp = 40, maxPp = 40 }, { id = "THUNDER_WAVE", pp = 20, maxPp = 20 }, { id = "SURF", pp = 15, maxPp = 15 } } local screen = newScreen({ player = lead, party = { lead } }) check(runToMenu(screen), "reached the menu") screen:push({ kind = "choose-forget", index = 1, move = { id = "EMBER", pp = 25, maxPp = 25 }, moveName = "EMBER", text = "CYNDAQUIL wants to learn EMBER!" }) screen:advanceQueue() return screen, lead end -- AskForgetMoveText's `para`/`cont` each want a press (home/text.asm:403-448), -- so the question is reached by pages rather than by one button. local function runToPhase(screen, phase, cap) for _ = 1, (cap or 900) do Input:step() screen:update(1 / 60) if screen.phase == phase then return true end end return false end do local screen, lead = learnScreen() eq(screen.phase, "learn-intro", "AskForgetMoveText's first page holds") eq(screen.message, "CYNDAQUIL is\ntrying to learn", "with _AskForgetMoveText's own wording (data/text/common_3.asm:141-165)") check(runToPhase(screen, "ask-forget"), "and its pages run into the question") eq(screen.message, "move to make room\nfor EMBER?", "whose last page is the one YesNoBox opens over") run(screen, 60) local tap = tapper(screen) tap("b") eq(screen.phase, "stop-learning", "NO there asks whether to stop learning") run(screen, 60) tap("b") eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask") check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit") eq(lead.moves[1].id, "TACKLE", "and the moveset is untouched throughout") eq(#lead.moves, 4, "with nothing added") end do local screen, lead = learnScreen() check(runToPhase(screen, "ask-forget"), "the pages reach the question") run(screen, 60) local tap = tapper(screen) tap("a") -- YES eq(screen.phase, "choose-forget", "YES opens the picker") tap("a") -- slot 1 eq(lead.moves[1].id, "EMBER", "and A on slot 1 learns the move there") end -- IsHMMove refuses the slot and `jr .loop` redraws the list (learn.asm:193-197). do local screen, lead = learnScreen() check(runToPhase(screen, "ask-forget"), "the pages reach the question") run(screen, 60) local tap = tapper(screen) tap("a") eq(screen.phase, "choose-forget", "the picker is up") screen.forgetIndex = 4 tap("a") eq(screen.phase, "choose-forget", "A on the HM row keeps the list") eq(screen.message, "HM moves can't be\nforgotten now.", "with MoveCantForgetHMText") eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1") run(screen, 60) eq(screen.phase, "choose-forget", "the picker is still up after the line") eq(lead.moves[4].id, "SURF", "and SURF is still there") end S.finish()