more buggies CLOSES #960, CLOSES #961, CLOSES #968, CLOSES #995, CLOSES #1006, CLOSES #1009, CLOSES #1013, CLOSES #1021, CLOSES #1031, CLOSES #1044, CLOSES #1049, CLOSES #1050, CLOSES #1045,,

This commit is contained in:
bryanthaboi
2026-08-10 17:06:24 -04:00
parent 7fa758602a
commit c3855cebec
20 changed files with 1837 additions and 70 deletions
@@ -248,7 +248,7 @@ return function(game)
-- ---- case 2: B-cancel settles on the OLD form's colours -----------------
local karp = startEvo("MAGIKARP", "GYARADOS")
U.wait(24)
U.wait(100) -- past the 80-frame pre-animLoop delay before the poll starts
U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel
if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then
check(false, "holding B prints \"stopped evolving\"")
@@ -1,13 +1,14 @@
-- Driver: cancel an evolution with the B button (#213).
--
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
-- flash: holding B aborts the evolution (the mon keeps its species and
-- pokered engine/movie/evolution.asm polls hJoy5 during the pic flash: a
-- fresh B press aborts the evolution (the mon keeps its species and
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
--
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
-- press B, and assert the mon stays CATERPIE with the "stopped evolving"
-- text on screen.
-- Case 1b: after cancel, checkParty with no level-ups must not re-offer;
-- a subsequent level-up set must offer again (EvolveAfterBattle parity).
-- Case 2 (control): let the flash run to completion with no input and
@@ -80,11 +81,11 @@ return function(game)
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220
U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png")
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
U.hold(game, "b", 20) -- Gen1 hJoy5 B-cancel
-- the flash aborts: EvolutionState is no longer the top (the stopped
-- text overlays it and then pops it)
@@ -118,7 +119,7 @@ return function(game)
if not waitFor(evoTop, 300) then
error("EvolutionState never opened after level-up re-offer")
end
U.wait(20)
U.wait(100) -- past the same 80-frame pre-animLoop delay
U.hold(game, "b", 20) -- cancel so case 2 stays independent
if not waitFor(function() return not evoTop() end, 240) then
error("level-up re-offer did not abort on B")
@@ -0,0 +1,484 @@
-- Ear check for the PC, bump, door/stairs and battle menu SFX (#960, #961, #1044, #1045).
-- POKEPORT_DRIVER=tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua POKEPORT_IDENTITY=sfx960 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local BattleState = require("src.battle.BattleState")
local Boxes = require("src.pokemon.Boxes")
local Menu = require("src.ui.Menu")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
local Strings = require("src.core.Strings")
local Timing = require("src.core.Timing")
local FADE = Timing.WARP_FADE_OUT
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1 else fail = fail + 1 end
U.log(ok and "PASS" or "FAIL", label)
return ok
end
-- every cue this run makes, forwarded so playback is untouched; the frame
local cues = {}
local realPlay = Sound.play
Sound.play = function(data, name)
cues[#cues + 1] = { name = name, frame = U.frame() }
return realPlay(data, name)
end
local function since(mark)
local names = {}
for i = mark + 1, #cues do names[#names + 1] = cues[i].name end
return #names > 0 and table.concat(names, ", ") or "nothing"
end
local function heard(mark, want)
for i = mark + 1, #cues do
if cues[i].name == want then return cues[i] end
end
return nil
end
local function countOf(mark, want)
local n = 0
for i = mark + 1, #cues do if cues[i].name == want then n = n + 1 end end
return n
end
-- ---- what the ear cannot check -----------------------------------------
local opts = game.save.options or {}
local sfxVol, musicVol = opts.sfxVol or 0, opts.musicVol or 0
if sfxVol == 0 then
U.log("FAIL SFX volume is 0. Every line below is about a sound that is or")
U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION")
U.log(" and start over, or this run proves nothing at all.")
end
check(("sfx volume %d/7, music volume %d/7"):format(sfxVol, musicVol),
sfxVol > 0)
-- an unresolved key is silent in exactly the way these bugs were
local sfx = (game.data.audio or {}).sfx or {}
for _, key in ipairs({ "Turn_On_PC", "Turn_Off_PC", "Enter_PC", "Collision",
"Save", "Go_Inside", "Go_Outside", "Press_AB" }) do
check("sfx " .. key .. " is in the generated audio", sfx[key] ~= nil)
end
-- positions come from data/events/hidden_events.asm and data/maps/objects/*.asm
local extras = game.data.field.hiddenExtras or {}
local pcTiles = extras.pcTiles or {}
local bedroomPC = (pcTiles.REDS_HOUSE_2F or {})[1]
local centerPC = (pcTiles.VIRIDIAN_POKECENTER or {})[1]
check("REDS_HOUSE_2F carries the OpenRedsPC tile", bedroomPC ~= nil)
check("VIRIDIAN_POKECENTER carries a PC tile", centerPC ~= nil)
local function warpTo(mapId, destMap)
for _, w in ipairs((game.data.maps[mapId] or {}).warps or {}) do
if w.destMap == destMap then return w end
end
return nil
end
local houseDoor = warpTo("PALLET_TOWN", "REDS_HOUSE_1F")
local houseExit = warpTo("REDS_HOUSE_1F", "LAST_MAP")
local houseStairs = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F")
local centerDoor = warpTo("VIRIDIAN_CITY", "VIRIDIAN_POKECENTER")
check("PALLET_TOWN has the door into REDS_HOUSE_1F", houseDoor ~= nil)
check("REDS_HOUSE_1F has an exit mat and the stairs up",
houseExit ~= nil and houseStairs ~= nil)
check("VIRIDIAN_CITY has the door into its POKéMON CENTER", centerDoor ~= nil)
-- ---- helpers ------------------------------------------------------------
local DIRS = { up = { 0, -1 }, down = { 0, 1 },
left = { -1, 0 }, right = { 1, 0 } }
local ORDER = { "down", "up", "left", "right" }
local function pressStep(dir)
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
U.wait(1)
end
local function stand(mapId, x, y, facing)
U.teleport(game, mapId, x, y, facing)
U.wait(15)
return game.overworld
end
-- the cell you stand on to face (cx, cy) from `dir`, i.e. one step back
local function cellBehind(cx, cy, dir)
local d = DIRS[dir]
return cx - d[1], cy - d[2]
end
-- first walkable free neighbour of (cx, cy), plus the facing that looks at
local function approach(ow, cx, cy)
for _, dir in ipairs(ORDER) do
local sx, sy = cellBehind(cx, cy, dir)
if ow.map:isWalkableCell(sx, sy) and not ow:npcAtCell(sx, sy) then
return sx, sy, dir
end
end
end
-- any free walkable cell on the map with a solid one next to it, so the
local function findWall(ow)
local map = ow.map
for cy = 0, map.heightCells - 1 do
for cx = 0, map.widthCells - 1 do
if map:isWalkableCell(cx, cy) and not map:warpAtCell(cx, cy)
and not ow:npcAtCell(cx, cy) then
for _, d in ipairs(ORDER) do
local dd = DIRS[d]
if not map:isWalkableCell(cx + dd[1], cy + dd[2]) then
return cx, cy, d
end
end
end
end
end
end
local function npcNamed(ow, name)
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == name then return n end
end
end
-- PlayMapChangeSound (home/overworld.asm:690) plays before GBFadeOutToBlack,
local function takeDoor(dir, want, label)
local mark = #cues
local from = game.overworld.map.id
local cue, switched
for _ = 1, 300 do
if cue or switched then
game.input.state[dir] = false
U.wait(1)
else
pressStep(dir)
end
cue = cue or heard(mark, want)
local ow = game.overworld
if not switched and ow and ow.map.id ~= from then switched = U.frame() end
if cue and switched then break end
end
game.input.state[dir] = false
U.wait(50) -- PlayerStepOutFromDoor walks off the mat before anything else
if not (cue and switched) then
check(("%s: %s played and the map changed"):format(label, want), false)
U.log(" cues on the way through:", since(mark))
return
end
check(("%s: %s fired %d frames before the map switched (the fade is %d)")
:format(label, want, switched - cue.frame, FADE),
switched - cue.frame >= FADE - 8)
end
-- CollisionCheckOnLand (home/overworld.asm): a sprite in the way takes the
local function bumpInto(dir, label)
local mark = #cues
for _ = 1, 10 do pressStep(dir) end
game.input.state[dir] = false
U.wait(24) -- past the 16-frame bumpCooldown, so the next bump is its own
check(label .. " rings Collision", heard(mark, "Collision") ~= nil)
end
local function topIs(class)
local top = game.stack:top()
return top ~= nil and getmetatable(top) == class
end
local function rowIndex(menu, label)
for i, item in ipairs(menu.items or {}) do
if item.label == label then return i end
end
end
-- rows come and go with save state and the ui.pc.items hook, so pick each
local function choose(menu, label)
local i = rowIndex(menu, label)
if not i then
check("the menu has a " .. label .. " row", false)
return false
end
menu.index = i
menu:clampScroll()
U.wait(2)
U.tap(game, "a")
U.wait(26)
return true
end
local function mash(cond, tries)
for _ = 1, tries or 40 do
if cond() then return true end
U.tap(game, "a")
U.wait(6)
end
return cond()
end
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 20) }
game.save.party[1].moves = {
{ id = "TACKLE", pp = 35, maxPP = 35 },
{ id = "VINE_WHIP", pp = 10, maxPP = 10 },
}
Boxes.ensure(game.save)
game.save.currentBox = 1
-- ---- the door into the house, from outside (#961) -----------------------
if houseDoor then
local sx, sy = cellBehind(houseDoor.x, houseDoor.y, "up")
stand("PALLET_TOWN", sx, sy, "up")
takeDoor("up", "Go_Inside", "walking into RED's house")
end
-- walking into MOM, and into a wall (#960); teleport is only a fallback
local ow = game.overworld
if ow.map.id ~= "REDS_HOUSE_1F" and houseExit then
ow = stand("REDS_HOUSE_1F", houseExit.x, houseExit.y, "up")
end
local mom = npcNamed(ow, "REDSHOUSE1F_MOM")
check("MOM is loaded on REDS_HOUSE_1F", mom ~= nil)
if mom then
local sx, sy, dir = approach(ow, mom.cellX, mom.cellY)
if sx then
ow = stand("REDS_HOUSE_1F", sx, sy, dir)
-- the teleport rebuilt the npc list, so pin her on the state we bump
local pinned = npcNamed(ow, "REDSHOUSE1F_MOM")
if pinned then pinned.frozen = true end
U.shot(game, DIR .. "/bug960_mom.png")
bumpInto(dir, "walking into MOM")
else
check("MOM has a free cell to be walked into from", false)
end
-- the control: a wall bump was audible before #960 too, so silence here
local wx, wy, wdir = findWall(game.overworld)
if wdir then
ow = stand("REDS_HOUSE_1F", wx, wy, wdir)
bumpInto(wdir, "walking into the wall to the " .. wdir)
else
check("REDS_HOUSE_1F has a wall to bump into", false)
end
end
-- ---- the stairs up (#961) -----------------------------------------------
if houseStairs then
local sx, sy = cellBehind(houseStairs.x, houseStairs.y, "up")
stand("REDS_HOUSE_1F", sx, sy, "up")
-- the destination decides the cue here, not the tile underfoot, so an
takeDoor("up", "Go_Inside", "taking the stairs up")
end
-- ---- the bedroom PC (#960) ---------------------------------------------
if bedroomPC then
-- OpenRedsPC's hidden_event is gated on SPRITE_FACING_UP, so the cell
local sx, sy = cellBehind(bedroomPC.x, bedroomPC.y, "up")
ow = stand("REDS_HOUSE_2F", sx, sy, "up")
check("the cell below the bedroom PC is walkable",
ow.map:isWalkableCell(sx, sy))
local mark = #cues
U.tap(game, "a")
U.wait(26)
check("A on the bedroom PC opens a menu", topIs(Menu))
check("...and turns it on with Turn_On_PC", heard(mark, "Turn_On_PC") ~= nil)
U.shot(game, DIR .. "/bug960_bedroom_pc.png")
if topIs(Menu) then
mark = #cues
choose(game.stack:top(), Strings("LOG OFF"))
check("LOG OFF on the bedroom PC rings Turn_Off_PC (#960)",
heard(mark, "Turn_Off_PC") ~= nil)
U.log(" cues:", since(mark))
check("...and the PC closed", game.stack:top() == game.overworld)
end
-- B out of the same menu is ExitPlayerPC's other entry and rings it too
U.tap(game, "a")
U.wait(26)
if topIs(Menu) then
local mark2 = #cues
U.tap(game, "b")
U.wait(26)
check("backing out of the bedroom PC with B rings it as well",
heard(mark2, "Turn_Off_PC") ~= nil)
end
end
-- ---- back out of the house, to the street (#961) ------------------------
if houseExit then
local sx, sy = cellBehind(houseExit.x, houseExit.y, "down")
ow = stand("REDS_HOUSE_1F", sx, sy, "down")
if not ow.map:isWalkableCell(sx, sy) then
U.log(("(%d, %d) is blocked; using the other half of the mat")
:format(sx, sy))
ow = stand("REDS_HOUSE_1F", sx + 1, sy, "down")
end
takeDoor("down", "Go_Outside", "stepping out onto the street")
end
-- ---- into the POKéMON CENTER, for the PC main menu -----------------------
if centerDoor then
local sx, sy = cellBehind(centerDoor.x, centerDoor.y, "up")
stand("VIRIDIAN_CITY", sx, sy, "up")
takeDoor("up", "Go_Inside", "walking into the POKéMON CENTER")
end
-- ---- the PC main menu: Enter_PC, and the silence under it (#960) --------
if centerPC then
local sx, sy = cellBehind(centerPC.x, centerPC.y, "up")
ow = stand("VIRIDIAN_POKECENTER", sx, sy, "up")
for _, n in ipairs(ow.npcs or {}) do n.frozen = true end -- the GENTLEMAN walks
local mark = #cues
U.tap(game, "a")
U.wait(26)
check("A on the Center PC opens the PC main menu", topIs(Menu))
check("...with Turn_On_PC", heard(mark, "Turn_On_PC") ~= nil)
local mine = (game.save.player.name or "RED") .. "'s PC"
if topIs(Menu) then
mark = #cues
choose(game.stack:top(), mine)
check(mine .. " rings Enter_PC (#960)", heard(mark, "Enter_PC") ~= nil)
check("...and the item PC opened", topIs(Menu))
-- BIT_USING_GENERIC_PC: reached this way, ExitPlayerPC is silent and
mark = #cues
U.tap(game, "b")
U.wait(26)
check("backing out of it again is silent, as the ROM is",
heard(mark, "Turn_Off_PC") == nil)
U.log(" cues:", since(mark))
end
-- ---- CHANGE BOX (#1044) ----------------------------------------------
-- the box PC reads SOMEONE'S PC until EVENT_MET_BILL (pokemon_pc.asm)
local flags = game.save.flags or {}
local boxPC = (flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET)
and "BILL'S PC" or Strings("SOMEONE'S PC")
if topIs(Menu) then
mark = #cues
choose(game.stack:top(), boxPC)
check(boxPC .. " rings Enter_PC too", heard(mark, "Enter_PC") ~= nil)
check("...and the box menu opened", topIs(Menu))
end
if topIs(Menu) and rowIndex(game.stack:top(), Strings("CHANGE BOX")) then
choose(game.stack:top(), Strings("CHANGE BOX"))
U.shot(game, DIR .. "/bug1044_change_box.png")
local before = game.save.currentBox
U.tap(game, "down") -- BOX 1 is the current one, so move off it
U.wait(8)
mark = #cues
-- A picks the box, then the "data will be saved" prompt and its YES
U.tap(game, "a")
U.wait(20)
mash(function() return game.save.currentBox ~= before end, 40)
U.wait(60) -- the 15-frame answer hold, then the write
check(("CHANGE BOX switched box %s -> %s")
:format(tostring(before), tostring(game.save.currentBox)),
game.save.currentBox ~= before)
check("...and rang the SAVE jingle (#1044)", heard(mark, "Save") ~= nil)
U.log(" cues:", since(mark))
end
-- ---- LOG OFF from the main menu --------------------------------------
for _ = 1, 6 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(20)
end
U.tap(game, "a")
U.wait(26)
if topIs(Menu) and rowIndex(game.stack:top(), Strings("LOG OFF")) then
local mark2 = #cues
choose(game.stack:top(), Strings("LOG OFF"))
check("LOG OFF on the PC main menu rings Turn_Off_PC",
heard(mark2, "Turn_Off_PC") ~= nil)
end
for _ = 1, 8 do
if game.stack:top() == game.overworld then break end
U.tap(game, "b")
U.wait(20)
end
end
-- the battle menu click (#1045) already landed in HEAD 12c2677; confirmation only
ow = stand("ROUTE_1", 5, 5, "down")
if not ow.map:isWalkableCell(5, 5) then
local fx, fy
for cy = 0, ow.map.heightCells - 1 do
for cx = 0, ow.map.widthCells - 1 do
if ow.map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end
end
if fx then break end
end
if fx then ow = stand("ROUTE_1", fx, fy, "down") end
end
local battle = BattleState.newWild(game, "SNORLAX", 50)
battle.onFinish = function(result) ow:afterBattle(result, battle) end
-- SPLASH so the foe's turn can never end the run under the menu presses
battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } }
battle.enemy.curMoves = battle.enemy.mon.moves
ow:pushBattle(battle)
U.wait(220) -- the send-out intro runs before the menu is reachable
mash(function() return battle.phase == "menu" end, 60)
check("the wild SNORLAX battle reached its FIGHT menu",
battle.phase == "menu")
-- one press, then long enough for the click to be its own sound
local function click(btn, label, want)
local mark = #cues
U.tap(game, btn)
U.wait(28)
local n = countOf(mark, "Press_AB")
check(("%s: %s gave %d click%s, expected %d")
:format(label, btn:upper(), n, n == 1 and "" or "s", want),
n == want)
end
if battle.phase == "menu" then
click("a", "A on FIGHT (#1045a)", 1)
check("...and the move list opened", battle.phase == "moveSelect")
U.shot(game, DIR .. "/bug1045_move_list.png")
click("b", "B out of the move list (#1045c)", 1)
check("...and the FIGHT menu came back", battle.phase == "menu")
click("a", "A on FIGHT again", 1)
click("a", "A on a move (#1045b)", 1)
mash(function() return battle.phase == "menu" end, 120)
if battle.phase == "menu" then
battle.menuIndex = 4 -- RUN shares the FIGHT/PKMN/ITEM call site
U.wait(4)
click("a", "A on RUN", 1)
end
end
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
-- ---- over to you --------------------------------------------------------
if centerPC then
local sx, sy = cellBehind(centerPC.x, centerPC.y, "up")
ow = stand("VIRIDIAN_POKECENTER", sx, sy, "up")
for _, n in ipairs(ow.npcs or {}) do n.frozen = true end
end
U.log("Everything above has been pressed once already; you are parked at the")
U.log("Viridian POKéMON CENTER PC to do it again by ear.")
U.log("A opens the PC main menu. RED's PC clicks in on the two-note ENTER PC")
U.log("chirp and LOG OFF closes with the descending power-down. B out of RED's")
U.log("PC is silent on purpose; a power-down there is the near miss, not a pass.")
U.log("SOMEONE'S PC, CHANGE BOX, any other box, YES: the SAVE jingle rings")
U.log("after the box has changed, the same jingle the SAVE menu plays. A")
U.log("jingle before the switch, or none at all, is #1044 back.")
U.log("The bedroom PC upstairs at home is the other half of #960: it beeps on,")
U.log("and LOG OFF or B rings the power-down there. That one is not silent.")
U.log("The COOLTRAINER at (4,3) and the NURSE at (3,1) are pinned; walk into")
U.log("either and it thuds like a wall. Walking into a wall is the control --")
U.log("it always thudded, so a silent wall means the device, not the fix.")
U.log(("Walk out over the exit mat at the bottom of the room: the door sound")
.. (" starts as the screen begins to darken, %d frames ahead of")
:format(FADE))
U.log("Viridian City. One that lands on the new map, or after it, is #961.")
U.log("In any battle, A on FIGHT/PKMN/ITEM/RUN, A on a move and B out of the")
U.log("move list all click. Silence on any of the three is #1045.")
U.log("Shots: " .. DIR .. "/bug960_*.png, bug1044_*.png, bug1045_*.png")
-- keeps naming cues with their frame after the hand-off, so a door sound
local reported = #cues
while true do
if #cues > reported then
for i = reported + 1, #cues do
U.log("cue", cues[i].name, "frame", cues[i].frame)
end
reported = #cues
end
coroutine.yield()
end
end
@@ -0,0 +1,410 @@
-- Pikachu stays in its ball until the rival fight (#1009) and clears (4,3) for the rival (#1021); the Route 15 leg only instruments #920.
-- pokeyellow scripts/OaksLab.asm, OaksLab_2.asm.
return function(game)
local U = dofile("tests/drivers/util.lua")
local GameVersion = require("src.core.GameVersion")
local MapScripts = require("src.script.MapScripts")
local Commands = require("src.script.Commands")
local PF = require("src.world.PikachuFollower")
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local BattleState = require("src.battle.BattleState")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local LAB = "OAKS_LAB"
local RIVAL = 1 -- OAKSLAB_RIVAL, object index 1
local GIFT = { x = 5, y = 3 } -- where OaksLabRLE_PlayerWalksToOak ends
local function check(label, ok)
U.log(ok and "PASS" or "FAIL", label)
return ok
end
local function idle()
while true do coroutine.yield() end
end
local function ow() return game.overworld end
local function follower() return PF.current(game.overworld) end
local function ctx()
return { game = game, save = game.save, overworld = game.overworld }
end
local function where(npc)
if not npc then return "gone" end
return "(" .. npc.cellX .. "," .. npc.cellY .. ") facing "
.. tostring(npc.facing)
end
-- one player step; false when refused, so a caller can route around a map edit
local function stepOnce(dir)
local p = ow().player
local x0, y0 = p.cellX, p.cellY
for _ = 1, 60 do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
p = ow().player
if p.cellX ~= x0 or p.cellY ~= y0 then break end
end
game.input.state[dir] = false
for _ = 1, 40 do
if not ow().player.moving then break end
U.wait(1)
end
U.wait(3)
p = ow().player
return p.cellX ~= x0 or p.cellY ~= y0
end
-- ---------------------------------------------------------------- checks
if not check("running the Yellow cache (POKEPORT_VERSION=yellow)",
GameVersion.isYellow()) then
U.log("Red and Blue have no follower and no Yellow lab script, so every")
U.log("line below would fail for the wrong reason.")
idle()
end
check("SPRITE_PIKACHU resolves in the sprite table",
game.data.sprites ~= nil and game.data.sprites.SPRITE_PIKACHU ~= nil)
check("PikachuFollower.oaksLabMakeWay exists",
type(PF.oaksLabMakeWay) == "function")
check("the pikachu_make_way verb exists",
type(Commands.pikachu_make_way) == "function")
check("and it blocks the runner while the walk plays",
Commands.meta.pikachu_make_way ~= nil
and Commands.meta.pikachu_make_way.blocking == true)
local lab = require("data.scripts.oaks_lab_yellow")
local oakRows = lab.talk.TEXT_OAKSLAB_OAK1
local function rowIndex(pred)
for i, r in ipairs(oakRows) do
if pred(r) then return i end
end
return nil
end
local iGramps = rowIndex(function(r)
return r[1] == "show_text" and r[2] == "_OaksLabRivalGrampsText"
end)
local iMakeWay = rowIndex(function(r) return r[1] == "pikachu_make_way" end)
local iShow = rowIndex(function(r)
return r[1] == "show_object" and r[3] == "OAKSLAB_RIVAL"
end)
check("Oak's parcel branch has a make-way row", iMakeWay ~= nil)
-- the callfar sits after the GRAMPS text and before ShowObject; a row on
check("it sits between the GRAMPS text and the rival's ShowObject",
iGramps ~= nil and iMakeWay ~= nil and iShow ~= nil
and iGramps < iMakeWay and iMakeWay < iShow)
local sfx = game.save.options and game.save.options.sfxVol
if sfx == 0 then
U.log("sfxVol is 0. Pikachu's cry as it bursts out of the ball will be")
U.log("silent and a mute run reads exactly like a missing cry -- turn")
U.log("the sound back up before judging the escape scene.")
end
-- leg 1: in the ball. Level 30 only so the lab battle ends fast
game.save.flags = game.save.flags or {}
local flags = game.save.flags
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_PIKACHU = true
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
flags.EVENT_GOT_POKEDEX = nil
flags.EVENT_OAK_GOT_PARCEL = nil
game.save.party = { Pokemon.new(game.data, "PIKACHU", 30) }
game.save.player.name = "bryan"
game.save.onBike = false
game.save.pikachuInBall = true
U.teleport(game, LAB, GIFT.x, GIFT.y, "up")
U.wait(10)
Commands.show_object(ctx(), LAB, "OAKSLAB_OAK1")
U.wait(5)
-- asked after the teleport: the registry only fills on first require
check("the Yellow lab module is the one bound to the map",
MapScripts.talkScript(LAB, "TEXT_OAKSLAB_OAK1") == oakRows)
check("straight after the gift there is no follower on the map",
follower() == nil)
-- save compat: nil pikachuInBall falls back to the rival-fight flag, not false
game.save.pikachuInBall = nil
U.wait(10)
check("a pre-#1009 save before the rival fight still has none",
follower() == nil)
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
U.wait(20)
check("a pre-#1009 save past the rival fight keeps its follower",
follower() ~= nil)
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
game.save.pikachuInBall = true
U.wait(20)
check("and the in-ball byte puts it away again", follower() == nil)
U.shot(game, SHOT_DIR .. "/bug1009_in_ball.png")
U.log("captured", SHOT_DIR .. "/bug1009_in_ball.png",
"- player alone at the gift spot")
-- --------------------------------------------- leg 2: out of the ball
Commands.show_object(ctx(), LAB, "OAKSLAB_RIVAL")
U.wait(5)
-- OaksLabRivalChallengesPlayerScript fires from y >= 6 with the starter held
local walkedClean = true
for _ = 1, 10 do
if ow().player.cellY >= 6 then break end
if follower() then walkedClean = false end
if not stepOnce("down") then
if not stepOnce("left") then break end
end
end
check("crossed the lab to the door row with no follower behind",
walkedClean and follower() == nil)
check("reached the row the rival challenges from", ow().player.cellY >= 6)
-- the escape only spawns the companion once the overworld is back on top
local sawBattle, escapedAt, spawnCell = false, nil, nil
for _ = 1, 2500 do
local o = game.overworld
local top = game.stack:top()
if top ~= o then
if getmetatable(top) == BattleState then sawBattle = true end
U.tap(game, "a")
U.wait(3)
else
local npc = follower()
if npc and not escapedAt then
escapedAt = U.frame()
spawnCell = { x = npc.cellX, y = npc.cellY, facing = npc.facing }
U.shot(game, SHOT_DIR .. "/bug1009_escaped.png")
end
if escapedAt and not o.runner:isRunning() and #o.scriptMoves == 0 then
break
end
U.wait(2)
end
end
check("the rival battle actually ran", sawBattle)
check("the escape scene put a follower on the map", escapedAt ~= nil)
check("EVENT_BATTLED_RIVAL_IN_OAKS_LAB is set",
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB == true)
check("the ball is open (save.pikachuInBall false, not nil)",
game.save.pikachuInBall == false)
if spawnCell then
local p = ow().player
U.log("it appeared at", spawnCell.x, spawnCell.y, "with the player at",
p.cellX, p.cellY, "facing", p.facing)
-- OaksLabPikachuEscapesPokeballScript faces the player up and uses spawn
check("it burst out on the cell behind the player, not beside him",
spawnCell.x == p.cellX and spawnCell.y == p.cellY + 1)
U.log("captured", SHOT_DIR .. "/bug1009_escaped.png")
end
-- leg 3: Oak's .DeliverParcelText needs parcel held, no balls, no Pokedex
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS = nil
flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = nil
flags.EVENT_GOT_POKEDEX = nil
game.save.pikachuInBall = false
game.save.inventory = game.save.inventory or {}
game.save.inventory.POKE_BALL = nil
if game.save.pokedex then game.save.pokedex.owned = {} end
Bag.add(game.save, "OAKS_PARCEL", 1, game.data)
-- the SPRITE_FACING_LEFT case of TryApplyPikachuMovementData
U.teleport(game, LAB, 4, 3, "right")
U.wait(10)
Commands.show_object(ctx(), LAB, "OAKSLAB_OAK1")
-- he walked out after the lab battle; ShowObject brings him back mid-scene
Commands.hide_object(ctx(), LAB, "OAKSLAB_RIVAL")
U.wait(5)
check("the follower is back for the parcel scene", follower() ~= nil)
stepOnce("right")
-- Oak stands on (5,2) and blocks, so this only turns the player up
U.hold(game, "up", 10)
U.wait(10)
local p = ow().player
local pika = follower()
U.log("player at", p.cellX, p.cellY, "facing", p.facing,
"| Pikachu at", where(pika))
check("the player is below Oak on row 3", p.cellY == 3 and p.facing == "up")
local onRivalCell = pika ~= nil and pika.cellX == 4 and pika.cellY == 3
check("Pikachu is standing on the rival's landing cell (4,3)", onRivalCell)
if not onRivalCell then
U.log("Without it on (4,3) the movement data does not apply and the")
U.log("scene below proves nothing about #1021.")
end
U.shot(game, SHOT_DIR .. "/bug1021_before.png")
-- the rival's own walk sits in the same scriptMoves list, so ask only
local function stillWalking(o, npc)
if npc.moving then return true end
for _, mv in ipairs(o.scriptMoves) do
if mv.entity == npc then return true end
end
return false
end
-- end pose snapshotted at walk stop; the idle roll turns it later (Func_fc803)
U.tap(game, "a")
local leftCell, rivalSeen, rivalArrived, midShot = nil, nil, nil, false
local settled
local trace, last = {}, nil
for f = 1, 1200 do
local o = game.overworld
local top = game.stack:top()
local npc = follower()
if npc then
local cell = npc.cellX .. "," .. npc.cellY
if cell ~= last then
last = cell
trace[#trace + 1] = "f" .. f .. " " .. cell
end
if not leftCell and not (npc.cellX == 4 and npc.cellY == 3) then
leftCell = f
end
if leftCell and not settled and not stillWalking(o, npc) then
settled = { x = npc.cellX, y = npc.cellY, facing = npc.facing }
end
end
local rival = o:npcByIndex(RIVAL)
if rival and not rivalSeen then rivalSeen = f end
if rival and not rivalArrived and rival.cellX == 4 and rival.cellY == 3
and not rival.moving then
rivalArrived = f
end
if leftCell and rival and rival.moving and not midShot then
midShot = true
U.shot(game, SHOT_DIR .. "/bug1021_stepped_aside.png")
end
if rivalArrived and settled then break end
if top ~= o then U.tap(game, "a") end
U.wait(2)
end
U.log("Pikachu trace:", table.concat(trace, " | "))
check("Pikachu moved off (4,3)", leftCell ~= nil)
check("the rival came in and reached (4,3)", rivalArrived ~= nil)
if leftCell and rivalSeen then
-- the callfar runs before ShowObject: the cell is clear before the rival
check("it started clearing the cell before the rival appeared",
leftCell <= rivalSeen)
end
if settled then
U.log("the walk ended with Pikachu at", settled.x, settled.y,
"facing", settled.facing)
end
-- OaksLabPikachuMovementData2: STEP_DOWN, STEP_RIGHT, LOOK_UP
check("it ended one below the player looking up",
settled ~= nil and settled.x == 5 and settled.y == 4
and settled.facing == "up")
U.shot(game, SHOT_DIR .. "/bug1021_rival_in_place.png")
-- leg 4: nothing is fixed for #920; this only records ow.npcs and trail.ledgeHop
local eastMap = game.data.maps.FUCHSIA_CITY
and game.data.maps.FUCHSIA_CITY.connections
and game.data.maps.FUCHSIA_CITY.connections.east
eastMap = eastMap and eastMap.map or "ROUTE_15"
U.log("Fuchsia's east connection is", eastMap)
U.teleport(game, "FUCHSIA_CITY", 10, 12, "down")
U.wait(10)
local city = ow().map
local row = nil
for y = 0, city.heightCells - 1 do
if city:isWalkableCell(city.widthCells - 1, y)
and city:isWalkableCell(city.widthCells - 2, y) then
row = row or y
end
end
if row then
U.log("crossing the east seam on row", row)
U.teleport(game, "FUCHSIA_CITY", city.widthCells - 2, row, "right")
U.wait(10)
for _ = 1, 6 do
if ow().map.id == eastMap then break end
if not stepOnce("right") then break end
end
end
-- the seam row is blocked (gate rebuild, mod): drop in on the route itself
local function dropOnRoute()
U.teleport(game, eastMap, 4, 8, "right")
U.wait(10)
local m = ow().map
if m:isWalkableCell(4, 8) then return end
for y = 0, m.heightCells - 1 do
for x = 0, 9 do
if m:isWalkableCell(x, y) then
U.teleport(game, eastMap, x, y, "right")
U.wait(10)
return
end
end
end
end
if ow().map.id ~= eastMap then
U.log("could not walk the seam; dropping straight on to", eastMap)
dropOnRoute()
end
check("standing on " .. eastMap, ow().map.id == eastMap)
local worstGap, lostAt, steps = 0, nil, 0
local back = { x = ow().player.cellX, y = ow().player.cellY }
for i = 1, 10 do
if not stepOnce("right") then
if not stepOnce("down") then break end
end
local o = game.overworld
if o.map.id ~= eastMap then
-- the route's gate is a warp, and an arrival respawn is not the stall
U.log("step", i, "walked into", o.map.id, "- stepping back out")
U.teleport(game, eastMap, back.x, back.y, "left")
U.wait(10)
break
end
steps = i
back.x, back.y = o.player.cellX, o.player.cellY
local npc = follower()
local p2 = o.player
local hop = o.pikachuTrail and o.pikachuTrail.ledgeHop
if npc then
local gap = math.abs(npc.cellX - p2.cellX)
+ math.abs(npc.cellY - p2.cellY)
if gap > worstGap then worstGap = gap end
U.log("step", i, "player", p2.cellX, p2.cellY, "| Pikachu", where(npc),
"| gap", gap, "| ledgeHop", tostring(hop))
else
lostAt = lostAt or i
U.log("step", i, "player", p2.cellX, p2.cellY,
"| Pikachu is not in ow.npcs at all | ledgeHop", tostring(hop))
end
end
U.log("walked", steps, "steps east on", eastMap)
check("the follower stayed in ow.npcs the whole way", lostAt == nil)
check("it never fell more than two cells behind", worstGap <= 2)
if lostAt then
U.log("it dropped out of ow.npcs on step", lostAt,
"- that is the shape #920 would take")
end
-- an arrival parks the follower under the player (#863), so walk one step
stepOnce("left")
U.wait(20)
U.shot(game, SHOT_DIR .. "/bug920_east_of_fuchsia.png")
U.log("Six shots, in story order. bug1009_in_ball: the player alone in the")
U.log("lab with the starter already in the party. bug1009_escaped: the cry,")
U.log("then Pikachu on the cell behind him. bug1021_stepped_aside: it walks")
U.log("down and right on the GRAMPS text while the rival is still off the")
U.log("map, ending below the player looking up. The near miss to watch for")
U.log("is the rival arriving first and Pikachu shuffling around him after,")
U.log("or the walk playing under the box instead of after it.")
U.log("Talk to Pikachu here for the control case: it answers with a bubble")
U.log("and a cry, which is the same sprite and the same audio path.")
U.log("#920 is unfixed. The pad is yours on Route 15; the step log above is")
U.log("what the triage wants from a stall -- whether it is still in ow.npcs")
U.log("and whether trail.ledgeHop stayed set after a ledge.")
idle()
end
@@ -0,0 +1,153 @@
-- A level-up evolution survives the B held from the level-up box (#968, #1031); a fresh press still cancels (#290, #213).
-- pokered engine/movie/evolution.asm EvolveMon, Evolution_CheckForCancel.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- TextBox and EvolutionState both require Sound inside update, so seeding
package.loaded["src.core.Sound"] = {
play = function() end,
playCry = function() end,
}
local Fixtures = require("tests.modkit.fixtures")
local Evolution = require("src.pokemon.Evolution")
local EvolutionState = require("src.ui.EvolutionState")
local Input = require("src.core.Input")
local Pokemon = require("src.pokemon.Pokemon")
local StateStack = require("src.core.StateStack")
local TextBox = require("src.render.TextBox")
local Data = Fixtures.fresh()
require("src.render.Font").load(Data)
-- FIXMON_A evolves into FIXMON_B at 16 (tests/fixture_data/pokemon.lua)
local EVO_LEVEL = 16
-- x is the default keyboard B (src/core/Input.lua DEFAULT_BINDINGS)
local B_KEY = "x"
local function newGame()
local game = { data = Data }
local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL)
game.save = {
party = { mon },
player = { name = "RED", id = 1 },
options = { textSpeed = 5 },
flags = {},
pokedex = { seen = {}, owned = {} },
}
game.stack = setmetatable({}, { __index = StateStack })
game.stack:init()
game.input = Input
Input:init()
return game, mon
end
-- one fixed step, in Game:step's order: promote the queued edges, then
local function step(game)
game.input:step()
game.stack:update(1 / 60)
end
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
local function levelUpBox(game, mon)
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
function() Evolution.checkParty(game, nil, { [mon] = true }) end))
end
-- one B edge on the box, still held when the movie takes over: the bug's handoff
local function dismissWithB(game, mon)
levelUpBox(game, mon)
local box = game.stack:top()
for _ = 1, 900 do
if box.done then break end
step(game)
end
if not box.done then return nil, "the level-up text never finished typing" end
Input:keypressed(B_KEY)
step(game)
local top = game.stack:top()
if getmetatable(top) ~= EvolutionState then
return nil, "the evolution screen never opened"
end
return top
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- B held out of the text box: the movie must run to the end and evolve.
do
local game, mon = newGame()
local evo, why = dismissWithB(game, mon)
if check(evo ~= nil, "the level-up box handed off to the movie: " .. tostring(why)) then
check(Input:isDown("b"),
"B is still physically down as the movie starts, which is what "
.. "the old isDown poll cancelled on")
eq(evo.cancelable, true,
"and this is a cancelable level-up evolution, so the movie really "
.. "is reading the button (#290)")
-- never released: no second edge ever reaches the movie
for _ = 1, 400 do
if evo.done then break end
step(game)
end
check(Input:isDown("b"), "B was held for the whole movie")
eq(evo.canceled, false, "the held B did not cancel the evolution")
eq(mon.species, "FIXMON_B", "the mon actually evolved")
eq(mon.stats.hp, require("src.pokemon.Stats")
.calc(Data.pokemon.FIXMON_B, EVO_LEVEL, mon.dvs, mon.statExp).hp,
"and Evolution.apply recalculated its stats on the new species")
local top = game.stack:top()
check(getmetatable(top) == TextBox and textOf(top):find("evolved into"),
"the congratulations text is what closes the movie")
end
end
-- A deliberate fresh press after the 80-frame delay still cancels.
do
local game, mon = newGame()
local evo = assert(dismissWithB(game, mon))
Input:keyreleased(B_KEY)
for _ = 1, 400 do
if evo.t > 80 then break end
step(game)
end
check(evo.t > 80 and not evo.done,
"the movie is past the DelayFrames window and still running")
Input:keypressed(B_KEY)
step(game)
eq(evo.canceled, true, "a fresh B press cancels the evolution (#213)")
eq(mon.species, "FIXMON_A", "and the mon keeps its species")
local top = game.stack:top()
check(getmetatable(top) == TextBox and textOf(top):find("stopped evolving"),
"_StoppedEvolvingText prints instead of the congratulations")
end
-- A press inside the 80 frames is not polled at all, so the mon still
do
local game, mon = newGame()
local evo = assert(dismissWithB(game, mon))
Input:keyreleased(B_KEY)
for _ = 1, 10 do step(game) end
Input:keypressed(B_KEY)
step(game)
Input:keyreleased(B_KEY)
check(evo.t <= 80, "the press landed inside the delay window")
eq(evo.canceled, false, "a B press during the delay is never polled")
for _ = 1, 400 do
if evo.done then break end
step(game)
end
eq(mon.species, "FIXMON_B", "so the evolution still completes")
end
T.finish()
+102
View File
@@ -0,0 +1,102 @@
-- Nurse Joy bows between the two closing lines (#995).
-- pokered engine/events/pokecenter.asm.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
-- the ROM-extracted strings the fixture text table does not carry
Data.text._PokemonFightingFitText = "Thank you for\nwaiting.\fYour POKéMON are\nfighting fit!"
Data.text._PokemonCenterFarewellText = "We hope to see\nyou again!"
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local fakeGame = { data = Data, stack = stackStub }
T.check(setUpvalue(OW.finishNurseHeal, "TextBox", textBoxStub),
"TextBox upvalue on finishNurseHeal")
T.check(setUpvalue(OW.finishNurseHeal, "Game", fakeGame),
"Game upvalue on finishNurseHeal")
local FIT = Data.text._PokemonFightingFitText
local BYE = Data.text._PokemonCenterFarewellText
local player = { cellX = 3, cellY = 5 }
local faced
local function newNurse()
faced = 0
return {
facing = "down",
facePlayer = function(self) faced = faced + 1; self.facing = "down" end,
}
end
local fakeSelf
local function reset()
pushed = {}
fakeSelf = setmetatable({ player = player }, { __index = OW })
end
-- === with the nurse on the counter: fit line, bow, farewell
reset()
local nurse = newNurse()
local finished = 0
fakeSelf:finishNurseHeal(BYE, function() finished = finished + 1 end, nurse)
T.eq(#pushed, 1, "the fighting-fit line goes up on its own")
T.eq(pushed[1].text, FIT, "first box is exactly the fighting-fit text")
T.check(pushed[1].text:find(BYE, 1, true) == nil,
"the farewell is no longer merged into it with a page break (#995)")
pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow")
T.eq(nurse.facing, "up", "image index $14: the nurse bows")
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
T.eq(hold.frames, 20, "DelayFrames $14 is 20 frames")
T.eq(hold.bubble, false, "no emotion bubble is drawn over her")
T.check(not hold.skippable, "the bow cannot be skipped with A/B")
T.eq(finished, 0, "the pokecenter is still busy during the bow")
-- OverworldState:update counts emote.frames down and then calls onDone
if hold.onDone then hold.onDone() end
T.eq(#pushed, 2, "the farewell follows the bow")
local farewell = pushed[2] or {}
T.eq(farewell.text, BYE, "second box is the farewell text")
T.eq(nurse.facing, "up", "she is still bowed while the farewell prints")
if farewell.onDone then farewell.onDone() end
T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back")
T.eq(faced, 1, "she is turned back exactly once")
T.eq(finished, 1, "control returns to the player once, after the farewell")
-- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text
reset()
finished = 0
fakeSelf:finishNurseHeal(BYE, function() finished = finished + 1 end)
T.eq(pushed[1].text, FIT, "npc-less caller still opens with the fit line")
pushed[1].onDone()
T.check(fakeSelf.emote == nil, "nothing to bow, so no world hold")
T.eq(#pushed, 2, "the farewell follows immediately")
farewell = pushed[2] or {}
T.eq(farewell.text, BYE, "npc-less caller still closes with the farewell")
if farewell.onDone then farewell.onDone() end
T.eq(finished, 1, "npc-less caller returns control once")
T.finish("nurse_bow_bug995")
@@ -0,0 +1,149 @@
-- Oak's aide quotes the REQUIREMENT, not your current count (#1006).
-- pokered engine/events/oaks_aide.asm .notEnoughOwnedMons.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
-- the ROM-extracted strings the fixture text table does not carry; labels
-- and wording match pokered data/text/text_1.asm
Data.text._OaksAideHiText =
"Hi! Remember me?\nI'm PROF.OAK's\vAIDE!\fIf you caught " ..
"{NUM:hOaksAideRequirement, 1, 3}\nkinds of POKéMON,\vI'm supposed to\v" ..
"give you an\v{RAM:wOaksAideRewardItemName}!\fSo, {PLAYER}! Have\n" ..
"you caught at\vleast {NUM:hOaksAideRequirement, 1, 3} kinds of\vPOKéMON?"
Data.text._OaksAideUhOhText =
"Let's see...\nUh-oh! You have\vcaught only " ..
"{NUM:hOaksAideNumMonsOwned, 1, 3}\vkinds of POKéMON!\fYou need " ..
"{NUM:hOaksAideRequirement, 1, 3} kinds\nif you want the\v" ..
"{RAM:wOaksAideRewardItemName}."
Data.text._OaksAideComeBackText =
"Oh. I see.\fWhen you get {NUM:hOaksAideRequirement, 1, 3}\nkinds, come " ..
"back\vfor {RAM:wOaksAideRewardItemName}."
Data.text._OaksAideHereYouGoText =
"Great! You have\ncaught {NUM:hOaksAideNumMonsOwned, 1, 3} kinds \v" ..
"of POKéMON!\vCongratulations!\fHere you go!"
Data.text._OaksAideGotItemText =
"{PLAYER} got the\n{RAM:wOaksAideRewardItemName}!"
-- the two rewards this suite drives (Route2Gate / Route11Gate2F pass them
Data.items.HM_FLASH = { id = "HM_FLASH", index = 196, name = "HM FLASH" }
Data.items.ITEMFINDER = { id = "ITEMFINDER", index = 6, name = "ITEMFINDER" }
local SaveData = require("src.core.SaveData")
local pushed = {}
local realTextBox = package.loaded["src.render.TextBox"]
-- story4's push/ask require TextBox lazily, so a package.loaded stub is
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local story4 = dofile("data/scripts/story4.lua")
local ROUTE_11 = story4.ROUTE_11_GATE_2F.talk.TEXT_ROUTE11GATE2F_OAKS_AIDE
local ROUTE_2 = story4.ROUTE_2_GATE.talk.TEXT_ROUTE2GATE_OAKS_AIDE
T.check(type(ROUTE_11) == "function" and type(ROUTE_2) == "function",
"both aides are wired to the shared oaksAide handler")
local game = {
data = Data,
save = SaveData.newGame(),
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
local function reset(ownedCount)
game.save = SaveData.newGame()
game.save.player.name = "RED"
local owned = {}
for i = 1, ownedCount do owned["SPECIES_" .. i] = true end
game.save.pokedex = { seen = {}, owned = owned }
pushed = {}
end
local function lastText()
return tostring(pushed[#pushed] and pushed[#pushed].text)
end
local function has(fragment)
return lastText():find(fragment, 1, true) ~= nil
end
local function held(id)
return game.save.inventory[id] or 0
end
-- A press on the box that is up
local function dismiss()
local box = pushed[#pushed]
if box and box.onDone then box.onDone() end
end
-- === the aide asks for his own threshold, whatever the player owns
reset(12)
local done = false
ROUTE_11(game, {}, {}, function() done = true end)
local offer = pushed[1]
T.check(offer.opts and offer.opts.choice ~= nil,
"the aide's opener is the YesNoChoice question")
T.check(has("least 30 kinds"), "opener asks for the aide's 30 kinds")
T.check(has("give you an\vITEMFINDER"), "opener names the reward item")
T.check(not has("{NUM"), "no placeholder survives into the opener")
-- === YES with too few kinds: both decimals are filled, and differently
offer.opts.choice(true)
T.check(has("caught only 12"), "Uh-oh line reports the kinds actually owned")
T.check(has("You need 30 kinds"), "Uh-oh line then states the requirement")
T.check(not has("You need 12 kinds"),
"the requirement is not overwritten by the owned count (#1006)")
T.check(has("want the\vITEMFINDER"), "Uh-oh line still names the reward")
dismiss()
T.check(done, "the Uh-oh branch completes the talk")
T.eq(held("ITEMFINDER"), 0, "no reward below the threshold")
T.check(not game.save.flags.EVENT_GOT_ITEMFINDER,
"the aide can still be asked again")
-- === the threshold tracks the aide, not a constant: Route 2 wants 10
reset(3)
ROUTE_2(game, {}, {}, function() end)
pushed[1].opts.choice(true)
T.check(has("caught only 3"), "Route 2 Uh-oh reports 3 kinds owned")
T.check(has("You need 10 kinds"), "Route 2 states its own 10-kind threshold")
T.check(has("want the\vHM FLASH"), "Route 2 names the HM FLASH reward")
-- === NO: ComeBackText quotes the requirement, nothing is given
reset(12)
done = false
ROUTE_11(game, {}, {}, function() done = true end)
pushed[1].opts.choice(false)
T.check(has("When you get 30"), "come-back line quotes the requirement")
T.check(has("back\vfor ITEMFINDER"), "come-back line names the reward")
dismiss()
T.check(done, "declining completes the talk")
T.eq(held("ITEMFINDER"), 0, "declining gives nothing")
-- === YES at the threshold: HereYouGo carries the OWNED count, then the
reset(30)
done = false
ROUTE_11(game, {}, {}, function() done = true end)
pushed[1].opts.choice(true)
T.check(has("caught 30 kinds"), "congratulation line carries the owned count")
dismiss()
T.check(has("RED got the\nITEMFINDER!"), "the item line names player and item")
dismiss()
T.check(done, "the reward branch completes the talk")
T.eq(held("ITEMFINDER"), 1, "ITEMFINDER lands in the bag")
T.check(game.save.flags.EVENT_GOT_ITEMFINDER, "the aide's event flag is set")
-- === repeat visit: the explanation text, no second ITEMFINDER
pushed = {}
done = false
ROUTE_11(game, {}, {}, function() done = true end)
T.eq(#pushed, 1, "a served player gets exactly one box")
T.check(pushed[1].opts == nil or pushed[1].opts.choice == nil,
"the repeat line is not a question")
T.eq(held("ITEMFINDER"), 1, "no second ITEMFINDER")
if realTextBox ~= nil then
package.loaded["src.render.TextBox"] = realTextBox
else
package.loaded["src.render.TextBox"] = nil
end
T.finish("oaks_aide_requirement_bug1006")
@@ -0,0 +1,113 @@
-- Yellow's starter Pikachu gets the nickname prompt (#1013).
-- pokeyellow scripts/OaksLab.asm OaksLabPlayerReceivesPikachuScript.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local pushed = {}
local realTextBox = package.loaded["src.render.TextBox"]
-- Commands requires TextBox at load time; stub it before the first require
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local Commands = require("src.script.Commands")
local SaveData = require("src.core.SaveData")
-- === the lab scene: one PIKACHU gift row, and no skipNickname on it
local lab = dofile("data/scripts/oaks_lab_yellow.lua")
local ball = lab.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL
T.check(type(ball) == "function", "the Eevee ball builds its rows per playthrough")
local captured
local function buildScene(cellX, cellY)
captured = nil
local game = { data = Data, save = SaveData.newGame() }
game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true
local ow = {
player = { cellX = cellX, cellY = cellY },
runner = { run = function(_, rows) captured = rows end },
}
ball(game, ow, { def = {} }, function() end)
return captured or {}
end
local function indexOf(rows, verb, arg)
for i, row in ipairs(rows) do
if row[1] == verb and (arg == nil or row[2] == arg) then return i end
end
end
-- the shove branch (player on the table row, py == 4) and the plain
for _, spot in ipairs({ { 9, 4 }, { 5, 6 } }) do
local rows = buildScene(spot[1], spot[2])
local where = ("from (%d,%d)"):format(spot[1], spot[2])
local gives = 0
for _, row in ipairs(rows) do
if row[1] == "give_pokemon" then
gives = gives + 1
T.eq(row[2], "PIKACHU", "the gift species is PIKACHU " .. where)
T.eq(row[3], 5, "the gift is level 5 " .. where)
T.check(row[4] == nil,
"no skipNickname: AskName is left to run " .. where .. " (#1013)")
end
end
T.eq(gives, 1, "exactly one give_pokemon row " .. where)
local give = indexOf(rows, "give_pokemon")
local received = indexOf(rows, "show_text", "_OaksLabReceivedText")
local got = indexOf(rows, "set_flag", "EVENT_GOT_STARTER")
T.check(received and give and received < give,
"the received line prints before the mon is added " .. where)
T.check(give and got and give < got,
"AddPartyMon runs before SetEvent EVENT_GOT_STARTER " .. where)
end
-- === nothing in the Yellow lab names a Kanto starter (#1014): only a mod
local source = assert(io.open("data/scripts/oaks_lab_yellow.lua", "r"))
local text = source:read("*a")
source:close()
for _, species in ipairs({ "CHARMANDER", "SQUIRTLE", "BULBASAUR" }) do
T.check(not text:find(species, 1, true),
"the Yellow lab script never names " .. species)
end
-- === give_pokemon offers AskName with a runner and no skipNickname
local function giveThrough(skipNickname)
pushed = {}
local game = { data = Data, save = SaveData.newGame(),
stack = { push = function(_, box) pushed[#pushed + 1] = box end } }
local runner = {
yield = function() return coroutine.yield() end,
resume = function(self, ...) coroutine.resume(self.co, ...) end,
}
local ctx = { game = game, save = game.save, runner = runner }
runner.co = coroutine.create(function()
Commands.give_pokemon(ctx, "FIXMON_A", 5, skipNickname)
end)
local ok, err = coroutine.resume(runner.co)
T.check(ok, "give_pokemon runs cleanly: " .. tostring(err))
return game.save
end
local save = giveThrough(nil)
T.eq(#pushed, 1, "a plain gift puts one box up")
T.check(tostring(pushed[1].text):find("nickname", 1, true) ~= nil,
"that box is AskName's question")
T.check(pushed[1].opts and pushed[1].opts.choice ~= nil,
"AskName is a YES/NO, not a plain box")
T.eq(#save.party, 1, "the gift joined the party")
save = giveThrough(true)
T.eq(#pushed, 0, "skipNickname suppresses the prompt")
T.eq(#save.party, 1, "the gift still joined the party")
if realTextBox ~= nil then
package.loaded["src.render.TextBox"] = realTextBox
else
package.loaded["src.render.TextBox"] = nil
end
T.finish("oaks_lab_yellow_starter_bug1013")
+2 -1
View File
@@ -118,8 +118,9 @@ for _, item in ipairs(menu.items) do
if item.label == "PROF.OAK's PC" then oak = item end
end
T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts
oak.onSelect()
-- drop the menu's Turn_On_PC and the row's Enter_PC
plays = {}
T.eq(pushed[2].kind, "text", "selection opens the access text")
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
"first session box is the access text")
+121
View File
@@ -0,0 +1,121 @@
-- Parity: the Fan Club Chairman asks before telling his story (#1050).
-- pokered scripts/PokemonFanClub.asm PokemonFanClubChairmanText.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity Fan Club chairman")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local ChoiceBox = require("src.ui.ChoiceBox")
local mapScripts = require("data.scripts.init")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local MAP, TEXT = "POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CHAIRMAN"
local script = mapScripts.talkScript(MAP, TEXT)
check(type(script) == "table", "the chairman is a row list")
eq(#ScriptRunner.validate(script), 0,
"the rows validate cleanly (labels resolve after the renumbering)")
-- === the intro is the question, not a plain box ===
local asks = 0
for _, row in ipairs(type(script) == "table" and script or {}) do
if row[1] == "ask" then
asks = asks + 1
eq(row[2], "_PokemonFanClubChairmanIntroText",
"the YesNoChoice rides the intro text (#1050)")
end
end
eq(asks, 1, "exactly one question, right after the intro")
-- === harness: run the talk script headless, recording show_text ids ===
local shown = {}
local origShow = Commands.show_text
-- forward extraOpts: Commands.ask rides show_text's 4th argument
Commands.show_text = function(ctx, textId, subs, ...)
table.insert(shown, textId)
return origShow(ctx, textId, subs, ...)
end
-- pressFn returns the Input.pressed table for this frame (default: A)
local function runScript(pressFn)
shown = {}
local ow = { map = { id = MAP, def = { label = MAP } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = pressFn and pressFn() or { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local function shownIs(want, msg)
eq(table.concat(shown, ","), table.concat(want, ","), msg)
end
-- press B while the YES/NO box is up, A otherwise: the NO answer
local function declines()
if getmetatable(StateStack:top()) == ChoiceBox then return { b = true } end
return { a = true }
end
local function held(id) return Game.save.inventory[id] or 0 end
-- === 1) YES: story, voucher, received line, explanation ===
Game.save = SaveData.newGame()
check(runScript(), "chairman script completes on YES")
shownIs({ "_PokemonFanClubChairmanIntroText",
"_PokemonFanClubChairmanStoryText",
"_PokemonFanClubReceivedBikeVoucherText",
"_PokemonFanClubExplainBikeVoucherText" },
"YES hears the RAPIDASH story out and collects the voucher")
eq(held("BIKE_VOUCHER"), 1, "the BIKE VOUCHER is in the bag")
check(Flags.get(Game.save, "EVENT_RECEIVED_BIKE_VOUCHER"),
"EVENT_GOT_BIKE_VOUCHER is set")
-- === 2) NO: the brush-off, and the voucher stays with the chairman ===
Game.save = SaveData.newGame()
check(runScript(declines), "chairman script completes on NO")
shownIs({ "_PokemonFanClubChairmanIntroText", "_PokemonFanClubNoStoryText" },
"NO skips the story and the gift (#1050)")
eq(held("BIKE_VOUCHER"), 0, "declining leaves the voucher unclaimed")
check(not Flags.get(Game.save, "EVENT_RECEIVED_BIKE_VOUCHER"),
"declining leaves the event clear, so he can be asked again")
-- === 3) asking again after NO still works, and YES then pays out ===
check(runScript(), "second visit completes")
shownIs({ "_PokemonFanClubChairmanIntroText",
"_PokemonFanClubChairmanStoryText",
"_PokemonFanClubReceivedBikeVoucherText",
"_PokemonFanClubExplainBikeVoucherText" },
"a player who said NO can come back for the voucher")
eq(held("BIKE_VOUCHER"), 1, "the voucher arrives on the second visit")
-- === 4) served player: .nothingleft, no question at all ===
check(runScript(), "post-voucher script completes")
shownIs({ "_PokemonFanClubChairFinalText" },
"with the voucher collected he only reminisces")
eq(held("BIKE_VOUCHER"), 1, "no second voucher")
Commands.show_text = origShow
S.finish()
+135
View File
@@ -0,0 +1,135 @@
-- Parity: the Silph Co. 7F worker's LAPRAS gift offers the nickname prompt (#1049).
-- pokered scripts/SilphCo7F.asm SilphCo7FSilphWorkerM1Text.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity Silph LAPRAS")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local ScriptRunner = require("src.script.ScriptRunner")
local Commands = require("src.script.Commands")
local Flags = require("src.script.Flags")
local Pokemon = require("src.pokemon.Pokemon")
local Boxes = require("src.pokemon.Boxes")
local mapScripts = require("data.scripts.init")
Game.data = Data
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
require("src.render.Font").load(Data)
local MAP, TEXT = "SILPH_CO_7F", "TEXT_SILPHCO7F_SILPH_WORKER_M1"
-- === 1) the worker is command rows, so the run carries a ScriptRunner ===
local script = mapScripts.talkScript(MAP, TEXT)
check(type(script) == "table",
"the LAPRAS worker is a row list, not a bare callback (#1049)")
eq(#ScriptRunner.validate(script), 0, "the rows validate cleanly")
local gives = 0
for _, row in ipairs(type(script) == "table" and script or {}) do
if row[1] == "give_pokemon" then
gives = gives + 1
eq(row[2], "LAPRAS", "the gift species is LAPRAS")
eq(row[3], 15, "the gift is level 15 (lb bc, LAPRAS, 15)")
check(row[4] == nil, "no skipNickname: AskName is left to run")
end
end
eq(gives, 1, "exactly one give_pokemon row")
-- === harness: run the talk script headless, A every frame, recording show_text ids
local shown = {}
local origShow = Commands.show_text
-- forward extraOpts: Commands.ask rides show_text's 4th argument
Commands.show_text = function(ctx, textId, subs, ...)
table.insert(shown, textId)
return origShow(ctx, textId, subs, ...)
end
local function runScript()
shown = {}
local ow = { map = { id = MAP, def = { label = MAP } },
npcs = {}, entities = {} }
local r = ScriptRunner.new(Game, ow)
r:run(script, { npc = { def = {}, facePlayer = function() end },
overworld = ow })
local guard = 0
while r:isRunning() and guard < 3000 do
guard = guard + 1
Input.pressed = { a = true }
StateStack:update(1 / 60)
r:update()
end
Input.pressed = {}
return not r:isRunning()
end
local function shownIs(want, msg)
eq(table.concat(shown, ","), table.concat(want, ","), msg)
end
-- === 2) the gift itself: thanks, nickname prompt, GotMonText, blurb ===
Game.save = SaveData.newGame()
check(runScript(), "LAPRAS gift script completes")
shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText",
"_DoYouWantToNicknameText", "_GotMonText",
"_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
"thanks, nickname prompt, got-mon line, then the LAPRAS blurb")
eq(#Game.save.party, 1, "LAPRAS joins the party")
local lapras = Game.save.party[1] or {}
eq(lapras.species, "LAPRAS", "gift species is LAPRAS")
eq(lapras.level, 15, "LAPRAS is level 15")
eq(lapras.nickname, "AAAAAAAAAA",
"the nickname prompt reaches the NamingScreen (A-mash)")
check(Flags.get(Game.save, "EVENT_GOT_LAPRAS"), "BIT_GOT_LAPRAS is set")
check(Game.save.pokedex.owned.LAPRAS, "LAPRAS is registered owned")
-- === 3) after the gift he worries about the PRESIDENT, and only after
check(runScript(), "post-gift script completes")
shownIs({ "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" },
"before Giovanni: the worried line, and no second LAPRAS")
eq(#Game.save.party, 1, "no second LAPRAS")
Flags.set(Game.save, "EVENT_BEAT_SILPH_CO_GIOVANNI")
check(runScript(), "post-Giovanni script completes")
shownIs({ "_SilphCo7FSilphWorkerM1SavedText" },
"after Giovanni: saved at last")
-- === 4) party full, box has room: SendNewMonToBox still asks the name ===
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
check(runScript(), "full-party gift script completes")
shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText",
"_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText",
"_SilphCo7FSilphWorkerM1LaprasDescriptionText" },
"full party: nickname, sent-to-box, got-mon line, blurb")
local boxed = false
for _, box in ipairs(Boxes.ensure(Game.save)) do
for _, m in ipairs(box) do
if m.species == "LAPRAS" then boxed = true end
end
end
check(boxed, "full-party LAPRAS lands in a box")
check(Flags.get(Game.save, "EVENT_GOT_LAPRAS"), "full-party gift sets the flag")
-- === 5) party AND every box full: BoxIsFullText, no got-mon line, flag
Game.save = SaveData.newGame()
for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end
Boxes.ensure(Game.save)
for b = 1, Boxes.COUNT do
for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end
end
check(runScript(), "full-everything script completes")
shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText", "_BoxIsFullText" },
"no room: the box-full line, never a got-mon line for a mon you lack")
check(not Flags.get(Game.save, "EVENT_GOT_LAPRAS"),
"a failed give leaves BIT_GOT_LAPRAS clear, so the gift stays claimable")
Commands.show_text = origShow
S.finish()