CLOSES #806, CLOSES #809, CLOSES #853, CLOSES #854, CLOSES #860, CLOSES #862, CLOSES #865, CLOSES #866

This commit is contained in:
bryanthaboi
2026-08-05 14:38:10 -04:00
parent 104c95a942
commit 863f371e68
25 changed files with 1746 additions and 74 deletions
@@ -0,0 +1,222 @@
-- A trainer's walk-up must stop short of a Strength boulder, and the boulder
-- must still be pushable afterwards (#809). TrainerWalkUpToPlayer (pokered
-- engine/overworld/trainer_sight.asm) writes dist-1 movement bytes that skip
-- collision, so the trainer used to park ON the boulder, and after that
-- IsSpriteInFrontOfPlayer (home/overworld.asm) handed TryPushingBoulder the
-- trainer instead of the rock. POKEPORT_DRIVER=tests/drivers/boulder_trainer_bug809_test.lua POKEPORT_IDENTITY=bug809 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
-- No POKEPORT_SPEED: the sighting, the "!" bubble and the walk-up all run at
-- the normal 60 Hz logic clock so the stop-short frame is the one a player
-- would see. The setup pushes are slow for the same reason; the run takes
-- about half a minute of real time before it hands the pad over.
return function(game)
local U = dofile("tests/drivers/util.lua")
-- pokered data/maps/objects/VictoryRoad3F.asm:
-- object_event 13, 3, SPRITE_COOLTRAINER_F, STAY, RIGHT, ..., OPP_COOLTRAINER_F, 3
-- object_event 22, 3, SPRITE_BOULDER, STAY, BOULDER_MOVEMENT_BYTE_2, ...
-- Her header range is 4 (data/generated/trainer_headers.lua VictoryRoad3F[4]),
-- so she spots the player anywhere on row 3 within four cells to her east and
-- then walks dist-1 cells toward him. Row 3 is walled at x=19, so BOULDER1
-- cannot simply be shoved west into her sight line: it has to go down column
-- 22 to row 6, west along row 6, and back up column 17 onto row 3.
local MAP = "VICTORY_ROAD_3F"
local MAP_LABEL = "VictoryRoad3F"
local BOULDER = "VICTORYROAD3F_BOULDER1"
local TRAINER = "VICTORYROAD3F_COOLTRAINER_F2"
local START = { x = 22, y = 2, facing = "down" }
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local pass = true
local function check(label, ok)
if not ok then pass = false end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function findNpc(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
-- Hold `btn` until `cond` goes true or the budget runs out, then release and
-- let any half-finished step land. Boulder pushes need a held direction:
-- handleInput only reaches checkBoulderPush while the player already faces
-- that way, and TryPushingBoulder arms on one poll and moves on the next
-- (BIT_TRIED_PUSH_BOULDER), so a single tap can never shift a rock.
local function holdUntil(btn, cond, budget)
local first = true
for _ = 1, budget or 600 do
if cond() then break end
if first then table.insert(game.input.pressQueue, btn); first = false end
game.input.state[btn] = true
coroutine.yield()
end
game.input.state[btn] = false
for _ = 1, 40 do
if not game.overworld.player.moving and #game.overworld.scriptMoves == 0 then
break
end
coroutine.yield()
end
U.wait(4) -- an input-free poll re-arms turning in place (wCheckFor180DegreeTurn)
return cond()
end
U.teleport(game, MAP, START.x, START.y, START.facing)
U.wait(10)
local ow = game.overworld
local rock = findNpc(ow, BOULDER)
local trainer = findNpc(ow, TRAINER)
check("BOULDER1 loaded on " .. MAP, rock ~= nil)
check("COOLTRAINER_F2 loaded on " .. MAP, trainer ~= nil)
if not (rock and trainer) then
U.log("map objects missing; nothing to drive")
while true do coroutine.yield() end
end
check("BOULDER1 starts at the asm cell (22,3)",
rock.cellX == 22 and rock.cellY == 3)
check("COOLTRAINER_F2 starts at the asm cell (13,3) facing right",
trainer.cellX == 13 and trainer.cellY == 3 and trainer.facing == "right")
check("checkBoulderPush resolves through pushableAtCell",
type(ow.pushableAtCell) == "function")
local header = game.data:trainerHeader(MAP_LABEL, trainer.def.index)
local range = header and header.range or 0
check("her sight range is 4 cells", range == 4)
-- The whole route, so a map or tileset edit shows up here instead of as a
-- driver that quietly wanders off. If a cell is not walkable the boulder
-- cannot be pushed onto it (CheckForCollisionWhenPushingBoulder reuses the
-- player's passability check) and the run is not worth continuing.
local ROUTE = {
{ 22, 4 }, { 22, 5 }, { 22, 6 }, { 23, 5 }, { 23, 6 },
{ 21, 6 }, { 20, 6 }, { 19, 6 }, { 18, 6 }, { 17, 6 },
{ 18, 7 }, { 17, 7 }, { 17, 5 }, { 17, 4 }, { 17, 3 },
{ 18, 4 }, { 18, 3 }, { 16, 3 }, { 16, 4 }, { 16, 2 },
}
local routeOk = true
for _, c in ipairs(ROUTE) do
if not ow.map:isWalkableCell(c[1], c[2]) then
routeOk = false
U.log("route cell not walkable:", c[1], c[2])
end
end
check("the push route is walkable end to end", routeOk)
-- STRENGTH is live for the map visit. BIT_STRENGTH_ACTIVE is what
-- TryPushingBoulder gates on -- it never re-reads badges or party moves --
-- so setting the field-move state is the whole grant (see the comment in
-- OverworldState:checkBoulderPush).
ow.strengthActive = true
-- Victory Road rolls a wild encounter on every completed step, not just in
-- grass (wild_encounters.asm counts caves as indoor), and this run walks
-- twenty-odd cells with an empty party. Drop the map's table: a wild
-- battle mid-route interrupts the push with a screen transition and has
-- nothing to do with what is being checked.
game.data.encounters[MAP] = nil
if not pass then
U.log("setup checks already failed; not driving the push")
while true do coroutine.yield() end
end
local function boulderAt(x, y)
return function() return rock.cellX == x and rock.cellY == y end
end
local function playerAt(x, y)
local p = ow.player
return function() return p.cellX == x and p.cellY == y end
end
-- down column 22 to row 6
holdUntil("down", boulderAt(22, 6), 400)
check("boulder pushed down column 22 to (22,6)", rock.cellX == 22 and rock.cellY == 6)
-- around to its east side
holdUntil("right", playerAt(23, 5), 120)
holdUntil("down", playerAt(23, 6), 120)
-- west along row 6 to the column that reaches row 3
holdUntil("left", boulderAt(17, 6), 700)
check("boulder pushed west along row 6 to (17,6)", rock.cellX == 17 and rock.cellY == 6)
-- around to its south side
holdUntil("down", playerAt(18, 7), 120)
holdUntil("left", playerAt(17, 7), 120)
-- up column 17 onto her row
holdUntil("up", boulderAt(17, 3), 400)
check("boulder pushed up column 17 onto row 3 at (17,3)",
rock.cellX == 17 and rock.cellY == 3)
if not pass then
U.log("the boulder never reached her row; the race below cannot happen")
while true do coroutine.yield() end
end
-- Step onto row 3 one cell out of range (18 - 13 = 5 > 4) so the sighting
-- happens on the push itself and not a moment earlier.
holdUntil("right", playerAt(18, 4), 120)
holdUntil("up", playerAt(18, 3), 120)
check("player waiting at (18,3), one cell outside her range",
ow.player.cellX == 18 and ow.player.cellY == 3 and not ow.engaging)
-- The engage lands on a battle we are not going to fight: stand in for it,
-- record where the walk-up stopped, and mark her beaten the way winning
-- would. Everything the walk-up does has already happened by this point.
local stopped
local realEngage = ow.engageTrainer
ow.engageTrainer = function(self, npc, onDone)
stopped = { npc = npc, x = npc.cellX, y = npc.cellY }
game.save.defeatedTrainers[npc.id] = true
if onDone then onDone() end
end
-- One push west: the boulder lands on (16,3) and the player follows onto
-- (17,3), four cells from her, which is the frame she spots him on.
holdUntil("left", function() return stopped ~= nil end, 400)
check("she spotted the player and finished her walk-up", stopped ~= nil)
check("the boulder moved one cell west to (16,3)",
rock.cellX == 16 and rock.cellY == 3)
if stopped then
U.log("she stopped at", stopped.x, stopped.y, "boulder at", rock.cellX, rock.cellY)
check("she is not standing on the boulder cell",
not (stopped.x == rock.cellX and stopped.y == rock.cellY))
check("she stopped one cell short of it, at (15,3)",
stopped.x == 15 and stopped.y == 3)
check("the push path still finds the boulder under that cell",
ow:pushableAtCell(rock.cellX, rock.cellY) == rock)
check("nothing else shares the boulder's cell",
ow:npcAtCell(rock.cellX, rock.cellY) == rock)
end
U.shot(game, SHOT_DIR .. "/bug809_walkup_stop.png")
-- ...and the rock still moves. Push it north, the one free direction left:
-- west is her, east is the player, south is where he came from.
holdUntil("down", playerAt(17, 4), 120)
holdUntil("left", playerAt(16, 4), 120)
holdUntil("up", boulderAt(16, 2), 400)
if not check("the boulder is still pushable after the engage",
rock.cellX == 16 and rock.cellY == 2) then
U.log("boulder ended at", rock.cellX, rock.cellY, "player at",
ow.player.cellX, ow.player.cellY)
end
holdUntil("down", playerAt(16, 4), 120)
U.shot(game, SHOT_DIR .. "/bug809_still_pushable.png")
ow.engageTrainer = realEngage
U.log(pass and "ALL CHECKS PASSED" or "SOME CHECKS FAILED")
U.log("On screen: the COOLTRAINER stands at (15,3) with a one-cell gap")
U.log("between her and the rock, which now sits at (16,2), one row up from")
U.log("where she stopped. The near miss to watch for is her sprite ending")
U.log("the walk-up on top of the rock, or standing clear of it but leaving")
U.log("it inert: walk into the rock from any side and it should still shift")
U.log("a cell. Her battle was stubbed out and she is flagged as beaten;")
U.log("re-run the driver to watch the race again from the start.")
while true do
coroutine.yield()
end
end
+209
View File
@@ -0,0 +1,209 @@
-- Driver: Fighting Dojo prize balls, #853 (dex page first) and #854 (the
-- question stays on screen under YES/NO). pokered scripts/FightingDojo.asm
-- runs `ld a, HITMONLEE / call DisplayPokedex` before .Text, and .Text is a
-- text_end string printed with PrintText immediately followed by YesNoChoice.
-- No POKEPORT_SPEED here: the dex page and the YES/NO pop are what is judged.
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/dojo_balls_bug853_test.lua POKEPORT_IDENTITY=bug853 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local DexEntryMenu = require("src.ui.DexEntryMenu")
local MapScripts = require("src.script.MapScripts")
local Screens = require("src.ui.Screens")
local OW = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
-- pokered data/maps/objects/FightingDojo.asm: the two SPRITE_POKE_BALL
-- objects sit at (4, 1) HITMONLEE and (5, 1) HITMONCHAN, on the north wall
-- under the posters. The only approach is from the mat below them.
local MAP = "FIGHTING_DOJO"
local LEE = { name = "FIGHTINGDOJO_HITMONLEE_POKE_BALL", x = 4, y = 1 }
local CHAN = { name = "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", x = 5, y = 1 }
local START = { x = 4, y = 4 } -- walk up from here to (4, 2), facing LEE
local failures = {}
local function check(cond, msg)
if cond then U.log("PASS", msg) else
failures[#failures + 1] = msg
U.log("FAIL", msg)
end
return cond
end
local function topIs(mt) return getmetatable(game.stack:top()) == mt end
local function under()
return game.stack.states[#game.stack.states - 1]
end
local function npcByName(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
end
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local page = top.pages and top.pages[top.pageIndex]
return page and table.concat(page, "\n") or ""
end
local function waitFor(cond, cap)
for _ = 1, (cap or 200) do
if cond() then return true end
U.wait(2)
end
return cond()
end
local function mashUntil(cond, cap)
for _ = 1, (cap or 100) do
if cond() then return true end
U.tap(game, "a")
U.wait(2)
end
return cond()
end
-- fresh dojo with the master already beaten and neither prize taken
local function seed(x, y, facing)
while game.stack:top() do game.stack:pop() end
game.save.flags = {
EVENT_BEAT_KARATE_MASTER = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = true,
}
game.save.defeatedTrainers = { FIGHTING_DOJO_obj_1 = true }
game.save.objectToggles = {}
game.save.player.name = game.save.player.name or "RED"
-- one mon so give_pokemon has a party to append to, and room for a prize
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.stack:push(OW, MAP, x, y, facing or "up")
U.wait(10)
return game.stack:top()
end
local ow = seed(START.x, START.y, "up")
------------------------------------------------------------------
-- machine-checkable half: seed, objects, script rows, text, screen id
------------------------------------------------------------------
check(game.save.flags.EVENT_BEAT_KARATE_MASTER == true,
"EVENT_BEAT_KARATE_MASTER is set (the balls answer at all)")
check(not game.save.flags.EVENT_GOT_HITMONLEE
and not game.save.flags.EVENT_GOT_HITMONCHAN,
"neither prize taken yet (no 'greedy' refusal path)")
local leeBall, chanBall = npcByName(ow, LEE.name), npcByName(ow, CHAN.name)
check(leeBall ~= nil, "HITMONLEE ball object loaded")
check(chanBall ~= nil, "HITMONCHAN ball object loaded")
check(leeBall and leeBall.cellX == LEE.x and leeBall.cellY == LEE.y,
("HITMONLEE ball sits at the asm cell (%d, %d)"):format(LEE.x, LEE.y))
check(chanBall and chanBall.cellX == CHAN.x and chanBall.cellY == CHAN.y,
("HITMONCHAN ball sits at the asm cell (%d, %d)"):format(CHAN.x, CHAN.y))
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL"))
== "function",
"TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL has a hand-ported talk script")
check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL"))
== "function",
"TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL has a hand-ported talk script")
-- the ask() string is the extracted descriptor, not the "You want X?" stub
local leeText = game.data.text._FightingDojoHitmonleePokeBallText
local chanText = game.data.text._FightingDojoHitmonchanPokeBallText
check(type(leeText) == "string" and leeText ~= "",
"_FightingDojoHitmonleePokeBallText resolves")
check(type(chanText) == "string" and chanText ~= "",
"_FightingDojoHitmonchanPokeBallText resolves")
if type(leeText) == "string" then
U.log("lee prompt reads:", (leeText:gsub("\n", " / ")))
end
local dexOk = pcall(Screens.get, game, "DexEntryMenu")
check(dexOk, "DexEntryMenu resolves through the Screens registry")
------------------------------------------------------------------
-- rehearsal on the HITMONCHAN ball, answered NO so nothing is consumed
------------------------------------------------------------------
if chanBall then
ow:talkTo(chanBall)
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
"#853: the ball opens the HITMONCHAN dex page before any question")
U.shot(game, DIR .. "/dojo_balls_1_dex.png")
U.tap(game, "b")
check(waitFor(function() return topIs(TextBox) end, 60),
"#853: closing the dex page leads into the offer text")
mashUntil(function() return topIs(ChoiceBox) end, 60)
check(topIs(ChoiceBox), "#854: the YES/NO menu opens on the offer")
check(getmetatable(under()) == TextBox,
"#854: the question box is still on the stack under the YES/NO menu")
U.shot(game, DIR .. "/dojo_balls_2_choice.png")
U.tap(game, "b") -- B answers NO; the prize stays unclaimed
waitFor(function() return game.stack:top() == ow end, 120)
check(not game.save.flags.EVENT_GOT_HITMONCHAN,
"answering NO leaves the HITMONCHAN prize unclaimed")
check(#game.save.party == 1, "answering NO adds nothing to the party")
end
------------------------------------------------------------------
-- hand-off: walk to the HITMONLEE ball and open it for real
------------------------------------------------------------------
ow = seed(START.x, START.y, "up")
for _ = 1, 12 do
if ow.player.cellY <= LEE.y + 1 then break end
U.hold(game, "up", 16)
U.wait(4)
end
local function facingTheBall()
local cur = game.overworld
local ball = cur and npcByName(cur, LEE.name)
if not ball then return false end
local fx, fy = cur.player:facingCell()
return cur:npcAtCell(fx, fy) == ball
end
if not facingTheBall() then
-- a map edit or a mod moved the ball: stand on any free walkable
-- neighbour instead. {dx, dy, facing} is the offset from the ball to
-- the stand cell plus the direction that looks back at it.
local sides = {
{ 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" },
}
for _, s in ipairs(sides) do
local cx, cy = LEE.x + s[1], LEE.y + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log("walk up stopped short; standing on", cx, cy, "facing", s[3])
ow = seed(cx, cy, s[3])
break
end
end
end
check(facingTheBall(), "player is standing against the HITMONLEE ball")
U.tap(game, "a")
check(waitFor(function() return topIs(DexEntryMenu) end, 60),
"#853: pressing A opens the HITMONLEE dex page")
U.shot(game, DIR .. "/dojo_balls_3_handoff.png")
if #failures == 0 then
U.log("all checks passed")
else
U.log(("%d check(s) failed:"):format(#failures), table.concat(failures, "; "))
end
U.log("On screen now: the HITMONLEE dex page the ball opened, name and")
U.log("sprite only, since the mon is seen but not owned yet. Press B: the")
U.log("offer types out, and the YES/NO menu should appear above it with the")
U.log("question still readable -- the old bug swapped the text away for a")
U.log("bare YES/NO over the overworld. Answer YES to take HITMONLEE; the")
U.log("HITMONCHAN ball beside it stays put and gives the greedy refusal.")
while true do
coroutine.yield()
end
end
+12 -2
View File
@@ -3,7 +3,9 @@
-- BUG1 gate -- the master stops the player on the tile to his left
-- BUG2 no speech -- no won text + no prize dialogue after the win
-- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry
-- BUG4 (verify) -- the ball opens the prize's dex preview first
-- (FightingDojo.asm DisplayPokedex, #853) and then
-- asks with the Gen1 descriptor text
-- BUG5 both balls -- the chosen ball AND the other one both vanish; the
-- other should stay and give the "greedy" refusal
-- BUG6 poster -- the north-wall posters ("Enemies on every side!") are
@@ -20,6 +22,7 @@ return function(game)
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local DexEntryMenu = require("src.ui.DexEntryMenu")
local OW = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
local Commands = require("src.script.Commands")
@@ -35,6 +38,7 @@ return function(game)
local function topIsTextBox() return getmetatable(game.stack:top()) == TextBox end
local function topIsChoice() return getmetatable(game.stack:top()) == ChoiceBox end
local function topIsDex() return getmetatable(game.stack:top()) == DexEntryMenu end
local function currentPageText()
local top = game.stack:top()
@@ -172,8 +176,14 @@ return function(game)
check(leeBall ~= nil and chanBall ~= nil, "BUG5: both prize balls on the mat")
if leeBall then
ow:talkTo(leeBall)
-- DisplayPokedex runs before .Text and YesNoChoice in FightingDojo.asm,
-- so the dex page is the first thing the ball opens (#853)
U.wait(3)
check(topIsDex(), "BUG4: the ball opens the HITMONLEE dex entry first")
U.shot(game, DIR .. "/dojo_4_dexentry.png")
mashUntil(function() return not topIsDex() end, 20)
check(sawText("hard kicking") or sawText("HITMONLEE"),
"BUG4: ball asks the Gen1 descriptor prompt (no dex entry)")
"BUG4: the dex page is followed by the Gen1 descriptor prompt")
U.shot(game, DIR .. "/dojo_4_prompt.png")
------------------------------------------------------------------
-- BUG5: choose YES -> only the chosen ball vanishes; the other stays
@@ -0,0 +1,321 @@
-- Driver: #862 Celadon Game Corner poster grunt, loss line + exit walk.
-- GameCornerRocketText saves _GameCornerRocketBattleEndText ("Dang!") for
-- PrintEndBattleText, and GameCornerRocketBattleScript picks the exit walk
-- from the player's cell (pokered/scripts/GameCorner.asm:54-102): east of
-- him it is WalkAroundPlayer, DOWN/R/R/UP/R/R/R/R, never UP into the poster.
-- No POKEPORT_SPEED: the walk and the battle text are what is under test.
-- SHOT_DIR=/tmp/shots POKEPORT_IDENTITY=bug862 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/game_corner_grunt_bug862_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local BattleState = require("src.battle.BattleState")
local pass, fail = 0, 0
local function check(label, ok, detail)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label, detail or "")
return ok
end
-- pokered/data/maps/objects/GameCorner.asm:36 -- the grunt is
-- object_event 9, 5, SPRITE_ROCKET, STAY, UP, facing the poster bg_event
-- at (9,4), which is wall. Standing east of him on (10,5) is the branch
-- that matters: wYCoord ~= 6 and wXCoord ~= 8, so the script takes
-- GameCornerMovement_Rocket_WalkAroundPlayer.
local MAP = "GAME_CORNER"
local NAME = "GAMECORNER_ROCKET"
local GX, GY = 9, 5
local STAND = { x = 10, y = 5, facing = "left" }
local POSTER = { x = 9, y = 4 }
-- DOWN, RIGHT, RIGHT, UP, RIGHT x4 from (9,5), ending on (15,5)
local AROUND = {
{ 9, 6 }, { 10, 6 }, { 11, 6 }, { 11, 5 },
{ 12, 5 }, { 13, 5 }, { 14, 5 }, { 15, 5 },
}
-- clean slate: he must not read as already defeated or already hidden
game.save.defeatedTrainers = {}
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.GAME_CORNER = nil
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
game.save.money = game.save.money or 3000
-- a tank that one-shots OPP_ROCKET #7, so the mash win below is quick and
-- the same every run whatever the type matchups are
local tank = Pokemon.new(game.data, "MEWTWO", 100)
tank.moves = {
{ id = "PSYCHIC_M", pp = 99 },
{ id = "THUNDERBOLT", pp = 99 },
{ id = "ICE_BEAM", pp = 99 },
{ id = "EARTHQUAKE", pp = 99 },
}
game.save.party = { tank }
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
local ow = game.overworld
local function findGrunt()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == NAME then return n end
end
return nil
end
local grunt = findGrunt()
check("GAMECORNER_ROCKET is on the floor", grunt ~= nil)
if grunt then
check("he stands on (9,5)", grunt.cellX == GX and grunt.cellY == GY,
("at (%d,%d)"):format(grunt.cellX, grunt.cellY))
end
-- a map edit or a mod could take (10,5) away; anything east of him keeps
-- the WalkAroundPlayer branch, so fall back to a free walkable neighbour
-- and say which branch that lands on
local function facingGrunt()
local g = findGrunt()
if not g then return false end
local fx, fy = ow.player:facingCell()
return ow:npcAtCell(fx, fy) == g
end
if grunt and not facingGrunt() then
local sides = {
{ 1, 0, "left" }, { 0, 1, "up" }, { -1, 0, "right" }, { 0, -1, "down" },
}
for _, s in ipairs(sides) do
local cx, cy = grunt.cellX + s[1], grunt.cellY + s[2]
if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then
U.log(("(%d,%d) is blocked; standing on"):format(STAND.x, STAND.y),
cx, cy, "facing", s[3])
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
grunt = findGrunt()
break
end
end
end
check("the player is face to face with him", facingGrunt())
local px, py = ow.player.cellX, ow.player.cellY
local around = not (py == 6 or px == 8)
U.log(("talking from (%d,%d): the script should take %s"):format(
px, py, around and "WalkAroundPlayer (down, right, right, up, "
.. "right x4)" or "WalkDirect (right x5)"))
-- the two strings the fix depends on, and the poster cell the pre-fix
-- single UP step walked him into
local t = game.data.text
check("_GameCornerRocketBattleEndText resolves",
type(t._GameCornerRocketBattleEndText) == "string"
and t._GameCornerRocketBattleEndText ~= "",
tostring(t._GameCornerRocketBattleEndText))
check("_GameCornerRocketAfterBattleText resolves",
type(t._GameCornerRocketAfterBattleText) == "string"
and t._GameCornerRocketAfterBattleText ~= "")
check("(9,4) is the poster wall, not a cell he can stand on",
not ow.map:isWalkableCell(POSTER.x, POSTER.y))
-- engageTrainer has to accept the script-supplied loss line; a stale
-- two-parameter copy would silently drop it and print nothing
local info = debug.getinfo(ow.engageTrainer, "S")
local sigOk = false
if info and info.short_src then
local src = io.open((info.short_src:gsub("^@", "")), "r")
if src then
local n = 0
for line in src:lines() do
n = n + 1
if n == info.linedefined then
sigOk = line:find("endBattleText", 1, true) ~= nil
break
end
end
src:close()
end
end
check("engageTrainer takes an endBattleText argument", sigOk)
U.shot(game, DIR .. "/bug862_0_before.png")
-- Talk and mash to a win, recording every battle message in order and
-- pausing on the loss line long enough to photograph it.
local said, battle = {}, nil
local lastSaid, dangShot = nil, false
local function sample()
local top = game.stack:top()
if getmetatable(top) == BattleState then
battle = battle or top
local cur = top.current
local text = type(cur) == "table" and cur.text
if type(text) == "string" and text ~= lastSaid then
lastSaid = text
said[#said + 1] = text
U.log("battle says:", (text:gsub("\n", " ")))
end
end
end
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local parts = {}
for _, page in ipairs(top.pages or {}) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
local function idle()
return game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and not ow.transitioning
end
U.tap(game, "a")
local sawAfter = false
for f = 1, 4000 do
sample()
if pageText():find("hideout", 1, true) then sawAfter = true break end
local top = game.stack:top()
if lastSaid and lastSaid:find("Dang", 1, true) and not dangShot then
-- stop mashing for a moment: the loss line is on the battle screen.
-- The row is picked up the frame it starts typing, so let it finish
-- before the capture or the shot is one letter wide.
dangShot = true
U.wait(60)
U.shot(game, DIR .. "/bug862_1_dang.png")
elseif top and top.phase then
if top.phase == "menu" then top.menuIndex = 1
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
U.tap(game, "a")
if f > 2400 and top.onFinish then
U.log("force-finishing a stalled battle")
top.onFinish("win")
if game.stack:top() == top then game.stack:pop() end
end
else
U.tap(game, "a")
end
U.wait(2)
sample()
end
check("reached the after-battle 'hideout' line", sawAfter)
check("the battle carried the script's loss line",
battle ~= nil and type(battle.endBattleText) == "string"
and battle.endBattleText:find("Dang", 1, true) ~= nil,
battle and tostring(battle.endBattleText) or "no battle seen")
-- PrintEndBattleText sits between TrainerDefeatedText and
-- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory)
local iDefeat, iDang, iMoney
for i, line in ipairs(said) do
if not iDefeat and line:find("defeated", 1, true) then iDefeat = i end
if not iDang and line:find("Dang", 1, true) then iDang = i end
if not iMoney and line:find("winning", 1, true) then iMoney = i end
end
check("the loss line printed on the battle screen", iDang ~= nil)
check("it printed with the ROCKET: name tag",
iDang ~= nil and said[iDang]:find(":", 1, true) ~= nil,
iDang and said[iDang] or "")
check("order is defeated -> Dang! -> payout",
iDefeat ~= nil and iDang ~= nil and iMoney ~= nil
and iDefeat < iDang and iDang < iMoney,
("defeated=%s dang=%s payout=%s"):format(tostring(iDefeat),
tostring(iDang),
tostring(iMoney)))
U.shot(game, DIR .. "/bug862_2_afterbattle.png")
-- Dismiss the after-battle box and watch the exit walk cell by cell.
U.tap(game, "a")
local visited, order, lowShot = {}, {}, false
local function mark(cx, cy)
local key = cx .. "," .. cy
if not visited[key] then
visited[key] = true
order[#order + 1] = key
end
end
-- the last step's hide_object rides its own onDone, so the grunt leaves
-- ow.npcs on the frame he lands: count the cell he is walking INTO as
-- visited too, or the destination never shows up in the sample
local last = { GX, GY }
for _ = 1, 900 do
local g = findGrunt()
if g then
mark(g.cellX, g.cellY)
last = { g.cellX, g.cellY }
if g.targetX and g.targetY then
mark(g.targetX, g.targetY)
last = { g.targetX, g.targetY }
end
if g.cellY > GY and not lowShot then
lowShot = true
U.shot(game, DIR .. "/bug862_3_walk.png")
end
elseif idle() then
break
end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(1)
end
for _ = 1, 400 do
if idle() then break end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(2)
end
U.wait(5)
U.shot(game, DIR .. "/bug862_4_gone.png")
U.log("cells he stood on:", table.concat(order, " "))
check("he never stood on the poster cell (9,4)",
not visited[POSTER.x .. "," .. POSTER.y])
check("he never stepped north of his start row", (function()
for key in pairs(visited) do
local y = tonumber(key:match(",(%d+)$"))
if y and y < GY then return false end
end
return true
end)())
if around then
check("he stepped down to (9,6) to get past the player", visited["9,6"])
check("he came back up onto row 5 and finished on (15,5)",
last[1] == 15 and last[2] == 5,
("last seen on (%d,%d)"):format(last[1], last[2]))
else
check("he walked straight along row 5 to (15,5)",
last[1] == 15 and last[2] == 5 and not visited["9,6"],
("last seen on (%d,%d)"):format(last[1], last[2]))
end
local toggles = game.save.objectToggles.GAME_CORNER
check("he despawned only after the last step", findGrunt() == nil)
check("his objectToggle is hidden",
toggles ~= nil and toggles.GAMECORNER_ROCKET == false)
check("he is recorded as defeated",
game.save.defeatedTrainers["GAME_CORNER_obj_11"] == true)
U.log(("checks: %d passed, %d failed"):format(pass, fail))
-- Hand the pad over on a clean copy of the same setup so the whole beat
-- can be watched at speed.
game.save.defeatedTrainers = {}
game.save.objectToggles.GAME_CORNER = nil
U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing)
U.log("You are east of the grunt again, facing him. Press A and win.")
U.log("Right looks like: he says his piece on the battle screen after")
U.log("\"RED defeated ROCKET!\" -- one box, \"ROCKET: Dang!\" -- and the")
U.log("¥ payout comes after it, not before. Then the hideout line, then")
U.log("he steps DOWN off row 5, right past you, back up and out east.")
U.log("The near miss to watch for: he steps UP into the poster, or the")
U.log("Dang! box turns up in the overworld after the battle has torn down.")
U.log("Talk to him from (9,6) below instead and he takes the straight")
U.log("five-step version east; both are correct, the branch is your cell.")
while true do
coroutine.yield()
end
end
+177
View File
@@ -0,0 +1,177 @@
-- Manual check of the Rocket Hideout B4F Jessie & James ambush: James walks
-- the full four tiles to the player's side (#865) and their loss line prints
-- on the battle screen before the prize money (#866).
-- pokeyellow scripts/RocketHideoutB4F.asm (MovementData_45605 falls through
-- into _45606) and data/maps/objects/RocketHideoutB4F.asm. No fast-forward:
-- POKEPORT_DRIVER=tests/drivers/jessie_james_bug866_test.lua POKEPORT_VERSION=yellow love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Commands = require("src.script.Commands")
local GameVersion = require("src.core.GameVersion")
local mapScripts = require("data.scripts.init")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROCKET_HIDEOUT_B4F"
local BEAT = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES"
local JAMES, JESSIE = "ROCKETHIDEOUTB4F_JAMES", "ROCKETHIDEOUTB4F_JESSIE"
-- RocketHideoutB4FScript_455a5 fires on wYCoord $e with wXCoord $18 or $19.
-- x=24 leaves EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT clear, which is
-- the branch that hands the four-step blob to James (object 2, spawned at
-- 25,10) and the three-step one to Jessie (object 3, at 24,10).
local TRIGGER = { x = 24, y = 14 }
local EXPECT = {
[JAMES] = { x = 25, y = 14, facing = "left" },
[JESSIE] = { x = 24, y = 13, facing = "down" },
}
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
check("running the Yellow cache (the duo exists nowhere else)",
GameVersion.isYellow())
if not GameVersion.isYellow() then
U.log("re-run with POKEPORT_VERSION=yellow; nothing below will be true")
end
local hooks = mapScripts.get(MAP)
check("yellow_jessie_james registered an onStep for " .. MAP,
type(hooks) == "table" and type(hooks.onStep) == "function")
check("their talk entries are registered too",
type(hooks) == "table" and type(hooks.talk) == "table"
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JAMES ~= nil
and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JESSIE ~= nil)
-- the #866 fix is a new script verb; if a mod shadowed it or the registry
-- never picked it up, the row would silently no-op and the line would come
-- back after the money instead of before it
local verb = Commands.resolve(game.data, "save_end_battle_text")
check("save_end_battle_text resolves as a script verb", type(verb) == "function")
local texts = {}
for i = 1, 4 do
local key = "_RocketHideoutJessieJamesText" .. i
texts[i] = game.data.text[key]
check(key .. " resolves to a string",
type(texts[i]) == "string" and texts[i] ~= "")
end
if type(texts[3]) == "string" then
U.log("the armed loss line reads:", (texts[3]:gsub("\n", " / ")))
end
local objs = (game.data.maps[MAP] or {}).objects or {}
local defs = {}
for _, o in ipairs(objs) do
if o.name == JAMES or o.name == JESSIE then defs[o.name] = o end
end
check("James is object 2 of " .. MAP .. ", hidden at (25,10)",
defs[JAMES] ~= nil and defs[JAMES].index == 2
and defs[JAMES].x == 25 and defs[JAMES].y == 10
and defs[JAMES].hidden == true)
check("Jessie is object 3, hidden at (24,10)",
defs[JESSIE] ~= nil and defs[JESSIE].index == 3
and defs[JESSIE].x == 24 and defs[JESSIE].y == 10
and defs[JESSIE].hidden == true)
local rocket = game.data.trainers.OPP_ROCKET
check("OPP_ROCKET party 43 (the duo's shared team) exists",
rocket ~= nil and rocket.parties ~= nil and rocket.parties[43] ~= nil)
-- arm the site: the ambush is gated only on its beat flag, so no story
-- progress is needed to make it live
game.save.flags[BEAT] = nil
game.save.flags.EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = nil
check(BEAT .. " cleared, so the trigger is live",
game.save.flags[BEAT] == nil)
-- a real party, because the human has to win the battle for the loss line
-- to print at all
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 60),
Pokemon.new(game.data, "NIDOKING", 58),
Pokemon.new(game.data, "STARMIE", 58),
}
game.save.player.name = "RED"
-- walk in from the north; the two elevator warps sit on row 15, so the
-- approach cannot come from below
U.teleport(game, MAP, TRIGGER.x, TRIGGER.y - 1, "down")
local ow = game.overworld
if not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y - 1) then
-- a map edit moved the free cell: any walkable neighbour of the trigger
-- works, the script only reads the tile the player lands on
local sides = { { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } }
for _, s in ipairs(sides) do
local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2]
if ow.map:isWalkableCell(cx, cy) then
U.log("standing on", cx, cy, "facing", s[3], "instead")
U.teleport(game, MAP, cx, cy, s[3])
ow = game.overworld
U.hold(game, s[3] == "down" and "down" or (s[3] == "right" and "right" or "left"), 20)
break
end
end
else
U.hold(game, "down", 20)
end
U.wait(10)
check("player stepped onto the trigger tile (24,14)",
ow.player.cellX == TRIGGER.x and ow.player.cellY == TRIGGER.y)
check("the ambush script is running", ow.runner:isRunning())
U.log("The cutscene is yours now: press A to read, then fight and win.")
U.log("Right looks like both Rockets closing in -- Jessie stopping one tile")
U.log("above you, James coming all the way down to stand at your right -- and")
U.log("after you win, \"ROCKET: Such a dreadful twerp!\" appearing on the")
U.log("battle screen just before the money line. The near-miss to watch for")
U.log("is James halting three tiles up by the wall, or that line showing up")
U.log("in the overworld box after the payout with no ROCKET: tag on it.")
U.log("Two more checks print below as you get to them.")
local function npcNamed(name)
for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do
if n.def and n.def.name == name then return n end
end
return nil
end
local approachDone, battleSeen = false, false
while true do
if not approachDone then
local j, s = npcNamed(JAMES), npcNamed(JESSIE)
local ow2 = game.overworld
if j and s and not j.moving and not s.moving and ow2
and #(ow2.scriptMoves or {}) == 0
and (j.cellY > 10 or s.cellY > 10) then
approachDone = true
check("James walked the full four tiles to (25,14) facing left",
j.cellX == EXPECT[JAMES].x and j.cellY == EXPECT[JAMES].y
and j.facing == EXPECT[JAMES].facing)
check("Jessie stopped three down at (24,13) facing the player",
s.cellX == EXPECT[JESSIE].x and s.cellY == EXPECT[JESSIE].y
and s.facing == EXPECT[JESSIE].facing)
U.log("James at", j.cellX, j.cellY, j.facing,
"Jessie at", s.cellX, s.cellY, s.facing)
U.shot(game, SHOT_DIR .. "/jj866_approach.png")
end
end
if not battleSeen then
local top = game.stack:top()
if getmetatable(top) == BattleState then
battleSeen = true
-- BattleState prints endBattleText between _TrainerDefeatedText and
-- _MoneyForWinningText, so an armed field IS the ordering fix
check("the battle carries the loss line as its end-battle text",
type(top.endBattleText) == "string" and top.endBattleText ~= ""
and top.endBattleText == texts[3])
if type(top.endBattleText) == "string" then
U.log("armed:", (top.endBattleText:gsub("\n", " / ")))
end
end
end
coroutine.yield()
end
end