mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 23:11:15 +02:00
Add Pokemon Crystal as a sixth supported version
Crystal boots from a user-supplied ROM, imports a full cache and is playable: copyright, the Crystal intro movie, the animated title, gender select, Oak, and out into Johto. 122 of the cart's 169 script specials are implemented. Import and data - tools/make_crystal_manifest.py derives the manifest by importing make_gold_manifest as a library, with three additive keyword seams. Gold and Silver still regenerate byte-identical, which is the standing requirement for touching that generator. - crystal_symbol_deltas.py and crystal_movie_symbols.py carry the symbol delta: Crystal renames the credits mons, splits the trainer card, Pokegear and pack-pal blocks by gender, and replaces the intro and title outright. - Crystal-only manifest keys: engineFlagOrder (162 flags to Gold's 93, so the badge block sits one higher) and unownCharmap (the main charmap parser stops at the first newcharmap so the two cannot contaminate each other). Extractor - RomExtractorGen2 becomes three-edition. Crystal corrections: PAL_MAP_BANK 0x13, a flat PICS_FIX pic bank, audio bank 0x5e, the mapSongs id-100 hole, seven NPC trades, a TradeTexts stride of 8, the five Crystal tileset anim steps with per-row degrade, and the column-major trainer card portraits. - New: animated front sprites (frames, bitmasks, play and idle scripts), the Battle Tower roster, Kris assets, Mobile System GB art, and the Crystal intro and title via src/import/CrystalMovie.lua. Engine - GameVersion gains engine(id) and fixes(id). Gold and Silver keep their original bugs where the bug is not hardware dependent; Crystal gets the fixes Crystal shipped: Lucky Number boxes 10-14, surfing onto an NPC, and the Reflect and Light Screen defence overflow. - Crystal story: Suicune and Eusine, Celebi behind the GS Ball flag, the Ruins of Alph chambers, Buena, the Move Tutor, the Poke Seer, and the Battle Tower including the wInBattleTowerBattle badge-boost guard. - Kris and the gender flag, animated fronts in battle and the summary screen, and mon caught data. Verification - Every extracted asset is pixel-compared against pret's own source PNGs. - Gold caches are byte-identical before and after, file for file. - New Crystal suites plus a T2 Gen 2 tier; the full suite passes.
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
-- The Battle Tower lobby, driven in a real game.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=lead-card POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/tower \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_battle_tower_shots.lua love .
|
||||
--
|
||||
-- Three sections, all on maps/BattleTower1F.asm's own extracted script:
|
||||
-- A an illegal party, so _CheckForBattleTowerRules prints its refusals
|
||||
-- B a legal party through Menu_ChallengeExplanationCancel and the room menu
|
||||
-- C the walk to the elevator the chosen room starts
|
||||
--
|
||||
-- Section B overrides the TryQuickSave stub for the length of the run:
|
||||
-- src/script/gen2/Specials.lua:2516 answers 0, and BattleTower1F.asm:84-85
|
||||
-- backs out of the whole challenge on that, so the desk cannot be walked
|
||||
-- past without it.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local BattleTower = require("src.core.gen2.BattleTower")
|
||||
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
|
||||
local Vm = require("src.script.gen2.Vm")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower"
|
||||
local fails, shots = 0, 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
local function shot(name)
|
||||
shots = shots + 1
|
||||
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
|
||||
end
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function isMenu() return getmetatable(top()) == ScriptMenu end
|
||||
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
|
||||
|
||||
local function party(spec)
|
||||
local list = {}
|
||||
for _, row in ipairs(spec) do
|
||||
local m = Mon.new(game.data, row[1], row[2])
|
||||
m.item = row[3]
|
||||
list[#list + 1] = m
|
||||
end
|
||||
game.save.party = list
|
||||
end
|
||||
|
||||
local function enterLobby()
|
||||
while top() do tap("b", 2) end
|
||||
assert(world:setMap("BATTLE_TOWER_1F", 7, 7, "up"), "no BATTLE_TOWER_1F")
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- maps/BattleTower1F.asm:55-73: A through the welcome, NO to the
|
||||
-- explanation offer, and the menu is the next command.
|
||||
local function talkToMenu(limit)
|
||||
for _ = 1, limit or 200 do
|
||||
if isMenu() then return true end
|
||||
if world.choicebox then tap("b", 4) else tap("a", 4) end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
say("--- A: an illegal party at the desk")
|
||||
party({ { "TYPHLOSION", 20 } })
|
||||
enterLobby()
|
||||
shot("lobby")
|
||||
ok(talkToMenu(), "the receptionist reaches Menu_ChallengeExplanationCancel")
|
||||
shot("challenge-menu")
|
||||
ok(isMenu() and #top().items == 3, "three rows: Challenge/Explanation/Cancel")
|
||||
tap("a", 6)
|
||||
|
||||
local pages = {}
|
||||
for _ = 1, 40 do
|
||||
local body = world.lastText
|
||||
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
|
||||
pages[#pages + 1] = body
|
||||
shot("rules-" .. #pages)
|
||||
end
|
||||
if not world:busy() and top() == nil then break end
|
||||
tap("a", 4)
|
||||
end
|
||||
local joined = table.concat(pages, " | ")
|
||||
say("pages: " .. joined)
|
||||
ok(joined:find("You're not ready", 1, true) ~= nil,
|
||||
"_ExcuseMeYoureNotReadyText printed")
|
||||
ok(joined:find("Only three", 1, true) ~= nil,
|
||||
"_OnlyThreeMonMayBeEnteredText printed")
|
||||
ok(joined:find("Please return when", 1, true) ~= nil,
|
||||
"_BattleTowerReturnWhenReadyText printed")
|
||||
|
||||
say("--- B: a legal party, the room menu, the walk out")
|
||||
local savedQuickSave = Vm.SPECIALS.TryQuickSave
|
||||
Vm.SPECIALS.TryQuickSave = function(vm) vm.scriptVar = 1 end
|
||||
|
||||
party({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20, "BERRY" },
|
||||
{ "MEGANIUM", 20, "GOLD_BERRY" } })
|
||||
enterLobby()
|
||||
ok(talkToMenu(), "the desk reaches the menu again")
|
||||
tap("a", 6)
|
||||
|
||||
local sawSavePrompt = false
|
||||
for _ = 1, 120 do
|
||||
if isRoomMenu() then break end
|
||||
if world.choicebox then
|
||||
sawSavePrompt = true
|
||||
shot("save-prompt")
|
||||
tap("a", 6)
|
||||
else
|
||||
tap("a", 4)
|
||||
end
|
||||
end
|
||||
ok(sawSavePrompt, "Text_SaveBeforeEnteringBattleRoom asked first")
|
||||
ok(isRoomMenu(), "special BattleTowerRoomMenu pushed Gen2BattleTowerMenu")
|
||||
shot("room-menu")
|
||||
|
||||
if isRoomMenu() then
|
||||
local screen = top()
|
||||
ok(#screen.rows == BattleTower.PRE_HOF_LEVEL_GROUPS,
|
||||
"no Hall of Fame, so four rooms are offered")
|
||||
-- L:10 with an L20 party is the refusal at mobile_46.asm:3915-3917.
|
||||
tap("a", 6)
|
||||
ok(screen.phase == "message", "picking L:10 prints the level refusal")
|
||||
shot("tops-this-level")
|
||||
for _ = 1, 150 do
|
||||
if screen.phase == "pick" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
ok(screen.phase == "pick", "and the menu comes back after the hold")
|
||||
tap("up", 6)
|
||||
shot("room-menu-l20")
|
||||
tap("a", 6)
|
||||
end
|
||||
|
||||
for _ = 1, 240 do
|
||||
if not world:busy() and top() == nil then break end
|
||||
tap("a", 4)
|
||||
end
|
||||
local tower = BattleTower.state(game.save)
|
||||
ok(tower.levelGroup ~= nil, "the level group survived the menu")
|
||||
say("levelGroup=" .. tostring(game.world.vm and game.world.vm.btLevelGroup)
|
||||
.. " reward=" .. tostring(tower.reward)
|
||||
.. " challenge=" .. tostring(tower.challenge)
|
||||
.. " map=" .. tostring(world.map and world.map.id))
|
||||
ok(tower.reward ~= nil,
|
||||
"BATTLETOWERACTION_CHOOSEREWARD banked a prize on the way out")
|
||||
shot("after-desk")
|
||||
|
||||
U.wait(180)
|
||||
shot("elevator-or-hallway")
|
||||
say("map after the walk: " .. tostring(world.map and world.map.id))
|
||||
|
||||
Vm.SPECIALS.TryQuickSave = savedQuickSave
|
||||
|
||||
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,249 @@
|
||||
-- Smoke: the whole Crystal boot chain, with no driver shortcut.
|
||||
--
|
||||
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
|
||||
-- POKEPORT_SHOT_DIR=<dir> \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_boot_smoke.lua love .
|
||||
--
|
||||
-- copyright -> GameFreak -> Crystal intro -> title -> intro menu -> NEW GAME
|
||||
-- -> clock -> Oak speech -> name pick -> naming screen -> the bedroom ->
|
||||
-- start menu -> outdoors -> a wild battle. Every step is asserted by the
|
||||
-- class of the state on the stack, so a broken hand-off names the screen it
|
||||
-- stalled on instead of just hanging.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local CopyrightSplash = require("src.ui.gen2.CopyrightSplash")
|
||||
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
|
||||
local GameFreakPresents = require("src.ui.gen2.GameFreakPresents")
|
||||
local GenderSelect = require("src.ui.gen2.GenderSelect")
|
||||
local InitClock = require("src.ui.gen2.InitClock")
|
||||
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||
local NamePick = require("src.ui.gen2.NamePick")
|
||||
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
||||
local OptionsMenu = require("src.ui.gen2.OptionsMenu")
|
||||
local StartMenu = require("src.ui.gen2.StartMenu")
|
||||
local TitleState = require("src.ui.gen2.TitleState")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-boot"
|
||||
local log = io.open(os.getenv("POKEPORT_BOOT_LOG")
|
||||
or (out .. "/boot.log"), "w")
|
||||
|
||||
local function say(line)
|
||||
print("[driver] " .. line)
|
||||
if log then log:write(line .. "\n"); log:flush() end
|
||||
end
|
||||
|
||||
local function bail(reason)
|
||||
say("FAIL " .. reason)
|
||||
if log then log:close() end
|
||||
love.event.quit(1)
|
||||
error(reason, 0)
|
||||
end
|
||||
|
||||
local function top()
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function isA(class)
|
||||
local state = top()
|
||||
return state ~= nil and getmetatable(state) == class
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function waitFor(label, predicate, frames)
|
||||
for _ = 1, frames or 900 do
|
||||
if predicate() then return end
|
||||
U.wait(1)
|
||||
end
|
||||
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
|
||||
end
|
||||
|
||||
local function pick(menu, value)
|
||||
for i, item in ipairs(menu.list.items) do
|
||||
if item.value == value then menu.list.index = i end
|
||||
end
|
||||
end
|
||||
|
||||
if GameVersion.get() ~= "crystal" then
|
||||
bail("booted " .. tostring(GameVersion.get()) .. ", not crystal")
|
||||
end
|
||||
if GameVersion.engine() ~= "crystal" then
|
||||
bail("engine lineage is " .. tostring(GameVersion.engine()))
|
||||
end
|
||||
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
|
||||
|
||||
U.wait(10)
|
||||
if not isA(CopyrightSplash) then
|
||||
bail("boot did not start at the copyright splash (top "
|
||||
.. tostring(top()) .. ")")
|
||||
end
|
||||
say("OK copyright")
|
||||
U.shot(game, out .. "/01-copyright.png")
|
||||
|
||||
waitFor("GameFreak presents", function() return isA(GameFreakPresents) end)
|
||||
U.wait(90)
|
||||
say("OK gamefreak")
|
||||
U.shot(game, out .. "/02-gamefreak.png")
|
||||
|
||||
-- ../pokecrystal/engine/movie/intro.asm:1
|
||||
waitFor("the Crystal intro", function() return isA(CrystalIntro) end)
|
||||
U.wait(240)
|
||||
say("OK crystal intro")
|
||||
U.shot(game, out .. "/03-intro.png")
|
||||
U.wait(240)
|
||||
U.shot(game, out .. "/03b-intro.png")
|
||||
tap("start")
|
||||
|
||||
waitFor("the title screen", function() return isA(TitleState) end)
|
||||
U.wait(90)
|
||||
say("OK title")
|
||||
U.shot(game, out .. "/04-title.png")
|
||||
for _ = 1, 3 do tap("start", 3) end
|
||||
|
||||
waitFor("the intro menu", function() return isA(MainMenu) end)
|
||||
say("OK main menu")
|
||||
U.shot(game, out .. "/05-mainmenu.png")
|
||||
|
||||
local menu = top()
|
||||
pick(menu, "option")
|
||||
tap("a")
|
||||
waitFor("the options screen", function() return isA(OptionsMenu) end)
|
||||
U.shot(game, out .. "/06-options.png")
|
||||
tap("b")
|
||||
waitFor("the intro menu again", function() return isA(MainMenu) end)
|
||||
|
||||
menu = top()
|
||||
pick(menu, "new")
|
||||
U.shot(game, out .. "/07-newgame.png")
|
||||
tap("a")
|
||||
|
||||
-- ../pokecrystal/engine/menus/init_gender.asm:23 InitGender, which
|
||||
-- PlayerProfileSetup runs first (engine/menus/intro_menu.asm:80-84).
|
||||
waitFor("the gender screen", function() return isA(GenderSelect) end, 300)
|
||||
say("OK gender select")
|
||||
U.shot(game, out .. "/07b-gender.png")
|
||||
tap("a")
|
||||
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:628
|
||||
waitFor("the clock screen", function() return isA(InitClock) end, 300)
|
||||
say("OK init clock")
|
||||
U.shot(game, out .. "/08-initclock.png")
|
||||
for _ = 1, 60 do
|
||||
if isA(OakSpeech) then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
waitFor("the Oak speech", function() return isA(OakSpeech) end, 300)
|
||||
local oak = top()
|
||||
say("OK oak speech, demo mon = " .. tostring(oak.demoSpecies))
|
||||
if oak.demoSpecies ~= "WOOPER" then
|
||||
bail("Oak's demo mon is " .. tostring(oak.demoSpecies)
|
||||
.. ", Crystal's is WOOPER")
|
||||
end
|
||||
U.wait(60)
|
||||
U.shot(game, out .. "/09-oak.png")
|
||||
|
||||
local shotDemo = false
|
||||
for _ = 1, 400 do
|
||||
if isA(NamePick) then break end
|
||||
if not shotDemo and isA(OakSpeech) and top().pic == top().marillPic then
|
||||
U.wait(45)
|
||||
U.shot(game, out .. "/10-wooper.png")
|
||||
shotDemo = true
|
||||
end
|
||||
tap("a", 2)
|
||||
end
|
||||
if not isA(NamePick) then
|
||||
bail("Oak speech never reached the name picker (top "
|
||||
.. tostring(top()) .. ")")
|
||||
end
|
||||
say("OK name pick" .. (shotDemo and " (demo pic captured)" or ""))
|
||||
-- ../pokecrystal/engine/menus/intro_menu.asm:738 NamePlayer
|
||||
waitFor("the name menu to slide in",
|
||||
function() return top().slide == nil end, 240)
|
||||
U.shot(game, out .. "/11-namepick.png")
|
||||
|
||||
local picker = top()
|
||||
picker.cursor = 1
|
||||
tap("a")
|
||||
waitFor("the naming screen", function() return isA(NamingScreen) end)
|
||||
local naming = top()
|
||||
tap("a")
|
||||
tap("right")
|
||||
tap("a")
|
||||
if naming.text ~= "AB" then
|
||||
bail("typed name is " .. tostring(naming.text) .. ", expected AB")
|
||||
end
|
||||
say("OK naming screen")
|
||||
U.shot(game, out .. "/12-naming.png")
|
||||
naming.row = naming:bottomRow()
|
||||
naming.col = 6
|
||||
tap("a")
|
||||
|
||||
for _ = 1, 600 do
|
||||
if game.phase == "play" and game.world and game.world.map then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
if not (game.phase == "play" and game.world and game.world.map) then
|
||||
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
|
||||
end
|
||||
if game.save.player.name ~= "AB" then
|
||||
bail("player name is " .. tostring(game.save.player.name))
|
||||
end
|
||||
if game.world.map.id ~= "PLAYERS_HOUSE_2F" then
|
||||
bail("new game started on " .. tostring(game.world.map.id)
|
||||
.. ", expected the bedroom")
|
||||
end
|
||||
say("OK overworld, map = " .. game.world.map.id
|
||||
.. ", name = " .. tostring(game.save.player.name))
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/13-bedroom.png")
|
||||
|
||||
tap("start")
|
||||
waitFor("the start menu", function() return isA(StartMenu) end)
|
||||
say("OK start menu")
|
||||
U.shot(game, out .. "/14-startmenu.png")
|
||||
tap("b")
|
||||
waitFor("the overworld again", function() return top() == nil end)
|
||||
|
||||
if not game.world:setMap("NEW_BARK_TOWN", 13, 6, "down") then
|
||||
bail("could not load NEW_BARK_TOWN")
|
||||
end
|
||||
U.wait(30)
|
||||
if game.world.map.id ~= "NEW_BARK_TOWN" then
|
||||
bail("setMap landed on " .. tostring(game.world.map.id))
|
||||
end
|
||||
local tileset = game.world.map.tileset or {}
|
||||
say("OK outdoors, tileset = " .. tostring(tileset.id)
|
||||
.. ", tilePalettes = " .. tostring(#(tileset.tilePalettes or {})))
|
||||
U.shot(game, out .. "/15-newbark.png")
|
||||
|
||||
local player = Mon.new(game.data, "CYNDAQUIL", 12)
|
||||
if not (player and #player.moves > 0) then
|
||||
bail("could not build a CYNDAQUIL from the Crystal pokemon.lua")
|
||||
end
|
||||
local wild = Mon.new(game.data, "WOOPER", 5)
|
||||
if not wild then bail("could not build a wild WOOPER") end
|
||||
game.save.party = { player }
|
||||
if not game.world:startBattle({ wild = wild }) then
|
||||
bail("startBattle failed")
|
||||
end
|
||||
U.wait(240)
|
||||
say("OK battle, " .. player.species .. " L" .. player.level
|
||||
.. " vs " .. wild.species .. " L" .. wild.level)
|
||||
U.shot(game, out .. "/16-battle.png")
|
||||
|
||||
say("PASS crystal boot chain in " .. out)
|
||||
if log then log:close() end
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,227 @@
|
||||
-- TryQuickSave and the Crystal script VARs, in a real Crystal game.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=f2-crystal POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/f2 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_f2_save_and_vars.lua love .
|
||||
--
|
||||
-- A ../pokecrystal/maps/BattleTower1F.asm:80-86 with NO monkeypatch: the
|
||||
-- desk's own `writetext / yesorno`, then `special TryQuickSave` -- the
|
||||
-- overwrite prompt, the SAVING pages and a real file on disk -- and the
|
||||
-- room menu it can only reach on a TRUE.
|
||||
-- B ../pokecrystal/maps/RadioTower2F.asm:135-146: the correct password's
|
||||
-- `readvar / addval 1 / writevar VAR_BLUECARDBALANCE`, through the real
|
||||
-- World, over a save and a reload, and then BuenaPrize spending it.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local BattleTowerMenu = require("src.ui.gen2.BattleTowerMenu")
|
||||
local BuenaPassword = require("src.ui.gen2.BuenaPassword")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
|
||||
local Specials = require("src.script.gen2.Specials")
|
||||
|
||||
local VAR_BLUECARDBALANCE = 0x18
|
||||
local VAR_BUENASPASSWORD = 0x19
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-f2"
|
||||
local fails, shots = 0, 0
|
||||
|
||||
local function say(line)
|
||||
print("[driver] " .. line)
|
||||
io.stdout:flush()
|
||||
end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
local function shot(name)
|
||||
shots = shots + 1
|
||||
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
|
||||
end
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
local save = game.save
|
||||
|
||||
ok(Specials.STUB_REASONS.TryQuickSave == nil,
|
||||
"TryQuickSave is a handler, not a stub")
|
||||
ok(Specials.STUB_REASONS.SampleKenjiBreakCountdown == nil,
|
||||
"SampleKenjiBreakCountdown is a handler too")
|
||||
|
||||
--------------------------------------------------------------- section B
|
||||
say("--- B: Buena's Blue Card, over a save and a reload")
|
||||
|
||||
ok(world:setMap("RADIO_TOWER_2F", 5, 5, "down") == true, "on RADIO_TOWER_2F")
|
||||
U.wait(30)
|
||||
|
||||
local crystal = Save.crystalState(save)
|
||||
crystal.buenaPassword.balance = 0
|
||||
crystal.buenaPassword.word = 0x00
|
||||
crystal.buenaPassword.day = nil
|
||||
|
||||
-- The three rows the correct-answer arm runs, straight off the extracted
|
||||
-- opcode table, so the round trip is World:readVar -> Vm -> World:writeVar.
|
||||
local function awardPoint()
|
||||
local started = world.vm:start({
|
||||
{ op = "readvar", var = VAR_BLUECARDBALANCE },
|
||||
{ op = "addval", args = { 1 } },
|
||||
{ op = "writevar", var = VAR_BLUECARDBALANCE },
|
||||
})
|
||||
ok(started == true, "the award rows started (vm idle)")
|
||||
for _ = 1, 120 do
|
||||
if not world.vm:running() then break end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
for _ = 1, 3 do awardPoint() end
|
||||
say("blue card balance: " .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
|
||||
ok(world:readVar(VAR_BLUECARDBALANCE) == 3,
|
||||
"three correct passwords are three points")
|
||||
ok(crystal.buenaPassword.balance == 3, "and the points are in save.crystal")
|
||||
|
||||
-- The password itself is a RETVAR_ADDR_DE row too; BuenasPassword parks the
|
||||
-- day's roll there through the same writevar path.
|
||||
world:writeVar(VAR_BUENASPASSWORD, 0x24)
|
||||
ok(world:readVar(VAR_BUENASPASSWORD) == 0x24, "wBuenasPassword reads back")
|
||||
|
||||
-- The map load that used to eat it: scriptVars is rebuilt empty at
|
||||
-- World.lua:579.
|
||||
ok(world:setMap("RADIO_TOWER_1F", 5, 5, "down") == true, "walked downstairs")
|
||||
U.wait(20)
|
||||
ok(world:readVar(VAR_BLUECARDBALANCE) == 3, "a map load does not eat it")
|
||||
|
||||
ok(game:writeSave() ~= false, "saved with the balance on it")
|
||||
local reloaded = Save.load(save.version)
|
||||
local reloadedBalance = reloaded and reloaded.crystal
|
||||
and reloaded.crystal.buenaPassword
|
||||
and reloaded.crystal.buenaPassword.balance
|
||||
say("reloaded blue card balance: " .. tostring(reloadedBalance))
|
||||
ok(reloadedBalance == 3, "and it survives a save and a reload")
|
||||
ok(reloaded and reloaded.crystal.buenaPassword.word == 0x24,
|
||||
"so does the password")
|
||||
|
||||
-- BuenaPrize, which could never dispense anything on a balance of 0.
|
||||
-- data/items/buena_prizes.asm's first row is ULTRA_BALL at 2 points.
|
||||
save.inventory = save.inventory or {}
|
||||
save.inventory.ULTRA_BALL = nil
|
||||
local prizeIndex
|
||||
for index, name in ipairs(world.vm.specialOrder or {}) do
|
||||
if name == "BuenaPrize" then prizeIndex = index - 1 break end
|
||||
end
|
||||
ok(prizeIndex ~= nil, "BuenaPrize is in constants.specialOrder")
|
||||
world.vm:start({ { op = "special", id = prizeIndex } })
|
||||
local sawPrizeMenu = false
|
||||
for _ = 1, 300 do
|
||||
if not world.vm:running() and top() == nil then break end
|
||||
if getmetatable(top()) == BuenaPassword then
|
||||
if not sawPrizeMenu then
|
||||
sawPrizeMenu = true
|
||||
say("prize menu balance shown: " .. tostring(top().balance))
|
||||
shot("prize-menu")
|
||||
tap("a", 6) -- ULTRA BALL
|
||||
else
|
||||
tap("b", 6) -- second pass: leave the shop
|
||||
end
|
||||
else
|
||||
tap("a", 4)
|
||||
end
|
||||
end
|
||||
ok(sawPrizeMenu, "BuenaPrize opened its list")
|
||||
say("ULTRA_BALL x" .. tostring(save.inventory.ULTRA_BALL)
|
||||
.. " balance=" .. tostring(world:readVar(VAR_BLUECARDBALANCE)))
|
||||
ok((save.inventory.ULTRA_BALL or 0) >= 1, "the prize was handed over")
|
||||
ok(world:readVar(VAR_BLUECARDBALANCE) == 1, "and two points were spent")
|
||||
shot("after-prize")
|
||||
|
||||
--------------------------------------------------------------- section A
|
||||
say("--- A: the Battle Tower desk, with the real TryQuickSave")
|
||||
|
||||
-- A file has to already exist for AskOverwriteSaveFile to have anything to
|
||||
-- ask about (`ld a, [wSaveFileExists] / and a / jr z, .erase`).
|
||||
ok(game:writeSave() ~= false, "a first save is on disk")
|
||||
ok(Save.exists(save.version), "Save.exists agrees")
|
||||
|
||||
save.party = {}
|
||||
for _, row in ipairs({ { "TYPHLOSION", 20 }, { "FERALIGATR", 20 },
|
||||
{ "MEGANIUM", 20 } }) do
|
||||
local mon = Mon.new(game.data, row[1], row[2])
|
||||
save.party[#save.party + 1] = mon
|
||||
end
|
||||
|
||||
for _ = 1, 40 do
|
||||
if top() == nil then break end
|
||||
tap("b", 2)
|
||||
end
|
||||
ok(world:setMap("BATTLE_TOWER_1F", 7, 7, "up") == true, "on BATTLE_TOWER_1F")
|
||||
U.wait(40)
|
||||
shot("tower-lobby")
|
||||
|
||||
local function isMenu() return getmetatable(top()) == ScriptMenu end
|
||||
local function isRoomMenu() return getmetatable(top()) == BattleTowerMenu end
|
||||
|
||||
local reached = false
|
||||
for _ = 1, 200 do
|
||||
if isMenu() then reached = true break end
|
||||
if world.choicebox then tap("b", 4) else tap("a", 4) end
|
||||
end
|
||||
ok(reached, "the receptionist reaches Menu_ChallengeExplanationCancel")
|
||||
tap("a", 6) -- CHALLENGE
|
||||
|
||||
-- Two yes/no prompts now, not one: Text_SaveBeforeEnteringBattleRoom and
|
||||
-- then AskOverwriteSaveFile's own, which the stub never asked.
|
||||
local prompts, pages = 0, {}
|
||||
for _ = 1, 240 do
|
||||
if isRoomMenu() then break end
|
||||
if world.choicebox then
|
||||
prompts = prompts + 1
|
||||
shot("prompt-" .. prompts)
|
||||
say("prompt " .. prompts .. ": " .. tostring(world.lastText))
|
||||
tap("a", 6)
|
||||
else
|
||||
local body = world.lastText
|
||||
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
|
||||
pages[#pages + 1] = body
|
||||
end
|
||||
tap("a", 4)
|
||||
end
|
||||
end
|
||||
say("pages: " .. table.concat(pages, " | "))
|
||||
ok(prompts >= 2,
|
||||
"AskOverwriteSaveFile asked its own question (" .. prompts .. " prompts)")
|
||||
local joined = table.concat(pages, " | ")
|
||||
ok(joined:find("SAVING", 1, true) ~= nil, "the SAVING page was shown")
|
||||
ok(joined:find("saved", 1, true) ~= nil, "and SavedTheGameText")
|
||||
ok(isRoomMenu(), "TryQuickSave answered TRUE and the room menu opened")
|
||||
shot("room-menu")
|
||||
|
||||
local onDisk = Save.load(save.version)
|
||||
ok(onDisk ~= nil, "the challenge's own save reached disk")
|
||||
ok(onDisk and onDisk.position and onDisk.position.map == "BATTLE_TOWER_1F",
|
||||
"and it was written from the lobby: "
|
||||
.. tostring(onDisk and onDisk.position and onDisk.position.map))
|
||||
|
||||
-- The room menu's own header is STATICMENU_DISABLE_B, so the way out of the
|
||||
-- desk is forward: pick a room and let the walk to the elevator finish.
|
||||
for _ = 1, 400 do
|
||||
if not world:busy() and top() == nil then break end
|
||||
tap("a", 4)
|
||||
end
|
||||
say("map after the desk: " .. tostring(world.map and world.map.id))
|
||||
for _ = 1, 40 do
|
||||
if top() == nil then break end
|
||||
game.stack:pop()
|
||||
end
|
||||
|
||||
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
-- U2 verification: ENGINE_PLAYER_IS_FEMALE end to end, plus the four gendered
|
||||
-- pictures and the Crystal surf refusal. Not a suite; a shot harness.
|
||||
--
|
||||
-- POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 POKEPORT_GENDER=girl \
|
||||
-- POKEPORT_SHOT_DIR=<dir> \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_flag_shots.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local GenderSelect = require("src.ui.gen2.GenderSelect")
|
||||
local HallOfFame = require("src.ui.gen2.HallOfFame")
|
||||
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local NamePick = require("src.ui.gen2.NamePick")
|
||||
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/u2-gender"
|
||||
local want = (os.getenv("POKEPORT_GENDER") or "girl"):lower()
|
||||
local log = io.open(out .. "/driver.log", "w")
|
||||
|
||||
local function say(line)
|
||||
print("[u2] " .. line)
|
||||
if log then log:write(line .. "\n"); log:flush() end
|
||||
end
|
||||
|
||||
local function done(code)
|
||||
if log then log:close() end
|
||||
love.event.quit(code)
|
||||
if code ~= 0 then error("driver failed", 0) end
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
local function bail(reason)
|
||||
say("FAIL " .. reason)
|
||||
done(1)
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isA(class)
|
||||
local s = top()
|
||||
return s ~= nil and getmetatable(s) == class
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function waitFor(label, predicate, frames)
|
||||
for _ = 1, frames or 900 do
|
||||
if predicate() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
|
||||
end
|
||||
|
||||
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
|
||||
say("fixes.surfOntoNpc=" .. tostring(GameVersion.fixes().surfOntoNpc))
|
||||
|
||||
------------------------------------------------------------------ new game
|
||||
U.wait(10)
|
||||
for _ = 1, 1200 do
|
||||
if isA(MainMenu) then break end
|
||||
tap("start", 2)
|
||||
end
|
||||
waitFor("the main menu", function() return isA(MainMenu) end, 600)
|
||||
local menu = top()
|
||||
for i, item in ipairs(menu.list.items) do
|
||||
if item.value == "new" then menu.list.index = i end
|
||||
end
|
||||
tap("a")
|
||||
|
||||
local gendered = false
|
||||
for _ = 1, 400 do
|
||||
if isA(GenderSelect) then gendered = true break end
|
||||
if isA(OakSpeech) or isA(NamePick) then break end
|
||||
U.wait(1)
|
||||
end
|
||||
if GameVersion.engine() == "crystal" then
|
||||
if not gendered then bail("Crystal never offered InitGender") end
|
||||
local pick = top()
|
||||
pick.cursor = (want == "girl") and 2 or 1
|
||||
U.shot(game, out .. "/00-gender.png")
|
||||
say("OK gender screen, choosing " .. want)
|
||||
tap("a")
|
||||
else
|
||||
if gendered then bail("Gold offered a gender prompt; it has none") end
|
||||
say("OK no gender prompt on " .. GameVersion.get())
|
||||
end
|
||||
|
||||
for _ = 1, 900 do
|
||||
if game.phase == "play" and game.world and game.world.map then break end
|
||||
if isA(NamingScreen) then
|
||||
local naming = top()
|
||||
naming.row = naming:bottomRow()
|
||||
naming.col = 6
|
||||
end
|
||||
tap("a", 2)
|
||||
end
|
||||
if not (game.phase == "play" and game.world and game.world.map) then
|
||||
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
|
||||
end
|
||||
local world = game.world
|
||||
local save = game.save
|
||||
say("OK overworld, name=" .. tostring(save.player.name)
|
||||
.. " gender=" .. tostring(save.player.gender)
|
||||
.. " sprite=" .. tostring(world:playerSpriteName()))
|
||||
|
||||
------------------------------------------------------ the engine flag itself
|
||||
local id = FieldMoves.FEMALE_FLAG
|
||||
say("FieldMoves.FEMALE_FLAG=" .. tostring(id))
|
||||
if GameVersion.engine() == "crystal" then
|
||||
if id ~= 99 then bail("female flag id is " .. tostring(id) .. ", want 99") end
|
||||
local reads = world:engineFlag(id)
|
||||
say("world:engineFlag(" .. id .. ")=" .. tostring(reads))
|
||||
if reads ~= (want == "girl") then
|
||||
bail("checkflag ENGINE_PLAYER_IS_FEMALE read " .. tostring(reads))
|
||||
end
|
||||
elseif id ~= nil then
|
||||
bail("Gold bound a female flag id: " .. tostring(id))
|
||||
end
|
||||
|
||||
------------------------------------------------------------- the three sites
|
||||
local sites = {
|
||||
{ "COPYCATS_HOUSE_2F", 3, 4, "up", "01-copycat" },
|
||||
{ "ROUTE_37", 5, 9, "up", "02-route37" },
|
||||
{ "POKECENTER_2F", 5, 5, "up", "03-pokecenter2f" },
|
||||
}
|
||||
for _, site in ipairs(sites) do
|
||||
local mapId, x, y, facing, name = site[1], site[2], site[3], site[4], site[5]
|
||||
if world.maps and world.maps[mapId] then
|
||||
world:setMap(mapId, x, y, facing)
|
||||
U.wait(40)
|
||||
U.shot(game, out .. "/" .. name .. ".png")
|
||||
local names = {}
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
names[#names + 1] = tostring(npc.def and npc.def.sprite)
|
||||
end
|
||||
say(name .. " map=" .. tostring(world.map.id)
|
||||
.. " objects=" .. table.concat(names, ","))
|
||||
else
|
||||
say("SKIP " .. mapId .. ": not in this cache")
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------------- the pack
|
||||
world:setMap("NEW_BARK_TOWN", 13, 6, "down")
|
||||
U.wait(20)
|
||||
save.inventory = save.inventory or {}
|
||||
save.inventory.POTION = 3
|
||||
Screens.push(game, "Gen2PackMenu", { save = save, world = {},
|
||||
onClose = function() game.stack:pop() end })
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/04-pack.png")
|
||||
local packGfx = top() and top().gfx
|
||||
local pals = packGfx and packGfx.gfx and packGfx.gfx.palettes
|
||||
local menuGfx = game.data.gen2MenuGfx or {}
|
||||
local femalePals = menuGfx.pack and menuGfx.pack.palettesFemale
|
||||
say("pack palettes are the female set: "
|
||||
.. tostring(pals ~= nil and pals == femalePals))
|
||||
tap("b")
|
||||
U.wait(10)
|
||||
while top() do game.stack:pop() end
|
||||
|
||||
------------------------------------------------------------ battle back pic
|
||||
local hud = menuGfx.battleHud or {}
|
||||
say("battleHud.playerBack=" .. tostring(hud.playerBack)
|
||||
.. " playerBackFemale=" .. tostring(hud.playerBackFemale))
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 12) }
|
||||
world:startBattle({ wild = Mon.new(game.data, "WOOPER", 5) })
|
||||
local battle
|
||||
for _ = 1, 600 do
|
||||
if getmetatable(top()) == BattleState then battle = top() break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not battle then bail("startBattle never reached the battle screen") end
|
||||
-- showPlayerTrainer is only true until SendOutPlayerMon; hold it so the
|
||||
-- back pic is what the shot catches.
|
||||
for _ = 1, 90 do
|
||||
battle.showPlayerTrainer = true
|
||||
U.wait(1)
|
||||
end
|
||||
battle.showPlayerTrainer = true
|
||||
U.shot(game, out .. "/05-battle-backpic.png")
|
||||
say("battle backpic path=" .. tostring(battle.playerBackPath))
|
||||
while top() do game.stack:pop() end
|
||||
U.wait(10)
|
||||
|
||||
------------------------------------------------------------- trainer card
|
||||
Screens.push(game, "Gen2TrainerCard", { onClose = function()
|
||||
game.stack:pop() end })
|
||||
U.wait(30)
|
||||
U.shot(game, out .. "/06-trainercard.png")
|
||||
say("trainer card female arm=" .. tostring(top() and top().female))
|
||||
tap("b")
|
||||
U.wait(10)
|
||||
while top() do game.stack:pop() end
|
||||
|
||||
------------------------------------------------------------- hall of fame
|
||||
save.hallOfFame = nil
|
||||
Screens.push(game, "Gen2HallOfFame", {
|
||||
save = save, mode = "induct", text = world.text,
|
||||
entry = { winCount = 1, mons = { { species = "CYNDAQUIL", level = 12,
|
||||
nickname = "CYNDAQUIL", otId = 1234, gender = "male" } } },
|
||||
onDone = function() game.stack:pop() end })
|
||||
U.wait(20)
|
||||
local hof = top()
|
||||
if getmetatable(hof) ~= HallOfFame then
|
||||
bail("Gen2HallOfFame did not push (top is " .. tostring(hof) .. ")")
|
||||
end
|
||||
-- The ceremony walks its own phases; freeze it so the two player cards can
|
||||
-- be posed and shot.
|
||||
hof.update = function() end
|
||||
hof.scx, hof.scy = 0, 0
|
||||
hof.phase = "player"
|
||||
U.shot(game, out .. "/07-hof-trainerpic.png")
|
||||
hof.phase = "playerBack"
|
||||
U.shot(game, out .. "/08-hof-backpic.png")
|
||||
say("hof backpic=" .. tostring(hof.playerBackPath)
|
||||
.. " trainerPic=" .. tostring(hof.trainerPicPath))
|
||||
while top() do game.stack:pop() end
|
||||
U.wait(10)
|
||||
|
||||
------------------------------------------------------------- surf onto NPC
|
||||
--
|
||||
-- SurfFunction.TrySurf with a facing object: Crystal refuses, Gold does not
|
||||
-- (../pokecrystal/engine/events/overworld.asm:364).
|
||||
local ctx = {
|
||||
save = { player = { badges = { FOG = true } } },
|
||||
mon = { species = "LAPRAS" },
|
||||
facing = "down",
|
||||
facingColl = 0x29,
|
||||
playerColl = 0x00,
|
||||
playerState = FieldMoves.PLAYER_NORMAL,
|
||||
facingObject = { id = "an NPC standing on the water" },
|
||||
}
|
||||
local refused = FieldMoves.surfFromMenu(ctx)
|
||||
say("surf onto an NPC: ok=" .. tostring(refused.ok)
|
||||
.. " text=" .. tostring(refused.text))
|
||||
local shouldRefuse = GameVersion.fixes().surfOntoNpc == true
|
||||
if (refused.ok ~= true) ~= shouldRefuse then
|
||||
bail("surf-onto-NPC answered ok=" .. tostring(refused.ok)
|
||||
.. " but fixes().surfOntoNpc=" .. tostring(shouldRefuse))
|
||||
end
|
||||
ctx.facingObject = nil
|
||||
if not FieldMoves.surfFromMenu(ctx).ok then
|
||||
bail("open water refused SURF")
|
||||
end
|
||||
|
||||
say("PASS " .. GameVersion.get() .. " / " .. want .. " in " .. out)
|
||||
done(0)
|
||||
end
|
||||
@@ -0,0 +1,234 @@
|
||||
-- Crystal's InitGender screen, end to end, and the six things that follow from
|
||||
-- the answer. POKEPORT_GENDER picks the arm: "girl", "boy" or "none" (Gold,
|
||||
-- which must never see the screen at all).
|
||||
--
|
||||
-- POKEPORT_IDENTITY=<sandbox> POKEPORT_GAME=crystal POKEPORT_BOOT_CINEMA=1 \
|
||||
-- POKEPORT_GENDER=girl POKEPORT_SHOT_DIR=<dir> \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_gender_shots.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local GenderSelect = require("src.ui.gen2.GenderSelect")
|
||||
local InitClock = require("src.ui.gen2.InitClock")
|
||||
local MainMenu = require("src.ui.gen2.MainMenu")
|
||||
local NamePick = require("src.ui.gen2.NamePick")
|
||||
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||
local OakSpeech = require("src.ui.gen2.OakSpeech")
|
||||
local TrainerCard = require("src.ui.gen2.TrainerCard")
|
||||
|
||||
local FieldMoves = require("src.world.gen2.FieldMoves")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Screens = require("src.ui.Screens")
|
||||
|
||||
return function(game)
|
||||
local want = os.getenv("POKEPORT_GENDER") or "girl"
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or ("/tmp/crystal-gender-" .. want)
|
||||
local log = io.open(out .. "/driver.log", "w")
|
||||
|
||||
local function say(line)
|
||||
print("[driver] " .. line)
|
||||
if log then log:write(line .. "\n"); log:flush() end
|
||||
end
|
||||
|
||||
local function bail(reason)
|
||||
say("FAIL " .. reason)
|
||||
if log then log:close() end
|
||||
love.event.quit(1)
|
||||
error(reason, 0)
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function isA(class)
|
||||
local state = top()
|
||||
return state ~= nil and getmetatable(state) == class
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function waitFor(label, predicate, frames)
|
||||
for _ = 1, frames or 900 do
|
||||
if predicate() then return end
|
||||
U.wait(1)
|
||||
end
|
||||
bail(("stalled waiting for %s (top is %s)"):format(label, tostring(top())))
|
||||
end
|
||||
|
||||
local function eq(got, wanted, label)
|
||||
if got ~= wanted then
|
||||
bail(("%s: got %s, want %s"):format(label, tostring(got),
|
||||
tostring(wanted)))
|
||||
end
|
||||
say("OK " .. label .. " = " .. tostring(got))
|
||||
end
|
||||
|
||||
say("version=" .. tostring(GameVersion.get())
|
||||
.. " engine=" .. tostring(GameVersion.engine())
|
||||
.. " gender=" .. want)
|
||||
|
||||
-- Straight to the intro menu; the boot chain itself is crystal_boot_smoke's.
|
||||
for _ = 1, 900 do
|
||||
if isA(MainMenu) then break end
|
||||
tap("start", 2)
|
||||
end
|
||||
if not isA(MainMenu) then
|
||||
for _ = 1, 400 do
|
||||
if isA(MainMenu) then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
end
|
||||
waitFor("the intro menu", function() return isA(MainMenu) end)
|
||||
local menu = top()
|
||||
for i, item in ipairs(menu.list.items) do
|
||||
if item.value == "new" then menu.list.index = i end
|
||||
end
|
||||
tap("a")
|
||||
|
||||
if want == "none" then
|
||||
-- Gold and Silver: PlayerProfileSetup has no InitGender to farcall.
|
||||
waitFor("the clock screen", function() return isA(InitClock) end, 400)
|
||||
if isA(GenderSelect) then bail("Gold put up the gender screen") end
|
||||
say("OK no gender screen; NEW GAME went straight to the clock")
|
||||
for _ = 1, 200 do
|
||||
if isA(OakSpeech) then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
|
||||
local speech = top()
|
||||
for _, step in ipairs(speech.steps or {}) do
|
||||
if step.kind == "gender" then bail("Gold's speech grew a gender beat") end
|
||||
end
|
||||
eq(speech.steps and speech.steps[1] and speech.steps[1].id, "init_clock",
|
||||
"the first beat")
|
||||
U.wait(40)
|
||||
U.shot(game, out .. "/01-oak.png")
|
||||
eq(game.save.player.gender, "male", "save.player.gender")
|
||||
say("PASS gold has no gender prompt")
|
||||
if log then log:close() end
|
||||
love.event.quit(0)
|
||||
return
|
||||
end
|
||||
|
||||
waitFor("the gender screen", function() return isA(GenderSelect) end, 600)
|
||||
local gender = top()
|
||||
U.wait(20)
|
||||
say("OK gender screen, cursor = " .. tostring(gender.cursor))
|
||||
U.shot(game, out .. "/01-genderselect.png")
|
||||
if want == "girl" then
|
||||
tap("down")
|
||||
U.wait(10)
|
||||
U.shot(game, out .. "/02-genderselect-girl.png")
|
||||
else
|
||||
U.shot(game, out .. "/02-genderselect-boy.png")
|
||||
end
|
||||
tap("a")
|
||||
|
||||
waitFor("the clock screen", function() return isA(InitClock) end, 400)
|
||||
eq(game.save.player.gender, want == "girl" and "female" or "male",
|
||||
"wPlayerGender after the answer")
|
||||
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
|
||||
"NamePlayer's InitName default")
|
||||
|
||||
for _ = 1, 200 do
|
||||
if isA(OakSpeech) then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
waitFor("the Oak speech", function() return isA(OakSpeech) end, 400)
|
||||
local speech = top()
|
||||
eq(speech.steps[1].id, "gender_select", "the beat the speech opened on")
|
||||
|
||||
local shotPic = false
|
||||
for _ = 1, 500 do
|
||||
if isA(NamePick) then break end
|
||||
if not shotPic and isA(OakSpeech) then
|
||||
local at = top().steps and top().steps[top().step]
|
||||
if at and at.id == "ask_player_name" then
|
||||
U.wait(45)
|
||||
local wanted = (want == "girl") and top().playerPicFemale
|
||||
or top().playerPic
|
||||
if top().pic ~= wanted then
|
||||
bail("DrawIntroPlayerPic put up the wrong pic")
|
||||
end
|
||||
U.shot(game, out .. "/03-intropic.png")
|
||||
shotPic = true
|
||||
end
|
||||
end
|
||||
tap("a", 2)
|
||||
end
|
||||
waitFor("the name picker", function() return isA(NamePick) end, 200)
|
||||
waitFor("the name menu to slide in",
|
||||
function() return top().slide == nil end, 240)
|
||||
local picker = top()
|
||||
eq(picker.items[2], want == "girl" and "KRIS" or "CHRIS", "preset row 1")
|
||||
eq(picker.items[3], want == "girl" and "AMANDA" or "MAT", "preset row 2")
|
||||
U.shot(game, out .. "/04-namepick.png")
|
||||
|
||||
picker.cursor = 1
|
||||
tap("a")
|
||||
waitFor("the naming screen", function() return isA(NamingScreen) end)
|
||||
local naming = top()
|
||||
eq(naming.gender, want == "girl" and "female" or "male",
|
||||
"the keyboard's header icon gender")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/05-naming.png")
|
||||
naming.row = naming:bottomRow()
|
||||
naming.col = 6
|
||||
tap("a")
|
||||
|
||||
for _ = 1, 700 do
|
||||
if game.phase == "play" and game.world and game.world.map then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
if not (game.phase == "play" and game.world and game.world.map) then
|
||||
bail("never reached the overworld (top is " .. tostring(top()) .. ")")
|
||||
end
|
||||
eq(game.world.map.id, "PLAYERS_HOUSE_2F", "the bedroom")
|
||||
eq(game.save.player.name, want == "girl" and "KRIS" or "CHRIS",
|
||||
"the name that reached the save")
|
||||
local sprite = game.world.player and game.world.player.spriteDef
|
||||
eq(sprite and sprite.id, FieldMoves.playerSprite(game.save.player.gender),
|
||||
"the overworld sheet")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/06-bedroom.png")
|
||||
|
||||
-- Walk a step so the sheet is seen mid-stride rather than only standing.
|
||||
U.hold(game, "down", 20)
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/07-bedroom-walk.png")
|
||||
|
||||
-- The card, opened directly: the START-menu route is Gen2StartMenu's and is
|
||||
-- already covered by crystal_boot_smoke.
|
||||
game.save.player.badges = { ZEPHYR = true, HIVE = true }
|
||||
local card = Screens.push(game, "Gen2TrainerCard",
|
||||
{ save = game.save, onClose = function() end })
|
||||
if getmetatable(card) ~= TrainerCard then bail("the card did not open") end
|
||||
eq(card.female, want == "girl", "GetCardPic picked KrisCardPic")
|
||||
U.wait(30)
|
||||
U.shot(game, out .. "/08-trainercard.png")
|
||||
tap("right")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/09-trainercard-badges.png")
|
||||
game.stack:pop()
|
||||
|
||||
local gear = Screens.push(game, "Gen2Pokegear", { save = game.save })
|
||||
U.wait(30)
|
||||
local pals = gear.pals and gear:pals()
|
||||
local femalePals = gear.gfx and gear.gfx.palettesFemale
|
||||
if want == "girl" and femalePals and pals ~= femalePals then
|
||||
bail("the gear kept MalePokegearPals for Kris")
|
||||
end
|
||||
if want == "boy" and femalePals and pals == femalePals then
|
||||
bail("the gear took FemalePokegearPals for Chris")
|
||||
end
|
||||
say("OK pokegear palettes = " .. (pals == femalePals and "female" or "male"))
|
||||
U.shot(game, out .. "/10-pokegear.png")
|
||||
game.stack:pop()
|
||||
|
||||
say("PASS crystal gender run (" .. want .. ") in " .. out)
|
||||
if log then log:close() end
|
||||
love.event.quit(0)
|
||||
end
|
||||
@@ -0,0 +1,117 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local CrystalIntro = require("src.ui.gen2.CrystalIntro")
|
||||
local TitleState = require("src.ui.gen2.TitleState")
|
||||
|
||||
local INTERVAL = 20
|
||||
local LIMIT = 4000
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-intro"
|
||||
local interval = tonumber(os.getenv("POKEPORT_SHOT_INTERVAL") or "")
|
||||
or INTERVAL
|
||||
|
||||
U.wait(30)
|
||||
local assets = game.data and game.data.gen2Intro
|
||||
if not (assets and assets.acts) then
|
||||
U.log("SKIP no crystal intro acts in this cache -- re-import crystal")
|
||||
return
|
||||
end
|
||||
|
||||
local finished = false
|
||||
local intro = CrystalIntro.new(game, {
|
||||
onDone = function() finished = true end,
|
||||
})
|
||||
game.stack:clear()
|
||||
game.stack:push(intro)
|
||||
|
||||
local shots, scene = 0, 0
|
||||
while not finished and intro.frames < LIMIT do
|
||||
U.wait(interval)
|
||||
if intro.scene ~= scene then
|
||||
scene = intro.scene
|
||||
U.log(("scene %d at frame %d (scx=%02x scy=%02x)")
|
||||
:format(scene, intro.frames, intro.scx % 256, intro.scy % 256))
|
||||
end
|
||||
if not finished then
|
||||
U.shot(game, ("%s/intro-%04d-scene%02d.png")
|
||||
:format(out, intro.frames, intro.scene))
|
||||
shots = shots + 1
|
||||
end
|
||||
end
|
||||
assert(finished,
|
||||
"the movie never reached IntroScene28 (frame " .. intro.frames .. ")")
|
||||
U.log(("movie done: %d shots over %d frames"):format(shots, intro.frames))
|
||||
|
||||
local skipped = false
|
||||
local intro2 = CrystalIntro.new(game, {
|
||||
onDone = function() skipped = true end,
|
||||
})
|
||||
game.stack:clear()
|
||||
game.stack:push(intro2)
|
||||
U.wait(45)
|
||||
U.tap(game, "b")
|
||||
U.wait(3)
|
||||
assert(skipped and intro2.skipped, "a button did not skip the intro")
|
||||
U.log("skip via B verified at frame " .. intro2.frames)
|
||||
|
||||
game:showTitle()
|
||||
U.wait(1)
|
||||
local title = game.stack:top()
|
||||
assert(getmetatable(title) == TitleState,
|
||||
"showTitle left " .. tostring(title) .. " on the stack")
|
||||
assert(#title.suicuneColor == 4,
|
||||
"title.lua has no 4-frame Suicune set -- stale cache?")
|
||||
assert(title.entrance, "title.lua has no entrance block -- stale cache?")
|
||||
assert(title.suicuneX == 48 and title.suicuneY == 96,
|
||||
("Suicune is at (%s, %s), expected (48, 96)")
|
||||
:format(tostring(title.suicuneX), tostring(title.suicuneY)))
|
||||
|
||||
for i = 1, 4 do
|
||||
U.shot(game, ("%s/title-entrance-%d.png"):format(out, i))
|
||||
U.wait(4)
|
||||
end
|
||||
for _ = 1, 90 do
|
||||
if title.entranceScx == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
assert(title.entranceScx == 0, "the entrance never landed")
|
||||
assert(title.gemY == title.gemRestY,
|
||||
("gem y %s did not land at %s with the entrance")
|
||||
:format(tostring(title.gemY), tostring(title.gemRestY)))
|
||||
U.wait(2)
|
||||
for i = 1, 5 do
|
||||
U.shot(game, ("%s/title-suicune-%d.png"):format(out, i))
|
||||
U.wait(6)
|
||||
end
|
||||
|
||||
if love.window and love.window.setMode then
|
||||
for _, shape in ipairs({ { 1280, 720 }, { 1920, 500 }, { 480, 800 } }) do
|
||||
love.window.setMode(shape[1], shape[2], { resizable = true })
|
||||
U.wait(3)
|
||||
U.shot(game, ("%s/title-wide-%dx%d.png"):format(out, shape[1], shape[2]))
|
||||
end
|
||||
love.window.setMode(1280, 720, { resizable = true })
|
||||
end
|
||||
|
||||
local intro3 = CrystalIntro.new(game, {})
|
||||
game.stack:clear()
|
||||
game.stack:push(intro3)
|
||||
local function runTo(target, extra)
|
||||
for _ = 1, LIMIT do
|
||||
if intro3.scene >= target then break end
|
||||
U.wait(5)
|
||||
end
|
||||
assert(intro3.scene >= target,
|
||||
"widescreen pass never reached scene " .. target)
|
||||
U.wait(extra)
|
||||
end
|
||||
runTo(4, 40)
|
||||
U.shot(game, out .. "/intro-wide-scene04.png")
|
||||
runTo(10, 60)
|
||||
U.shot(game, out .. "/intro-wide-scene10.png")
|
||||
runTo(18, 20)
|
||||
U.shot(game, out .. "/intro-wide-scene18.png")
|
||||
intro3:skip()
|
||||
|
||||
U.log(("PASS crystal intro + title shots in %s"):format(out))
|
||||
end
|
||||
@@ -0,0 +1,278 @@
|
||||
-- The Crystal story specials, driven in a real game.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=unitb-crystal POKEPORT_GAME=crystal \
|
||||
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unitb \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_story_shots.lua love .
|
||||
--
|
||||
-- Four sections, each entered by putting the player on the real map and
|
||||
-- letting the extracted script run:
|
||||
-- A TIN_TOWER_1F the Suicune confrontation, and whether it flees
|
||||
-- B ILEX_FOREST the GS Ball shrine, Celebi, and CheckCaughtCelebi
|
||||
-- C DRAGON_SHRINE GiveDratini's Extremespeed moveset
|
||||
-- D DAY_CARE GiveOddEgg
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local NamingScreen = require("src.ui.gen2.NamingScreen")
|
||||
local Roamers = require("src.core.gen2.Roamers")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-story"
|
||||
local fails = 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function battleState()
|
||||
local st = top()
|
||||
if st ~= nil and getmetatable(st) == BattleState then return st end
|
||||
return nil
|
||||
end
|
||||
local function inBattle()
|
||||
local st = battleState()
|
||||
return st and st.battle or nil
|
||||
end
|
||||
|
||||
local function waitFor(pred, frames)
|
||||
for _ = 1, frames or 1200 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local world
|
||||
|
||||
-- Mash A until the script is finished and the stack is bare again. The
|
||||
-- naming screen gets B: `givepoke` ends in Specials.askNickname
|
||||
-- (src/script/gen2/Specials.lua:382) and mashing A there types letters
|
||||
-- forever instead of leaving.
|
||||
-- The naming screen has no B exit: crystal_boot_smoke.lua leaves it by
|
||||
-- putting the cursor on the bottom row's confirm cell. Confirming a blank
|
||||
-- name is the cart's own "no nickname" (home/string.asm:6 _InitString).
|
||||
local function mash()
|
||||
local st = top()
|
||||
if getmetatable(st) == NamingScreen then
|
||||
st.row, st.col = st:bottomRow(), 6
|
||||
end
|
||||
tap("a", 2)
|
||||
end
|
||||
|
||||
local function settle(frames)
|
||||
for _ = 1, frames or 3000 do
|
||||
if not world:busy() and top() == nil then return true end
|
||||
mash()
|
||||
end
|
||||
local meta, name = getmetatable(top()), "?"
|
||||
for id, mod in pairs(package.loaded) do
|
||||
if mod == meta then name = id end
|
||||
end
|
||||
say((" stuck on %s (phase=%s) busy=%s"):format(name,
|
||||
tostring(top() and top().phase), tostring(world:busy())))
|
||||
return false
|
||||
end
|
||||
|
||||
-- Stand one cell below the object carrying `scriptKey`, facing it.
|
||||
local function standBelow(mapId, scriptKey)
|
||||
world:setMap(mapId, 1, 1, "down")
|
||||
U.wait(10)
|
||||
local tx, ty
|
||||
for _, npc in ipairs(world.npcs or {}) do
|
||||
if npc.def and npc.def.scriptKey == scriptKey then
|
||||
tx, ty = npc.cellX, npc.cellY
|
||||
end
|
||||
end
|
||||
if not tx then return false end
|
||||
world:setMap(mapId, tx, ty + 1, "up")
|
||||
U.wait(30)
|
||||
return world.player.cellX == tx and world.player.cellY == ty + 1
|
||||
end
|
||||
|
||||
local function moveIds(mon)
|
||||
local list = {}
|
||||
for _, move in ipairs((mon and mon.moves) or {}) do
|
||||
list[#list + 1] = move.id
|
||||
end
|
||||
return table.concat(list, ",")
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
|
||||
|
||||
local save = game.save
|
||||
save.player = save.player or {}
|
||||
save.player.name = save.player.name or "CHRIS"
|
||||
save.player.id = save.player.id or 30000
|
||||
save.inventory = save.inventory or {}
|
||||
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
|
||||
|
||||
-- ---- A TIN_TOWER_1F ----------------------------------------------------
|
||||
-- ../pokecrystal/maps/TinTower1F.asm:84 TinTower1FSuicuneBattleScript, run
|
||||
-- from the SCENE_TINTOWER1F_SUICUNE_BATTLE scene script at :22.
|
||||
say("A Tin Tower")
|
||||
say(" crystal AlwaysFleeMons SUICUNE = "
|
||||
.. tostring(Roamers.alwaysFleeMons("crystal").SUICUNE)
|
||||
.. ", gold = " .. tostring(Roamers.alwaysFleeMons("gold").SUICUNE))
|
||||
world.mapScenes.TIN_TOWER_1F = 0
|
||||
world:setMap("TIN_TOWER_1F", 9, 14, "up")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/a1-tintower-enter.png")
|
||||
|
||||
ok(waitFor(function() return world:busy() end, 600),
|
||||
"the scene script started on map entry")
|
||||
U.wait(120)
|
||||
U.shot(game, out .. "/a2-beasts.png")
|
||||
ok(waitFor(function() return inBattle() ~= nil end, 3000),
|
||||
"the Tin Tower scene reached a battle")
|
||||
local battle = inBattle()
|
||||
if battle then
|
||||
U.wait(90)
|
||||
U.shot(game, out .. "/a3-suicune-battle.png")
|
||||
ok(battle.enemy and battle.enemy.species == "SUICUNE",
|
||||
"the wild mon is SUICUNE (got " ..
|
||||
tostring(battle.enemy and battle.enemy.species) .. ")")
|
||||
ok((battle.enemy and battle.enemy.level) == 40, "at level 40")
|
||||
-- ../pokecrystal/engine/battle/core.asm:759 TryEnemyFlee: with Suicune off
|
||||
-- AlwaysFleeMons it has to survive the two random gates as well.
|
||||
local fled = 0
|
||||
for _ = 1, 500 do
|
||||
if battle:tryEnemyFlee() then fled = fled + 1 end
|
||||
end
|
||||
ok(fled == 0, "Suicune never flees in 500 rolls (fled " .. fled .. ")")
|
||||
ok(battle.outcome == nil, "and the battle is still live")
|
||||
battle.outcome = "run"
|
||||
local st = battleState()
|
||||
if st then st:finishBattle() end
|
||||
end
|
||||
ok(settle(2000), "the Tin Tower script ran to its end")
|
||||
|
||||
-- ---- B ILEX_FOREST -----------------------------------------------------
|
||||
-- ../pokecrystal/maps/IlexForest.asm:429 IlexForestShrineScript, a BGEVENT_UP
|
||||
-- at (8,22). EVENT_FOREST_IS_RESTLESS and the GS Ball are the two gates; on
|
||||
-- a retail cart neither can be reached, so both are set here the way the
|
||||
-- Virtual Console wrapper sets sGSBallFlag
|
||||
-- (../pokecrystal/engine/menus/save.asm:168).
|
||||
say("B Ilex Forest shrine")
|
||||
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
|
||||
Save.crystalState(save).gsBall = "have"
|
||||
save.inventory.GS_BALL = 1
|
||||
save.inventory.MASTER_BALL = 5
|
||||
world:setMap("ILEX_FOREST", 8, 23, "up")
|
||||
U.wait(40)
|
||||
world.events:set(192, true)
|
||||
U.shot(game, out .. "/b1-shrine.png")
|
||||
|
||||
tap("a", 30)
|
||||
U.shot(game, out .. "/b2-shrine-prompt.png")
|
||||
-- The prompt is the script's own yesorno; A takes YES.
|
||||
ok(waitFor(function()
|
||||
if inBattle() then return true end
|
||||
tap("a", 3)
|
||||
return false
|
||||
end, 400), "the shrine script reached a battle")
|
||||
local celebi = inBattle()
|
||||
if celebi then
|
||||
U.wait(90)
|
||||
U.shot(game, out .. "/b3-celebi.png")
|
||||
ok(celebi.enemy and celebi.enemy.species == "CELEBI",
|
||||
"the wild mon is CELEBI (got " ..
|
||||
tostring(celebi.enemy and celebi.enemy.species) .. ")")
|
||||
ok(celebi.battleType == 11,
|
||||
"the battle carries BATTLETYPE_CELEBI (got "
|
||||
.. tostring(celebi.battleType) .. ")")
|
||||
-- Catch it with a MASTER BALL so CheckCaughtCelebi has something to read.
|
||||
local thrown = false
|
||||
for _ = 1, 900 do
|
||||
local st = battleState()
|
||||
if not st then break end
|
||||
if not thrown and st.phase == "menu" then
|
||||
st:useItem("MASTER_BALL")
|
||||
thrown = true
|
||||
U.wait(60)
|
||||
U.shot(game, out .. "/b4-masterball.png")
|
||||
end
|
||||
mash()
|
||||
end
|
||||
end
|
||||
ok(settle(2000), "the shrine script ran to its end")
|
||||
U.shot(game, out .. "/b5-after-celebi.png")
|
||||
local caught = false
|
||||
for _, mon in ipairs(save.party) do
|
||||
if mon.species == "CELEBI" then caught = true end
|
||||
end
|
||||
ok(caught, "Celebi is in the party")
|
||||
ok(Save.crystalState(save).celebiCaught == true,
|
||||
"CheckCaughtCelebi recorded the catch")
|
||||
ok((save.inventory.GS_BALL or 0) == 0, "the GS Ball was taken")
|
||||
|
||||
-- ---- C DRAGON_SHRINE ---------------------------------------------------
|
||||
-- ../pokecrystal/maps/DragonShrine.asm:192 DragonShrineElder1Script, whose
|
||||
-- .GiveDratini arm (:208) is `givepoke DRATINI, 15 / checkevent
|
||||
-- EVENT_ANSWERED_DRAGON_MASTER_QUIZ_WRONG / special GiveDratini`.
|
||||
say("C Dragon Shrine")
|
||||
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
|
||||
-- ../pokecrystal/maps/DragonShrine.asm:10 SCENE_DRAGONSHRINE_NOOP; scene 0 is
|
||||
-- the Dragon Master quiz cutscene, which is not what this section measures.
|
||||
world.mapScenes.DRAGON_SHRINE = 1
|
||||
ok(standBelow("DRAGON_SHRINE", "63:51a5"), "found the shrine elder")
|
||||
U.shot(game, out .. "/c1-shrine.png")
|
||||
tap("a", 30)
|
||||
U.shot(game, out .. "/c2-elder.png")
|
||||
ok(settle(2000), "the elder's script ran to its end")
|
||||
U.shot(game, out .. "/c3-dratini.png")
|
||||
local dratini
|
||||
for _, mon in ipairs(save.party) do
|
||||
if mon.species == "DRATINI" then dratini = mon end
|
||||
end
|
||||
ok(dratini ~= nil, "the elder handed over a DRATINI")
|
||||
if dratini then
|
||||
say(" moves = " .. moveIds(dratini))
|
||||
ok(moveIds(dratini) == "WRAP,THUNDER_WAVE,TWISTER,EXTREMESPEED",
|
||||
"with .Moveset0, Extremespeed and all")
|
||||
ok(dratini.moves[4].pp == 5, "Extremespeed arrives with 5 PP")
|
||||
end
|
||||
|
||||
-- ---- D DAY_CARE --------------------------------------------------------
|
||||
-- ../pokecrystal/maps/DayCare.asm:23 DayCareManScript_Inside, whose
|
||||
-- `special GiveOddEgg` is at :33.
|
||||
say("D Day Care")
|
||||
save.party = { Mon.new(game.data, "TYPHLOSION", 50) }
|
||||
save.inventory.EGG_TICKET = 1
|
||||
ok(standBelow("DAY_CARE", "18:6f8f"), "found the Day-Care Man")
|
||||
U.shot(game, out .. "/d1-daycare.png")
|
||||
tap("a", 30)
|
||||
U.shot(game, out .. "/d2-gramps.png")
|
||||
ok(settle(2000), "the Day-Care Man's script ran to its end")
|
||||
U.shot(game, out .. "/d3-oddegg.png")
|
||||
local egg = save.party[2]
|
||||
ok(egg ~= nil and egg.isEgg == true, "an EGG joined the party")
|
||||
if egg then
|
||||
say((" %s ot=%s otId=%s eggSteps=%s moves=%s"):format(
|
||||
tostring(egg.species), tostring(egg.ot), tostring(egg.otId),
|
||||
tostring(egg.eggSteps), moveIds(egg)))
|
||||
ok(egg.ot == "ODD", "with OT ODD")
|
||||
ok(egg.eggSteps == 20, "and 20 hatch cycles")
|
||||
end
|
||||
ok((save.inventory.EGG_TICKET or 0) == 0, "the EGG TICKET was tossed")
|
||||
|
||||
say(fails == 0 and ("PASS crystal story in " .. out)
|
||||
or ("FAIL " .. fails .. " checks, shots in " .. out))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,277 @@
|
||||
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
|
||||
-- opponent draw, the walk-in, the battle, and the counter the room script
|
||||
-- reads back.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=f1-tower POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_tower_battle_f1.lua love .
|
||||
--
|
||||
-- src/world/gen2/World.lua has no startTowerBattle / setObjectSprite hook and
|
||||
-- World:startBattle does not forward `battleTower` into Battle.new, so the
|
||||
-- three seams are shimmed here for the length of the run. The bodies are
|
||||
-- exactly what belongs in World:specialHooks and World:startBattle.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local BattleTower = require("src.core.gen2.BattleTower")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
-- The world exists before a POKEPORT_DRIVER chunk loads (main.lua:426-438),
|
||||
-- so the seams go on the live instance.
|
||||
local function shimWorldSeams(world)
|
||||
local towerBattle = false
|
||||
local baseNew = Battle.new
|
||||
Battle.new = function(opts)
|
||||
if towerBattle then opts.battleTower = true end
|
||||
return baseNew(opts)
|
||||
end
|
||||
|
||||
local baseStartBattle = World.startBattle
|
||||
world.startBattle = function(self, opts, onDone)
|
||||
towerBattle = (opts and opts.battleTower) and true or false
|
||||
local started = baseStartBattle(self, opts, onDone)
|
||||
towerBattle = false
|
||||
return started
|
||||
end
|
||||
|
||||
local hooks = world.vm.specials
|
||||
hooks.startTowerBattle = function(trainer, onDone)
|
||||
return world:startBattle({ trainer = trainer, battleTower = true }, onDone)
|
||||
end
|
||||
hooks.setObjectSprite = function(objectId, spriteName)
|
||||
local index = (objectId or 0) - 1
|
||||
local def = world.map and world.map.def
|
||||
local obj = def and def.objects and def.objects[index]
|
||||
local sheet = world.sprites and world.sprites[spriteName]
|
||||
if not (obj and sheet) then return false end
|
||||
obj.sprite = spriteName
|
||||
local npc = world:objectEntity(objectId)
|
||||
if npc and npc:setSpriteDef(sheet) then world:applySpritePalette(npc) end
|
||||
world:rebuildPeople({ seamless = true })
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- the run --------------------------------------------------------------
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
|
||||
local fails, shots = 0, 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
local function shot(name)
|
||||
shots = shots + 1
|
||||
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
|
||||
end
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
|
||||
shimWorldSeams(world)
|
||||
ok(type(world.vm.specials.startTowerBattle) == "function",
|
||||
"the shimmed startTowerBattle hook reached the VM")
|
||||
ok(type(world.vm.specials.setObjectSprite) == "function",
|
||||
"and so did setObjectSprite")
|
||||
|
||||
local roster = BattleTower.roster(game.data)
|
||||
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
|
||||
if roster then
|
||||
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
|
||||
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
|
||||
roster.sampleTrainers))
|
||||
end
|
||||
|
||||
-- Three legal mons over the L10 room, each with ONE strong move so a blind
|
||||
-- A-press run cannot pick a status move for a hundred turns.
|
||||
local party = {}
|
||||
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
|
||||
{ "FERALIGATR", 60, "SURF", "BERRY" },
|
||||
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
|
||||
local def = game.data.moves[row[3]]
|
||||
local mon = Mon.new(game.data, row[1], row[2], {
|
||||
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
|
||||
})
|
||||
mon.item = row[4]
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
game.save.party = party
|
||||
|
||||
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
|
||||
-- the L10 one (battle_tower.asm:1129-1141).
|
||||
local tower = BattleTower.state(game.save)
|
||||
tower.levelGroup = 1
|
||||
tower.streak = 0
|
||||
tower.trainers = {}
|
||||
tower.challenge = BattleTower.NO_CHALLENGE
|
||||
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
|
||||
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
|
||||
U.wait(30)
|
||||
shot("room-entered")
|
||||
|
||||
-- The scene script walks the player in, draws the opponent and starts the
|
||||
-- battle; nothing here presses anything until a text box wants it.
|
||||
local sawOpponent, sawBattle = false, false
|
||||
for _ = 1, 900 do
|
||||
local vm = world.vm
|
||||
if vm and vm.btOpponent and not sawOpponent then
|
||||
sawOpponent = true
|
||||
say(("opponent: %s %s (row %d), sprite %s, group %d")
|
||||
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
|
||||
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
|
||||
vm.btOpponent.group))
|
||||
for slot, mon in ipairs(vm.btOpponent.rows) do
|
||||
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
|
||||
tostring(mon.item)))
|
||||
end
|
||||
U.wait(20)
|
||||
shot("opponent-walked-in")
|
||||
end
|
||||
if world.battleActive then
|
||||
sawBattle = true
|
||||
break
|
||||
end
|
||||
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
|
||||
end
|
||||
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
|
||||
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
|
||||
|
||||
local screen, battle = nil, nil
|
||||
if sawBattle then
|
||||
U.wait(90)
|
||||
shot("battle-open")
|
||||
screen = game.stack:top()
|
||||
battle = screen and screen.battle
|
||||
ok(battle ~= nil, "the battle screen owns a Battle")
|
||||
if battle then
|
||||
ok(battle.inBattleTowerBattle == true,
|
||||
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
|
||||
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
|
||||
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
|
||||
.. " L" .. tostring(battle.enemy and battle.enemy.level))
|
||||
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
|
||||
"with a three-mon Tower party")
|
||||
end
|
||||
end
|
||||
|
||||
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
|
||||
-- move with PP left.
|
||||
local function hardestMove(mon)
|
||||
local best, bestPower = 1, -1
|
||||
for index, move in ipairs((mon and mon.moves) or {}) do
|
||||
local def = game.data.moves and game.data.moves[move.id]
|
||||
local power = (def and def.power) or 0
|
||||
if (move.pp or 0) > 0 and power > bestPower then
|
||||
best, bestPower = index, power
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- PP topped up between turns: an unattended run otherwise Struggles itself
|
||||
-- to death long before the third opponent mon is down.
|
||||
local function refill(mon)
|
||||
for _, move in ipairs((mon and mon.moves) or {}) do
|
||||
move.pp = move.maxPp or move.pp
|
||||
end
|
||||
end
|
||||
|
||||
local menus = 0
|
||||
for _ = 1, 900 do
|
||||
if not world.battleActive or (battle and battle.over) then break end
|
||||
local phase = screen and screen.phase
|
||||
if phase == "menu" then
|
||||
menus = menus + 1
|
||||
if menus == 1 then shot("battle-menu") end
|
||||
refill(battle and battle.player)
|
||||
screen.menuIndex = 1
|
||||
tap("a", 4)
|
||||
elseif phase == "moves" then
|
||||
screen.menuIndex = hardestMove(battle and battle.player)
|
||||
tap("a", 4)
|
||||
elseif phase == "submenu" then
|
||||
-- A forced switch cannot be cancelled, so walk off the fainted lead.
|
||||
tap("down", 3)
|
||||
tap("a", 4)
|
||||
else
|
||||
tap("a", 3)
|
||||
end
|
||||
end
|
||||
if battle then
|
||||
say("battle over=" .. tostring(battle.over)
|
||||
.. " outcome=" .. tostring(battle.outcome)
|
||||
.. " phase=" .. tostring(screen and screen.phase))
|
||||
ok(battle.over, "the battle resolved")
|
||||
ok(battle.outcome == "win", "and the L60 party won it")
|
||||
shot("battle-end")
|
||||
end
|
||||
for _ = 1, 200 do
|
||||
if not world.battleActive then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
ok(not world.battleActive, "the battle screen came down")
|
||||
U.wait(30)
|
||||
shot("after-battle")
|
||||
|
||||
local vm = world.vm
|
||||
local state = BattleTower.state(game.save)
|
||||
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
|
||||
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
|
||||
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
|
||||
tostring(vm and vm.stringBuffer)))
|
||||
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
|
||||
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
|
||||
"and armed sBattleTowerChallengeState")
|
||||
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
|
||||
ok(state.prevTeams and #state.prevTeams.prev == 3,
|
||||
"and sBTMonPrevTrainer recorded the team")
|
||||
|
||||
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
|
||||
-- second draw all follow on their own.
|
||||
local pages, secondDraw = {}, nil
|
||||
for _ = 1, 400 do
|
||||
local body = world.lastText
|
||||
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
|
||||
pages[#pages + 1] = body
|
||||
if #pages <= 6 then shot("page-" .. #pages) end
|
||||
end
|
||||
if world.battleActive then
|
||||
secondDraw = world.vm and world.vm.btOpponent
|
||||
break
|
||||
end
|
||||
if world.choicebox then tap("a", 4) else tap("a", 3) end
|
||||
end
|
||||
say("pages: " .. table.concat(pages, " | "))
|
||||
local joined = table.concat(pages, " | ")
|
||||
ok(joined:find("healed to full health", 1, true) ~= nil
|
||||
or joined:find("Next up", 1, true) ~= nil,
|
||||
"the room script carried on past the battle")
|
||||
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
|
||||
or joined:find("no.2", 1, true) ~= nil,
|
||||
"and wStringBuffer3 named the SECOND opponent")
|
||||
|
||||
if world.battleActive then
|
||||
U.wait(60)
|
||||
shot("second-battle")
|
||||
ok(true, "the loop came back round for opponent two")
|
||||
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
|
||||
end
|
||||
|
||||
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
|
||||
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,182 @@
|
||||
-- The four Ruins of Alph secret chambers, opened in a real game.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=f3-crystal POKEPORT_GAME=crystal \
|
||||
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/f3 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_chambers.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:25 closed, :42 open;
|
||||
-- ../pokecrystal/engine/overworld/scripting.asm:2147 halves them to block 2,0.
|
||||
local WALL_BLOCK = 3
|
||||
local WALL_SHUT, WALL_OPEN = 0x2e, 0x30
|
||||
|
||||
local CHAMBERS = {
|
||||
{
|
||||
key = "OMANYTE", word = "WATER",
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:25 CheckItem, :38 MON_ITEM
|
||||
shut = function(game)
|
||||
game.save.inventory = { FIRE_STONE = 1 }
|
||||
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
|
||||
{ item = "FIRE_STONE" }) }
|
||||
end,
|
||||
arm = function(game)
|
||||
game.save.inventory = {}
|
||||
game.save.party = {
|
||||
Mon.new(game.data, "TYPHLOSION", 40),
|
||||
Mon.new(game.data, "LANTURN", 30, { item = "WATER_STONE" }),
|
||||
}
|
||||
end,
|
||||
armed = "a WATER STONE held by the last party mon",
|
||||
},
|
||||
{
|
||||
key = "HO_OH", word = "HO-OH",
|
||||
-- ../pokecrystal/engine/events/unown_walls.asm:2 wPartySpecies[0].
|
||||
shut = function(game)
|
||||
game.save.inventory = {}
|
||||
game.save.party = {
|
||||
Mon.new(game.data, "TYPHLOSION", 40),
|
||||
Mon.new(game.data, "HO_OH", 70),
|
||||
}
|
||||
end,
|
||||
arm = function(game)
|
||||
game.save.party = {
|
||||
Mon.new(game.data, "HO_OH", 70),
|
||||
Mon.new(game.data, "TYPHLOSION", 40),
|
||||
}
|
||||
end,
|
||||
armed = "HO-OH moved into the FIRST party slot",
|
||||
},
|
||||
{
|
||||
key = "KABUTO", word = "ESCAPE",
|
||||
shut = function(game)
|
||||
game.save.inventory = { ESCAPE_ROPE = 1 }
|
||||
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
|
||||
end,
|
||||
-- ../pokecrystal/engine/events/overworld.asm:809 farcall
|
||||
-- SpecialKabutoChamber, off EscapeRopeOrDig's rope arm.
|
||||
arm = function(_, world)
|
||||
UnownWords.kabutoChamber(world.events, world.map and world.map.id)
|
||||
end,
|
||||
armed = "SpecialKabutoChamber, as the escape rope calls it",
|
||||
},
|
||||
{
|
||||
key = "AERODACTYL", word = "LIGHT",
|
||||
shut = function(game)
|
||||
game.save.inventory = {}
|
||||
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
|
||||
end,
|
||||
-- ../pokecrystal/engine/events/overworld.asm:285 farcall
|
||||
-- SpecialAerodactylChamber, inside FlashFunction.CheckUseFlash.
|
||||
arm = function(_, world)
|
||||
UnownWords.aerodactylChamber(world.events, world.map and world.map.id)
|
||||
end,
|
||||
armed = "SpecialAerodactylChamber, as FLASH calls it",
|
||||
},
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-chambers"
|
||||
local fails = 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function wordScreen()
|
||||
local state = game.stack:top()
|
||||
return (state and getmetatable(state) == UnownWords) and state or nil
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
|
||||
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.name = game.save.player.name or "CHRIS"
|
||||
game.save.player.id = game.save.player.id or 30000
|
||||
|
||||
local function enter(mapId)
|
||||
world:setMap("RUINS_OF_ALPH_OUTSIDE", 8, 15, "down")
|
||||
U.wait(20)
|
||||
world.mapScenes[mapId] = 0
|
||||
world:setMap(mapId, 3, 1, "up")
|
||||
for _ = 1, 400 do
|
||||
U.wait(2)
|
||||
if not world:busy() and not world.pendingSceneScript then break end
|
||||
end
|
||||
U.wait(30)
|
||||
end
|
||||
|
||||
local function blockNow()
|
||||
local blocks = world.map and world.map.def and world.map.def.blocks
|
||||
return blocks and blocks[WALL_BLOCK]
|
||||
end
|
||||
|
||||
for index, chamber in ipairs(CHAMBERS) do
|
||||
local mapId = UnownWords.CHAMBER_MAPS[chamber.key]
|
||||
local flag = UnownWords.WALL_OPENED[chamber.key]
|
||||
say(("%d %s (flag %d)"):format(index, mapId, flag))
|
||||
world.events:set(flag, false)
|
||||
|
||||
chamber.shut(game, world)
|
||||
enter(mapId)
|
||||
ok(world.map and world.map.id == mapId, " stood in the chamber")
|
||||
ok(not world.events:get(flag), " the wall flag is still clear")
|
||||
ok(blockNow() == WALL_SHUT,
|
||||
(" and the hidden-doors callback drew the closed wall (%s)")
|
||||
:format(tostring(blockNow())))
|
||||
U.shot(game, ("%s/%d-%s-shut.png"):format(out, index,
|
||||
chamber.key:lower()))
|
||||
|
||||
chamber.arm(game, world)
|
||||
say(" armed: " .. chamber.armed)
|
||||
world.mapScenes[mapId] = 0
|
||||
enter(mapId)
|
||||
ok(world.events:get(flag), " the wall flag is set now")
|
||||
ok(blockNow() == WALL_OPEN,
|
||||
(" and the wall-open script rewrote the block (%s)")
|
||||
:format(tostring(blockNow())))
|
||||
U.shot(game, ("%s/%d-%s-open.png"):format(out, index,
|
||||
chamber.key:lower()))
|
||||
|
||||
-- ../pokecrystal/maps/RuinsOfAlphOmanyteChamber.asm:82
|
||||
-- RuinsOfAlphOmanyteChamberWallPatternLeft
|
||||
local screen
|
||||
for _ = 1, 200 do
|
||||
screen = wordScreen()
|
||||
if screen then break end
|
||||
tap("a", 2)
|
||||
end
|
||||
ok(screen ~= nil, " the wall pattern reached DisplayUnownWords")
|
||||
if screen then
|
||||
U.wait(20)
|
||||
ok(screen.wall and screen.wall.word == chamber.word,
|
||||
(" showing %s (got %s)"):format(chamber.word,
|
||||
tostring(screen.wall and screen.wall.word)))
|
||||
U.shot(game, ("%s/%d-%s-word.png"):format(out, index,
|
||||
chamber.key:lower()))
|
||||
tap("a", 10)
|
||||
U.wait(20)
|
||||
end
|
||||
end
|
||||
|
||||
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
|
||||
U.wait(10)
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
-- The Ruins of Alph wall words and word rooms, driven in a real game.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=unit-e-unown POKEPORT_GAME=crystal \
|
||||
-- POKEPORT_VERSION=crystal POKEPORT_SHOT_DIR=/tmp/unit-e \
|
||||
-- POKEPORT_DRIVER=tests/drivers/crystal_unown_words_shots.lua love .
|
||||
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
-- maps/RuinsOfAlphKabutoChamber.asm:272 `bg_event 4, 0, BGEVENT_UP`
|
||||
local CHAMBERS = {
|
||||
{ map = "RUINS_OF_ALPH_KABUTO_CHAMBER", word = "ESCAPE" },
|
||||
{ map = "RUINS_OF_ALPH_AERODACTYL_CHAMBER", word = "LIGHT" },
|
||||
{ map = "RUINS_OF_ALPH_OMANYTE_CHAMBER", word = "WATER" },
|
||||
{ map = "RUINS_OF_ALPH_HO_OH_CHAMBER", word = "HO-OH" },
|
||||
}
|
||||
|
||||
local WORD_ROOMS = {
|
||||
{ map = "RUINS_OF_ALPH_KABUTO_WORD_ROOM", x = 9, y = 6 },
|
||||
{ map = "RUINS_OF_ALPH_AERODACTYL_WORD_ROOM", x = 9, y = 6 },
|
||||
{ map = "RUINS_OF_ALPH_OMANYTE_WORD_ROOM", x = 9, y = 6 },
|
||||
{ map = "RUINS_OF_ALPH_HO_OH_WORD_ROOM", x = 9, y = 6 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-unown-words"
|
||||
local fails = 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
local function wordScreen()
|
||||
local state = top()
|
||||
return (state and getmetatable(state) == UnownWords) and state or nil
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
say("version=" .. GameVersion.get() .. " engine=" .. GameVersion.engine())
|
||||
|
||||
local save = game.save
|
||||
save.player = save.player or {}
|
||||
save.player.name = save.player.name or "CHRIS"
|
||||
save.player.id = save.player.id or 30000
|
||||
save.party = { Mon.new(game.data, "TYPHLOSION", 40) }
|
||||
|
||||
for index, chamber in ipairs(CHAMBERS) do
|
||||
say("A" .. index .. " " .. chamber.map)
|
||||
world:setMap(chamber.map, 4, 1, "up")
|
||||
U.wait(30)
|
||||
U.shot(game, ("%s/a%d-%s-chamber.png"):format(
|
||||
out, index, chamber.word:lower()))
|
||||
local screen
|
||||
for _ = 1, 300 do
|
||||
screen = wordScreen()
|
||||
if screen then break end
|
||||
if not world:busy() then tap("a", 2) else tap("a", 2) end
|
||||
end
|
||||
ok(screen ~= nil, chamber.map .. " reached DisplayUnownWords")
|
||||
if screen then
|
||||
U.wait(20)
|
||||
ok(screen.wall and screen.wall.word == chamber.word,
|
||||
(" showing %s (got %s)"):format(chamber.word,
|
||||
tostring(screen.wall and screen.wall.word)))
|
||||
ok(#screen.squares == #chamber.word,
|
||||
(" %d letter squares"):format(#screen.squares))
|
||||
U.shot(game, ("%s/a%d-%s-word.png"):format(
|
||||
out, index, chamber.word:lower()))
|
||||
tap("a", 10)
|
||||
ok(wordScreen() == nil, " A closes the box")
|
||||
U.wait(20)
|
||||
end
|
||||
end
|
||||
|
||||
for index, room in ipairs(WORD_ROOMS) do
|
||||
say("B" .. index .. " " .. room.map)
|
||||
world:setMap(room.map, room.x, room.y, "up")
|
||||
U.wait(40)
|
||||
ok(world.map and world.map.id == room.map, " stood in " .. room.map)
|
||||
U.shot(game, ("%s/b%d-%s.png"):format(out, index,
|
||||
room.map:lower():gsub("ruins_of_alph_", "")))
|
||||
end
|
||||
|
||||
say(fails == 0 and "ALL OK" or (fails .. " FAILURES"))
|
||||
U.wait(10)
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
-- The two chamber walls the FIELD MOVES open, driven through the production
|
||||
-- call sites rather than by calling the routines directly.
|
||||
--
|
||||
-- ../pokecrystal/engine/events/overworld.asm:280-291 FlashFunction.CheckUseFlash
|
||||
-- (badge FIRST, then SpecialAerodactylChamber) and :808-813 EscapeRopeOrDig's
|
||||
-- `.escaperope` arm.
|
||||
--
|
||||
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gate_chamber_fieldmoves.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local UnownWords = require("src.world.gen2.UnownWords")
|
||||
|
||||
return function(game)
|
||||
local fails = 0
|
||||
local function say(line) print("[driver] " .. line); io.stdout:flush() end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
local function clearText()
|
||||
for _ = 1, 200 do
|
||||
if not world:busy() then return true end
|
||||
tap("a", 3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function enter(mapId)
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
assert(world:setMap(mapId, 3, 7, "up"), "no " .. mapId .. " in this cache")
|
||||
for _ = 1, 240 do
|
||||
if not world:busy() then break end
|
||||
U.wait(2)
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
-- ---- the escape rope, in the Kabuto chamber ------------------------------
|
||||
say("--- KABUTO, through World:useEscapeRope()")
|
||||
enter(UnownWords.CHAMBER_MAPS.KABUTO)
|
||||
ok(not UnownWords.wallOpened(world.events, "KABUTO"),
|
||||
"the Kabuto wall flag starts clear")
|
||||
game.save.inventory = { ESCAPE_ROPE = 1 }
|
||||
-- wDigWarpNumber / wBackupMapGroup: the cave's own entrance, which
|
||||
-- ../pokecrystal/engine/events/overworld.asm:795-798 copies into wNextWarp.
|
||||
world.backupWarp = { map = "RUINS_OF_ALPH_OUTSIDE", warp = 1 }
|
||||
clearText()
|
||||
local rope = world:useEscapeRope("ESCAPE_ROPE")
|
||||
say("escape rope result: " .. tostring(rope))
|
||||
ok(rope == "escape_rope", "the rope was used")
|
||||
ok(UnownWords.wallOpened(world.events, "KABUTO"),
|
||||
"and SpecialKabutoChamber set the wall flag")
|
||||
|
||||
-- The rope must not open a wall anywhere else.
|
||||
world.queuedFieldMove = nil
|
||||
enter("RUINS_OF_ALPH_OUTSIDE")
|
||||
clearText()
|
||||
local before = UnownWords.wallOpened(world.events, "OMANYTE")
|
||||
game.save.inventory = { ESCAPE_ROPE = 1 }
|
||||
world:useEscapeRope("ESCAPE_ROPE")
|
||||
ok(UnownWords.wallOpened(world.events, "OMANYTE") == before,
|
||||
"a rope used outside a chamber opens nothing")
|
||||
|
||||
-- ---- FLASH, in the Aerodactyl chamber ------------------------------------
|
||||
say("--- AERODACTYL, through World:useFieldMove(\"FLASH\")")
|
||||
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
|
||||
ok(world.map.id == UnownWords.CHAMBER_MAPS.AERODACTYL, "in the chamber")
|
||||
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
|
||||
"the wall flag starts clear")
|
||||
|
||||
local flash = game.data.moves.FLASH
|
||||
game.save.party = { Mon.new(game.data, "TYPHLOSION", 40,
|
||||
{ moves = { { id = "FLASH", pp = flash.pp, maxPp = flash.pp } } }) }
|
||||
|
||||
-- :281-283, the ZEPHYRBADGE gate that runs BEFORE the special.
|
||||
game.save.player = game.save.player or {}
|
||||
game.save.player.badges = {}
|
||||
local refused = world:useFieldMove("FLASH", game.save.party[1])
|
||||
ok(refused and refused.ok ~= true, "no ZEPHYRBADGE: FLASH is refused")
|
||||
ok(refused and refused.badge == "ZEPHYR", "on the badge, not on the map")
|
||||
ok(not UnownWords.wallOpened(world.events, "AERODACTYL"),
|
||||
"and the badgeless press did NOT open the wall")
|
||||
|
||||
-- The refusal opened a text box; the world is busy until it is dismissed.
|
||||
clearText()
|
||||
game.save.player.badges = { ZEPHYR = true }
|
||||
local used = world:useFieldMove("FLASH", game.save.party[1])
|
||||
say("flash result: ok=" .. tostring(used and used.ok)
|
||||
.. " action=" .. tostring(used and used.action))
|
||||
ok(used and used.ok == true,
|
||||
"with the badge FLASH is allowed in a chamber that is not a dark cave")
|
||||
ok(UnownWords.wallOpened(world.events, "AERODACTYL"),
|
||||
"and SpecialAerodactylChamber set the wall flag")
|
||||
|
||||
enter(UnownWords.CHAMBER_MAPS.AERODACTYL)
|
||||
U.wait(40)
|
||||
local _, block = world:blockIndexAt(3, 5)
|
||||
say("aerodactyl block under the wall: " .. tostring(block))
|
||||
U.shot(game, (os.getenv("POKEPORT_SHOT_DIR") or "/tmp")
|
||||
.. "/01-aerodactyl-flash-open.png")
|
||||
|
||||
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
-- Gate check: the Crystal wave left Gold alone. No Battle Tower, no Buena's
|
||||
-- Blue Card, no Ruins of Alph secret chambers, and the three beasts roam.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_VERSION=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gate_gold_untouched.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Roamers = require("src.core.gen2.Roamers")
|
||||
|
||||
return function(game)
|
||||
local fails = 0
|
||||
local function say(line) print("[driver] " .. line); io.stdout:flush() end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
say("version=" .. tostring(game.version) .. " map=" .. tostring(world.map.id))
|
||||
|
||||
local order = {}
|
||||
for _, name in ipairs((world.constants or {}).specialOrder or {}) do
|
||||
order[name] = true
|
||||
end
|
||||
ok(next(order) ~= nil, "the Gold cache names its specials")
|
||||
for _, name in ipairs({ "BattleTowerBattle", "BattleTowerAction",
|
||||
"BattleTowerRoomMenu", "LoadOpponentTrainerAndPokemonWithOTSprite",
|
||||
"BuenasPassword", "BuenaPrize", "AskRememberPassword",
|
||||
"OmanyteChamber", "HoOhChamber", "CelebiShrineEvent",
|
||||
"SampleKenjiBreakCountdown", "MoveTutor", "PokeSeer" }) do
|
||||
ok(not order[name], "Gold has no " .. name .. " special")
|
||||
end
|
||||
|
||||
ok(world.maps.BATTLE_TOWER_1F == nil, "no BATTLE_TOWER_1F map")
|
||||
ok(world.maps.BATTLE_TOWER_BATTLE_ROOM == nil, "no tower battle room")
|
||||
ok((world.trainers or {}).battleTower == nil,
|
||||
"trainers.lua carries no battleTower roster")
|
||||
ok((world.eventTables or {}).unownWalls == nil,
|
||||
"events.lua carries no unownWalls table")
|
||||
|
||||
-- ../pokecrystal/constants/event_flags.asm:486-489 are Crystal-only.
|
||||
for _, flag in ipairs({ 806, 807, 808, 809 }) do
|
||||
ok(not world.events:get(flag), "wall-opened flag " .. flag .. " is clear")
|
||||
end
|
||||
|
||||
-- ../pokecrystal/constants/script_constants.asm:69-74, Crystal-only VARs.
|
||||
for _, id in ipairs({ 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a }) do
|
||||
ok(world:readVar(id) == 0,
|
||||
("VAR $%02x reads 0 on Gold"):format(id))
|
||||
end
|
||||
|
||||
-- data/wild/roammon_maps.asm: RAIKOU, ENTEI and SUICUNE, all three.
|
||||
local roster = Roamers.roster(world.encounters)
|
||||
say("roamer roster: " .. #roster .. " rows")
|
||||
for _, row in ipairs(roster) do
|
||||
say((" %s L%s start=%s"):format(tostring(row.species),
|
||||
tostring(row.level), tostring(row.map)))
|
||||
end
|
||||
ok(#roster == 3, "three beasts in the roster")
|
||||
local list = Roamers.init(game.save, { encounters = world.encounters, force = true })
|
||||
local live = 0
|
||||
for _, slot in ipairs(list or {}) do
|
||||
if Roamers.active(slot) then live = live + 1 end
|
||||
end
|
||||
ok(live == 3, "all three are active after InitRoamMons")
|
||||
|
||||
-- pokegold/engine/battle/core.asm:3476-3479: TRAP and FORCESHINY only.
|
||||
ok(Battle.noEscapeBattleType({ battleType = 9 }) == true, "TRAP: no escape")
|
||||
ok(Battle.noEscapeBattleType({ battleType = 7 }) == true,
|
||||
"FORCESHINY: no escape")
|
||||
ok(Battle.noEscapeBattleType({ battleType = 10 }) == false,
|
||||
"FORCEITEM: Lugia and Ho-Oh can still be run from on Gold")
|
||||
ok(Battle.noEscapeBattleType({ battleType = 8 }) == false, "TREE: escapable")
|
||||
|
||||
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,236 @@
|
||||
-- maps/BattleTowerBattleRoom.asm's own loop, driven in a real game: the
|
||||
-- opponent draw, the walk-in, the battle, and the counter the room script
|
||||
-- reads back.
|
||||
--
|
||||
-- Nothing is shimmed: the startTowerBattle / setObjectSprite hooks and the
|
||||
-- battleTower field all come from World itself.
|
||||
--
|
||||
-- POKEPORT_GAME=crystal POKEPORT_VERSION=crystal \
|
||||
-- POKEPORT_SHOT_DIR=/tmp/tower-battle \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gate_tower_noshim.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local BattleTower = require("src.core.gen2.BattleTower")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- ---- the run --------------------------------------------------------------
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/crystal-tower-battle"
|
||||
local fails, shots = 0, 0
|
||||
|
||||
local function say(line) print("[driver] " .. line) end
|
||||
local function ok(cond, line)
|
||||
if not cond then fails = fails + 1 end
|
||||
say((cond and "OK " or "FAIL ") .. line)
|
||||
end
|
||||
local function shot(name)
|
||||
shots = shots + 1
|
||||
U.shot(game, ("%s/%02d-%s.png"):format(out, shots, name))
|
||||
end
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
U.wait(60)
|
||||
local world = game.world
|
||||
assert(world and world.map, "crystal world did not boot")
|
||||
|
||||
ok(type(world.vm.specials.startTowerBattle) == "function",
|
||||
"World:specialHooks supplies startTowerBattle, unshimmed")
|
||||
ok(type(world.vm.specials.setObjectSprite) == "function",
|
||||
"World:specialHooks supplies setObjectSprite, unshimmed")
|
||||
|
||||
local roster = BattleTower.roster(game.data)
|
||||
ok(roster ~= nil, "trainers.lua carries the Battle Tower roster")
|
||||
if roster then
|
||||
say(("roster: %d trainers, %d groups of %d, sample ceiling %d")
|
||||
:format(#roster.trainers, roster.levelGroups, roster.uniqueMon,
|
||||
roster.sampleTrainers))
|
||||
end
|
||||
|
||||
-- Three legal mons over the L10 room, each with ONE strong move so a blind
|
||||
-- A-press run cannot pick a status move for a hundred turns.
|
||||
local party = {}
|
||||
for _, row in ipairs({ { "TYPHLOSION", 60, "FLAMETHROWER" },
|
||||
{ "FERALIGATR", 60, "SURF", "BERRY" },
|
||||
{ "MEGANIUM", 60, "BODY_SLAM", "GOLD_BERRY" } }) do
|
||||
local def = game.data.moves[row[3]]
|
||||
local mon = Mon.new(game.data, row[1], row[2], {
|
||||
moves = { { id = row[3], pp = def.pp, maxPp = def.pp } },
|
||||
})
|
||||
mon.item = row[4]
|
||||
party[#party + 1] = mon
|
||||
end
|
||||
game.save.party = party
|
||||
|
||||
-- The desk's own SAVELEVELGROUP write, so the room the script walks into is
|
||||
-- the L10 one (battle_tower.asm:1129-1141).
|
||||
local tower = BattleTower.state(game.save)
|
||||
tower.levelGroup = 1
|
||||
tower.streak = 0
|
||||
tower.trainers = {}
|
||||
tower.challenge = BattleTower.NO_CHALLENGE
|
||||
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
assert(world:setMap("BATTLE_TOWER_BATTLE_ROOM", 3, 7, "up"),
|
||||
"no BATTLE_TOWER_BATTLE_ROOM in the cache")
|
||||
U.wait(30)
|
||||
shot("room-entered")
|
||||
|
||||
-- The scene script walks the player in, draws the opponent and starts the
|
||||
-- battle; nothing here presses anything until a text box wants it.
|
||||
local sawOpponent, sawBattle = false, false
|
||||
for _ = 1, 900 do
|
||||
local vm = world.vm
|
||||
if vm and vm.btOpponent and not sawOpponent then
|
||||
sawOpponent = true
|
||||
say(("opponent: %s %s (row %d), sprite %s, group %d")
|
||||
:format(tostring(vm.btOpponent.classId), tostring(vm.btOpponent.name),
|
||||
vm.btOpponent.index, tostring(vm.btOpponent.sprite),
|
||||
vm.btOpponent.group))
|
||||
for slot, mon in ipairs(vm.btOpponent.rows) do
|
||||
say((" mon %d: %s L%d %s"):format(slot, mon.species, mon.level,
|
||||
tostring(mon.item)))
|
||||
end
|
||||
U.wait(20)
|
||||
shot("opponent-walked-in")
|
||||
end
|
||||
if world.battleActive then
|
||||
sawBattle = true
|
||||
break
|
||||
end
|
||||
if world.textbox or world.choicebox then tap("a", 4) else U.wait(2) end
|
||||
end
|
||||
ok(sawOpponent, "special LoadOpponentTrainerAndPokemonWithOTSprite drew one")
|
||||
ok(sawBattle, "special BattleTowerBattle pushed the battle screen")
|
||||
|
||||
local screen, battle = nil, nil
|
||||
if sawBattle then
|
||||
U.wait(90)
|
||||
shot("battle-open")
|
||||
screen = game.stack:top()
|
||||
battle = screen and screen.battle
|
||||
ok(battle ~= nil, "the battle screen owns a Battle")
|
||||
if battle then
|
||||
ok(battle.inBattleTowerBattle == true,
|
||||
"wInBattleTowerBattle is set, so DoBadgeTypeBoosts is off")
|
||||
say("enemy trainer: " .. tostring(battle.trainer and battle.trainer.name))
|
||||
say("enemy lead: " .. tostring(battle.enemy and battle.enemy.species)
|
||||
.. " L" .. tostring(battle.enemy and battle.enemy.level))
|
||||
ok(battle.trainer ~= nil and #battle.trainer.party == 3,
|
||||
"with a three-mon Tower party")
|
||||
end
|
||||
end
|
||||
|
||||
-- One press per look at the phase: FIGHT off the 2x2 menu, then the hardest
|
||||
-- move with PP left.
|
||||
local function hardestMove(mon)
|
||||
local best, bestPower = 1, -1
|
||||
for index, move in ipairs((mon and mon.moves) or {}) do
|
||||
local def = game.data.moves and game.data.moves[move.id]
|
||||
local power = (def and def.power) or 0
|
||||
if (move.pp or 0) > 0 and power > bestPower then
|
||||
best, bestPower = index, power
|
||||
end
|
||||
end
|
||||
return best
|
||||
end
|
||||
|
||||
-- PP topped up between turns: an unattended run otherwise Struggles itself
|
||||
-- to death long before the third opponent mon is down.
|
||||
local function refill(mon)
|
||||
for _, move in ipairs((mon and mon.moves) or {}) do
|
||||
move.pp = move.maxPp or move.pp
|
||||
end
|
||||
end
|
||||
|
||||
local menus = 0
|
||||
for _ = 1, 900 do
|
||||
if not world.battleActive or (battle and battle.over) then break end
|
||||
local phase = screen and screen.phase
|
||||
if phase == "menu" then
|
||||
menus = menus + 1
|
||||
if menus == 1 then shot("battle-menu") end
|
||||
refill(battle and battle.player)
|
||||
screen.menuIndex = 1
|
||||
tap("a", 4)
|
||||
elseif phase == "moves" then
|
||||
screen.menuIndex = hardestMove(battle and battle.player)
|
||||
tap("a", 4)
|
||||
elseif phase == "submenu" then
|
||||
-- A forced switch cannot be cancelled, so walk off the fainted lead.
|
||||
tap("down", 3)
|
||||
tap("a", 4)
|
||||
else
|
||||
tap("a", 3)
|
||||
end
|
||||
end
|
||||
if battle then
|
||||
say("battle over=" .. tostring(battle.over)
|
||||
.. " outcome=" .. tostring(battle.outcome)
|
||||
.. " phase=" .. tostring(screen and screen.phase))
|
||||
ok(battle.over, "the battle resolved")
|
||||
ok(battle.outcome == "win", "and the L60 party won it")
|
||||
shot("battle-end")
|
||||
end
|
||||
for _ = 1, 200 do
|
||||
if not world.battleActive then break end
|
||||
tap("a", 3)
|
||||
end
|
||||
ok(not world.battleActive, "the battle screen came down")
|
||||
U.wait(30)
|
||||
shot("after-battle")
|
||||
|
||||
local vm = world.vm
|
||||
local state = BattleTower.state(game.save)
|
||||
say(("streak=%d challenge=%d scriptVar=%s wNrOfBeaten=%s strbuf=%q")
|
||||
:format(state.streak, state.challenge, tostring(vm and vm.scriptVar),
|
||||
tostring(vm and vm.mem and vm.mem[BattleTower.WRAM_NR_BEATEN]),
|
||||
tostring(vm and vm.stringBuffer)))
|
||||
ok(state.streak >= 1, "ReadBTTrainerParty stepped the streak counter")
|
||||
ok(state.challenge == BattleTower.CHALLENGE_IN_PROGRESS,
|
||||
"and armed sBattleTowerChallengeState")
|
||||
ok(#(state.trainers or {}) >= 1, "sBTTrainers recorded who was fought")
|
||||
ok(state.prevTeams and #state.prevTeams.prev == 3,
|
||||
"and sBTMonPrevTrainer recorded the team")
|
||||
|
||||
-- The receptionist's heal, the "next up, opponent no. N" prompt and the
|
||||
-- second draw all follow on their own.
|
||||
local pages, secondDraw = {}, nil
|
||||
for _ = 1, 400 do
|
||||
local body = world.lastText
|
||||
if type(body) == "string" and body ~= "" and pages[#pages] ~= body then
|
||||
pages[#pages + 1] = body
|
||||
if #pages <= 6 then shot("page-" .. #pages) end
|
||||
end
|
||||
if world.battleActive then
|
||||
secondDraw = world.vm and world.vm.btOpponent
|
||||
break
|
||||
end
|
||||
if world.choicebox then tap("a", 4) else tap("a", 3) end
|
||||
end
|
||||
say("pages: " .. table.concat(pages, " | "))
|
||||
local joined = table.concat(pages, " | ")
|
||||
ok(joined:find("healed to full health", 1, true) ~= nil
|
||||
or joined:find("Next up", 1, true) ~= nil,
|
||||
"the room script carried on past the battle")
|
||||
ok(joined:find("Next up, opponent\nno.2", 1, true) ~= nil
|
||||
or joined:find("no.2", 1, true) ~= nil,
|
||||
"and wStringBuffer3 named the SECOND opponent")
|
||||
|
||||
if world.battleActive then
|
||||
U.wait(60)
|
||||
shot("second-battle")
|
||||
ok(true, "the loop came back round for opponent two")
|
||||
say("second opponent: " .. tostring(secondDraw and secondDraw.name))
|
||||
end
|
||||
|
||||
say(("streak after round two: %d"):format(BattleTower.state(game.save).streak))
|
||||
say(fails == 0 and "PASS" or (fails .. " FAILURES"))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
Reference in New Issue
Block a user