mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
295 lines
12 KiB
Lua
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
|