Files
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

295 lines
12 KiB
Lua

-- The battle RULES that a fixture test cannot see: when TRANSFORM actually
-- lands on screen, what a caught mon's record says, and that STRUGGLE,
-- MAGNITUDE, DREAM EATER, SPITE and a refused RUN behave the way
-- engine/battle/effect_commands.asm and engine/battle/core.asm say they do --
-- all of it against the live extracted tables and the real battle screen
-- rather than a fixture.
--
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_battle_rules.lua \
-- POKEPORT_SHOT_DIR=/tmp/gold-rules love .
--
-- The shots are the point of the first block: BattleState draws the enemy pic
-- from whichever mon its `shownMon` slot holds, and that slot follows the
-- EVENT QUEUE. Battle:takeTurn resolves a whole round up front, so anything
-- the rules write straight into the mon record is on screen a beat before its
-- own message -- which is what "DITTO transformed at the beginning of its
-- turn" looked like. 01-submitted.png is taken one frame after the move is
-- submitted and before any message has been drained, and 02-transformed.png
-- once TRANSFORM's own line has been read: the two shots are what the pic
-- timing has to be judged on, and the driver PRINTS which one the swap landed
-- on rather than asserting it, because the rules half of that (the `transform`
-- event, and the pre-transform record kept beside it) is all this side of the
-- seam owns -- src/ui/gen2/BattleState.lua owns `shownMon`.
--
-- What the driver does assert is every rule: the transform is undone on the
-- way out of the battle (a caught DITTO is a DITTO), STRUGGLE's damage and its
-- quarter-damage recoil, MAGNITUDE's rolled power, DREAM EATER's checkhit gate
-- and SPITE's PP drain, and a RUN refused by a trainer battle costing nothing.
local U = require("tests.drivers.util")
local Mon = require("src.battle.gen2.Mon")
local function battleScreen(game)
for _ = 1, 900 do
local top = game.stack:top()
if top and top.battle then return top end
U.wait(1)
end
error("battle screen never came up")
end
-- Drain the screen's own queue: press A until it is asking for a move again.
local function drain(game, screen, frames)
for _ = 1, (frames or 400) do
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
local function newWild(game, species, level, moves)
local mon = Mon.new(game.data, species, level)
if moves then
mon.moves = {}
for i, id in ipairs(moves) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
end
return mon
end
local function giveMoves(mon, game, moves)
mon.moves = {}
for i, id in ipairs(moves) do
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
end
return mon
end
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-rules"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failures = {}
local function check(ok, what)
print((ok and "[ok] " or "[FAIL] ") .. what)
if not ok then failures[#failures + 1] = what end
end
-- ------------------------------------------------------------------ 1 + 3
-- A wild DITTO whose only move is TRANSFORM, so the AI cannot pick anything
-- else, and a player mon slow enough that the DITTO moves second.
local player = Mon.new(game.data, "SNORLAX", 30)
giveMoves(player, game, { "TACKLE" })
game.save.party = { player }
game.save.inventory = { MASTER_BALL = 5 }
local ditto = newWild(game, "DITTO", 20, { "TRANSFORM" })
assert(world:startBattle({ wild = ditto }), "startBattle failed")
local screen = battleScreen(game)
drain(game, screen, 200)
U.shot(game, out .. "/00-menu.png")
print("[driver] enemy shown as " .. tostring(screen:activeMon("enemy")
and screen:activeMon("enemy").species))
screen:submit({ kind = "move", move = "TACKLE" })
local sawTransformEvent = false
for _, event in ipairs(screen.queue) do
if event.kind == "transform" then sawTransformEvent = true end
end
U.wait(1)
U.shot(game, out .. "/01-submitted.png")
local shownAtSubmit = screen:activeMon("enemy")
print("[driver] pic timing: at submit the enemy draws as "
.. tostring(shownAtSubmit and shownAtSubmit.species)
.. " (01-submitted.png)")
check(sawTransformEvent,
"the round carries a `transform` event for the screen to swap its pic on")
-- The frame the bug report is about: the player's own move is still on
-- screen, its animation has finished and the enemy pic is being drawn again
-- -- and the mon it is drawn from has already been rewritten by a TRANSFORM
-- whose line has not been read yet.
U.wait(120)
U.shot(game, out .. "/01z-before-transform-line.png")
print("[driver] with the box still reading "
.. tostring(screen.message and screen.message:gsub("\n", " / "))
.. " the enemy pic is drawn from "
.. tostring(screen:activeMon("enemy") and screen:activeMon("enemy").species)
.. " (01z-before-transform-line.png)")
-- One shot per message on the way through the round, so the frame where the
-- pic stops being a DITTO can be pointed at rather than described.
for step = 1, 6 do
U.tap(game, "a")
U.wait(24)
U.shot(game, out .. ("/01%s-step.png"):format(string.char(96 + step)))
print("[driver] step " .. step .. " message: "
.. tostring(screen.message and screen.message:gsub("\n", " / ")))
end
drain(game, screen, 400)
U.shot(game, out .. "/02-transformed.png")
local shownAfter = screen:activeMon("enemy")
print("[driver] pic timing: after TRANSFORM's own line it draws as "
.. tostring(shownAfter and shownAfter.species) .. " (02-transformed.png)")
check(ditto.species == "SNORLAX",
"the rules half lands at once: the battler IS the copy for the rest of "
.. "the round")
check(screen.battle:volatile(ditto).preTransform
and screen.battle:volatile(ditto).preTransform.species == "DITTO",
"and the record it was is kept for the reload")
-- The catch: PokeBallEffect reloads the caught mon out of its base data
-- (item_effects.asm `.catch_without_fail` reads wTempEnemyMonSpecies), so a
-- transformed DITTO is caught as a DITTO. A MASTER BALL so the roll is not
-- part of what is being tested.
screen:useItem("MASTER_BALL")
for _ = 1, 400 do
if game.save.party[2] then break end
U.tap(game, "a")
U.wait(3)
end
U.shot(game, out .. "/03-caught.png")
local caught = game.save.party[2]
check(caught ~= nil, "the DITTO was caught into the party")
-- CleanUpBattleRAM (BattleState:finishBattle -> clearAllVolatiles) is where
-- the reload lands, so the record is judged once the battle is off the
-- stack -- which is also the last moment before the overworld and the next
-- save write see it.
-- B, not A: the capture ends on AskGiveNicknameText, and answering NO is
-- what walks the screen through to ExitBattle instead of parking it in the
-- naming screen (which sits ON TOP of the battle, so "the battle is not the
-- top of the stack" is not the same as "the battle is over").
local done = false
for _ = 1, 900 do
if screen.phase == "done" then done = true break end
U.tap(game, "b")
U.wait(3)
end
U.wait(30)
print(("[driver] battle finished=%s phase=%s"):format(tostring(done),
tostring(screen.phase)))
check(done, "the battle screen reached ExitBattle")
if caught then
print("[driver] caught record: species=" .. tostring(caught.species)
.. " move1=" .. tostring(caught.moves and caught.moves[1]
and caught.moves[1].id))
check(caught.species == "DITTO",
"the caught record is the real DITTO (was "
.. tostring(caught.species) .. ")")
check(caught.moves and caught.moves[1]
and caught.moves[1].id == "TRANSFORM",
"and it kept its own move list")
end
-- ---------------------------------------------------------------------- 2
-- STRUGGLE: real damage, then a quarter of it back (BattleCommand_Recoil).
local struggler = Mon.new(game.data, "RATTATA", 30)
giveMoves(struggler, game, { "TACKLE" })
struggler.moves[1].pp = 0
game.save.party = { struggler }
local target = newWild(game, "SNORLAX", 30, { "SPLASH" })
assert(world:startBattle({ wild = target }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 200)
local foeBefore, mineBefore = target.hp, struggler.hp
screen:submit({ kind = "move", move = "TACKLE" })
drain(game, screen, 400)
U.shot(game, out .. "/04-struggle.png")
local dealt = foeBefore - target.hp
local recoil = mineBefore - struggler.hp
print(("[driver] STRUGGLE dealt %d and recoiled %d"):format(dealt, recoil))
check(dealt > 5, "STRUGGLE deals its 50 power, not chip damage")
check(recoil == math.max(1, math.floor(dealt / 4)),
"and the user takes a quarter of it back")
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(60)
-- ------------------------------------------------------------------ 4/5/6
local caster = Mon.new(game.data, "GASTLY", 40)
giveMoves(caster, game, { "MAGNITUDE", "DREAM_EATER", "SPITE" })
caster.hp = math.max(1, caster.hp - 20)
game.save.party = { caster }
local dummy = newWild(game, "RATTATA", 20, { "TACKLE" })
assert(world:startBattle({ wild = dummy }), "startBattle failed")
screen = battleScreen(game)
drain(game, screen, 200)
local before = dummy.hp
screen:submit({ kind = "move", move = "MAGNITUDE" })
local sawMagnitude = false
for _, event in ipairs(screen.queue) do
if event.text and event.text:match("^Magnitude %d") then
sawMagnitude = true
end
end
drain(game, screen, 400)
U.shot(game, out .. "/05-magnitude.png")
check(sawMagnitude, "MAGNITUDE announces its rolled magnitude")
check(before - dummy.hp > 1,
"and hits for the rolled power, not the ROM's stored 1 (dealt "
.. tostring(before - dummy.hp) .. ")")
local hpBefore, mineHp = dummy.hp, caster.hp
screen:submit({ kind = "move", move = "DREAM_EATER" })
drain(game, screen, 400)
check(dummy.hp == hpBefore,
"DREAM EATER misses an awake target outright")
check(caster.hp <= mineHp, "and saps nothing from it")
local ppBefore = dummy.moves[1].pp
screen:submit({ kind = "move", move = "SPITE" })
drain(game, screen, 400)
U.shot(game, out .. "/06-spite.png")
print(("[driver] SPITE took the foe's TACKLE from %d to %d PP")
:format(ppBefore, dummy.moves[1].pp))
check(dummy.moves[1].pp < ppBefore - 1,
"SPITE drains 2-5 PP off the move the target last used")
for _ = 1, 400 do
if not game.stack:top() or not game.stack:top().battle then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(60)
-- ---------------------------------------------------------------------- 7
-- RUN in a trainer battle: `.cant_run_from_trainer` leaves
-- wBattlePlayerAction alone and falls back into BattleMenu, so the round is
-- never spent and the trainer does not get a free swing.
local runner = Mon.new(game.data, "RATTATA", 30)
giveMoves(runner, game, { "TACKLE" })
game.save.party = { runner }
local foe = Mon.new(game.data, "GEODUDE", 30)
giveMoves(foe, game, { "TACKLE" })
assert(world:startBattle({ trainer = { class = "YOUNGSTER",
name = "JOEY", party = { foe } } }), "trainer startBattle failed")
screen = battleScreen(game)
drain(game, screen, 300)
local hpAtRun = runner.hp
local turnAtRun = screen.battle.turn
screen:submit({ kind = "run" })
drain(game, screen, 300)
U.shot(game, out .. "/07-run-refused.png")
check(runner.hp == hpAtRun,
"a refused RUN costs no HP: the trainer never got a swing")
check(screen.battle.turn == turnAtRun + 1 and not screen.battle.over,
"and the battle is still running")
check(foe.hp == foe.maxHp, "nor did anything happen to the foe")
if #failures > 0 then
for _, what in ipairs(failures) do print("[FAIL] " .. what) end
error(#failures .. " battle-rule checks failed")
end
print("[driver] all battle-rule checks passed")
print("[driver] shots in " .. out)
end