Closes #919, closes #982, closes #1003, closes #1012, closes #1022, closes #1028, closes #1033

This commit is contained in:
bryanthaboi
2026-08-14 10:05:51 -04:00
parent 2010ba71a6
commit a94fecfec8
48 changed files with 1263 additions and 541 deletions
@@ -191,12 +191,12 @@ return function(game)
return cond()
end
-- Jump the flash clock to just under FLASH_FRAMES (220) once the shot is
-- Jump the flash clock to just under FLASH_FRAMES (368) once the shot is
-- taken. EvolutionState:update only compares self.t against that constant,
-- so this ends the animation early without touching the palette logic.
local function skipToEnd()
local st = evoTop()
if st then st.t = 214 end
if st then st.t = 362 end
end
local function startEvo(species, into)
@@ -212,7 +212,7 @@ return function(game)
-- ---- case 1: the reported case, SGB, mid-flash --------------------------
local weedle = startEvo("WEEDLE", "KAKUNA")
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 220
U.wait(24) -- into the flash, far short of FLASH_FRAMES = 368
U.shot(game, DIR .. "/evo279_1_flash_sgb.png")
local c1 = probe("flash/SGB", {
monYellow1 = YELLOW[2], monYellow2 = YELLOW[3],
@@ -6,7 +6,7 @@
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
--
-- Case 1 (level path, cancelable): open EvolutionState directly, wait past
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220),
-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=368),
-- 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;
@@ -81,7 +81,7 @@ 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(100) -- past the 80-frame pre-animLoop delay, still under 220
U.wait(100) -- past the 80-frame pre-animLoop delay, still under 368
U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png")
@@ -133,8 +133,8 @@ return function(game)
local done2 = false
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
-- let the full flash run (FLASH_FRAMES=220) without pressing B
waitFor(function() return not evoTop() end, 400)
-- let the full flash run (FLASH_FRAMES=368) without pressing B
waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
+3 -3
View File
@@ -89,8 +89,8 @@ return function(game)
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
waitFor(function() return not evoTop() end, 400)
-- let the full flash run (FLASH_FRAMES=368) with no input, then apply
waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case1)")
end
@@ -124,7 +124,7 @@ return function(game)
local done2 = false
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
waitFor(function() return not evoTop() end, 400)
waitFor(function() return not evoTop() end, 500)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
@@ -0,0 +1,188 @@
-- Driver: a trainer who walked up to the player must be back on her spawn
-- cell the next time her map loads, connection crossings included (#1028).
-- .loadNewMap calls LoadMapHeader for a seam crossing exactly as a warp does,
-- and .loadSpriteData zeroes the sprite state data and re-seeds every
-- SPRITESTATEDATA2_MAPY/MAPX from the map header's object data
-- (pokered home/overworld.asm), so only the save-side defeat flag survives.
--
-- Route 3's Lass (data/maps/objects/Route3.asm object_event 23, 4,
-- SPRITE_COOLTRAINER_F, STAY, LEFT, ..., OPP_LASS) has sight range 4, so
-- standing above the third Bug Catcher at (19,4) is inside her line and she
-- walks dist-1 = 3 cells west to (20,4) -- the reporter's exact setup.
--
-- POKEPORT_DRIVER=tests/drivers/trainer_reset_bug1028_test.lua \
-- POKEPORT_IDENTITY=bug1028 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Zoom = require("src.render.Zoom")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local MAP = "ROUTE_3"
local TARGET = 6
local SPAWN = { x = 23, y = 4 }
local STAND = { x = 19, y = 4 }
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 lass()
local ow = game.overworld
for _, n in ipairs(ow and ow.npcs or {}) do
if n.def and n.def.index == TARGET then return n end
end
return nil
end
local function at(n) return n and (n.cellX .. "," .. n.cellY) or "absent" end
-- hold a direction until cond() or the budget runs out, then let the
-- half-finished step land
local function holdUntil(btn, cond, budget)
local first = true
for _ = 1, budget or 900 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
local ow = game.overworld
if ow and not ow.player.moving then break end
coroutine.yield()
end
U.wait(4)
return cond()
end
local function walkTo(btn, axis, want, budget)
return holdUntil(btn, function()
local p = game.overworld and game.overworld.player
return p and p[axis] == want and not p.moving
end, budget)
end
local function onMap(id)
return game.overworld and game.overworld.map and game.overworld.map.id == id
end
-- mash A until the battle stack unwinds back to the overworld
local function mashToOverworld(budget)
for _ = 1, budget or 4000 do
if game.stack:top() == game.overworld and not game.overworld.engaging then
return true
end
U.tap(game, "a")
U.wait(3)
end
return false
end
-- absolute zoom: reset first so every shot frames the same amount of world
local function survey(n)
Zoom.reset()
U.wait(2)
for _ = 1, n do game:zoomStep(-1) end
U.wait(30)
end
-- back up to the sighting cell so the before/after shots share a camera
local function returnToStand()
walkTo("right", "cellX", 11)
walkTo("up", "cellY", 4)
walkTo("right", "cellX", STAND.x)
end
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 80),
Pokemon.new(game.data, "SNORLAX", 80),
}
game.save.player.name = "MATT"
U.teleport(game, MAP, STAND.x, STAND.y, "right")
U.wait(20)
-- only the target Lass may engage: the reporter has already cleared the
-- Bug Catchers and Youngsters ahead of her
local ow = game.overworld
for _, n in ipairs(ow.npcs) do
if n.def.trainerClass and n.def.index ~= TARGET then
game.save.defeatedTrainers[n.id] = true
end
end
local l = lass()
check("Lass starts on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
U.log("spawn:", at(l))
-- she sights the player and walks up, then the battle runs
for _ = 1, 600 do
if ow.engaging then break end
coroutine.yield()
end
check("the Lass sighted the player at " .. STAND.x .. "," .. STAND.y, ow.engaging == true)
check("the battle ran to completion", mashToOverworld())
U.wait(30)
l = lass()
local movedX, movedY = l and l.cellX, l and l.cellY
U.log("after the walk-up she stands at", at(l))
check("she is off her spawn cell after walking up",
l ~= nil and not (movedX == SPAWN.x and movedY == SPAWN.y))
check("she is recorded as defeated", game.save.defeatedTrainers[l.id] == true)
survey(4)
U.shot(game, DIR .. "/bug1028_1_walked_up.png")
Zoom.reset()
U.wait(5)
-- west along row 4, down the single gap at column 11, then west on row 9
-- to the Pewter City seam
walkTo("left", "cellX", 11)
walkTo("down", "cellY", 9)
check("reached the descent column", game.overworld.player.cellX == 11
and game.overworld.player.cellY == 9)
-- crossing x=0 westward is the seam: crossConnection, not a warp
holdUntil("left", function() return onMap("PEWTER_CITY") end, 1200)
check("crossed the seam into Pewter City", onMap("PEWTER_CITY"))
survey(12)
U.shot(game, DIR .. "/bug1028_2_pewter_survey.png")
Zoom.reset()
U.wait(5)
-- and back east into Route 3, the map load that must re-seed her
holdUntil("right", function() return onMap(MAP) end, 1200)
check("crossed back into Route 3", onMap(MAP))
U.wait(30)
l = lass()
U.log("after the return crossing she stands at", at(l))
check("she is back on her spawn cell " .. SPAWN.x .. "," .. SPAWN.y,
l ~= nil and l.cellX == SPAWN.x and l.cellY == SPAWN.y)
check("she is still recorded as defeated",
l ~= nil and game.save.defeatedTrainers[l.id] == true)
-- same cell, same zoom as shot 1: she should now be three cells further
-- east, and being defeated she must not re-engage on the way back in
returnToStand()
check("walked back to the sighting cell",
game.overworld.player.cellX == STAND.x
and game.overworld.player.cellY == STAND.y)
check("a defeated trainer does not re-engage", game.overworld.engaging ~= true)
survey(4)
U.shot(game, DIR .. "/bug1028_3_reset.png")
Zoom.reset()
U.log(pass and "RESULT PASS" or "RESULT FAIL")
love.event.quit(pass and 0 or 1)
while true do coroutine.yield() end
end
@@ -20,7 +20,7 @@ local PartyMenu = require("src.ui.PartyMenu")
-- minimal stack/input doubles matching the StateStack and Input surfaces,
-- plus an overworld stub: PALLET_TOWN's OVERWORLD tileset passes
-- CheckIfInOutsideMap, and the badges cover the list-time HM gates
-- CheckIfInOutsideMap
local function newGame(moves, inventory)
local game = {
data = { pokemon = { LAPRAS = { name = "LAPRAS" },
@@ -87,14 +87,15 @@ openSubmenu(pm2)
same(actions(pm2.subItems), { "stats", "switch" },
"no field moves: the submenu is just STATS/SWITCH")
-- the badge gates still filter the list: the same Lapras without the
-- badges keeps its moves but shows none of them
-- GetMonFieldMoves is badge-blind: the same Lapras without the badges
-- still lists both HMs, and .outOfBattleMovePointers refuses on
-- selection instead (#1022)
local noBadges = newGame(
{ { id = "STRENGTH", pp = 15 }, { id = "SURF", pp = 15 } })
local pm3 = PartyMenu.new(noBadges, {})
noBadges.stack:push(pm3)
openSubmenu(pm3)
same(actions(pm3.subItems), { "stats", "switch" },
"ungated badges keep the HM moves out of the submenu")
same(actions(pm3.subItems), { "strength", "surf", "stats", "switch" },
"the HM moves are listed without the badges (#1022)")
T.finish("party_fieldmove_order_bug792")
@@ -16,6 +16,7 @@ local T = require("tests.modkit")
local boxes = {}
package.loaded["src.render.TextBox"] = {
new = function(_, s, done) return { text = s, onDone = done } end,
soundOpts = function() return {} end,
}
package.loaded["src.core.Sound"] = { play = function() end }
package.loaded["src.inventory.Bag"] = {
+7
View File
@@ -187,6 +187,13 @@ local function runOne(seed)
local function drive(side)
local bt = side.bt
if bt.result then return end
local top = side.game.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
return
end
if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then
local usable = {}
+7
View File
@@ -181,6 +181,13 @@ local function playMatch(hostEntry, guestEntry, rnd, label)
local function drive(side)
local bt = side.bt
if bt.result then return end
local top = side.game.stack:top()
if top and top.forceSwitch and top.party then
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i break end
end
return
end
if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then
local usable = {}
+1 -1
View File
@@ -604,7 +604,7 @@ do
local game = uiGame({ mon })
Bag.add(game.save, "RARE_CANDY", 1)
game.stack:push(BagMenu.new(game))
for _ = 1, 600 do
for _ = 1, 800 do
local top = game.stack:top()
if not top then break end
pressed = { a = true }
+3 -1
View File
@@ -584,7 +584,9 @@ check(not fpm.submenu and forced == fgame.save.party[1],
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
do
local owStub = { strengthActive = false,
map = { def = { tileset = "OVERWORLD" } }, dark = false }
map = { def = { tileset = "OVERWORLD" } }, dark = false,
partyKnows = function(self, id) return self.knows == id end,
knows = "STRENGTH" }
local sgame = partyGame()
sgame.overworld = owStub
sgame.data.text = {} -- the strength texts fall back to Strings sources
+10
View File
@@ -49,6 +49,14 @@ function Link.prepare(data)
return Input
end
local function steerReplacement(game)
local top = game.stack:top()
if not (top and top.forceSwitch and top.party) then return end
for i, mon in ipairs(top.party) do
if mon.hp > 0 then top.index = i return end
end
end
-- run a full lockstep battle over a loopback pair, mashing A on both
-- sides, and report whether any turn's hashes disagreed
function Link.lockstep(gameA, gameB, opts)
@@ -80,7 +88,9 @@ function Link.lockstep(gameA, gameB, opts)
while (resA == nil or resB == nil) and guard < limit do
guard = guard + 1
Input.pressed = { a = true }
steerReplacement(gameA)
gameA.stack:update(1 / 60)
steerReplacement(gameB)
gameB.stack:update(1 / 60)
end
+11 -4
View File
@@ -315,12 +315,19 @@ do
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local OW = require("src.world.OverworldController")
-- the reward text is a CHAIN of boxes split at each gym script's sound
-- command, so walk it: read a box's pages, close it, let its onDone push
-- the next one
local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
local top = Game.stack:top()
while top and top.pages do
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
Game.stack:pop()
if top.onDone then top.onDone() end
top = Game.stack:top()
end
return table.concat(parts, "\n")
end
+10 -8
View File
@@ -176,7 +176,8 @@ check(not onStack(pmSurf), "party menu closes after a successful SURF")
check(sawText("got on"), "_SurfingGotOnText shown on a successful SURF")
-- ===========================================================================
-- I: list-time badge filter, CUT/SURF/STRENGTH absent without the badge
-- I: GetMonFieldMoves is badge-blind -- CUT/SURF/STRENGTH are listed with or
-- without the badge, and .outOfBattleMovePointers refuses on selection (#1022)
-- ===========================================================================
Game.save.party = { mkMon("SQUIRTLE", "CUT", "SURF", "STRENGTH") }
Game.save.inventory = {}
@@ -185,8 +186,8 @@ local pmNoBadge = PartyMenu.new(Game)
Game.stack:push(pmNoBadge)
frame({ "a" })
local actsOff = submenuActions(pmNoBadge)
check(not actsOff.cut and not actsOff.surf and not actsOff.strength,
"no CUT/SURF/STRENGTH submenu entries without the required badges")
check(actsOff.cut and actsOff.surf and actsOff.strength,
"CUT/SURF/STRENGTH submenu entries listed without the badges (#1022)")
popToOW()
Game.save.inventory = { CASCADEBADGE = true, SOULBADGE = true, RAINBOWBADGE = true }
local pmBadge = PartyMenu.new(Game)
@@ -194,7 +195,7 @@ Game.stack:push(pmBadge)
frame({ "a" })
local actsOn = submenuActions(pmBadge)
check(actsOn.cut and actsOn.surf and actsOn.strength,
"CUT/SURF/STRENGTH submenu entries appear once the badges are held")
"CUT/SURF/STRENGTH submenu entries still there once the badges are held")
-- ===========================================================================
-- I: CUT from the party menu. The Cerulean tree BLOCK (50, at block 9,14)
@@ -471,8 +472,9 @@ local pmLobby = PartyMenu.new(Game)
Game.stack:push(pmLobby)
frame({ "a" })
local actsLobby = submenuActions(pmLobby)
check(not actsLobby.fly, "FLY omitted inside Indigo Plateau lobby")
check(not actsLobby.escape, "TELEPORT omitted inside Indigo Plateau lobby")
check(actsLobby.fly, "FLY still listed inside Indigo Plateau lobby (#1022)")
check(actsLobby.escape,
"TELEPORT still listed inside Indigo Plateau lobby (#1022)")
popToOW()
-- restore fainted field-move mon for the STRENGTH/SURF cases below
@@ -486,8 +488,8 @@ Game.save.inventory = {
ow = pushOW("SEAFOAM_ISLANDS_1F", 17, 10, "right")
clearCaptured()
local pmFaintStr = PartyMenu.new(Game)
-- Seafoam is not OVERWORLD, so FLY is omitted: CUT, STRENGTH, SURF, STATS, SWITCH
selectSubItem(pmFaintStr, 2)
-- move order on the mon: FLY, CUT, STRENGTH, SURF, then STATS, SWITCH
selectSubItem(pmFaintStr, 3)
eq(Game.overworld.strengthActive, true,
"fainted mon can activate STRENGTH from the party menu")
check(sawText("used") and sawText("STRENGTH"),
+11 -5
View File
@@ -72,13 +72,19 @@ Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
-- concatenates the pages of the TextBox checkVictoryRewards pushed
-- concatenates the pages of the box CHAIN checkVictoryRewards pushed: the
-- reward text splits into a box per gym-script sound command, so close each
-- one and let its onDone push the next
local function stackedDialogue()
local top = Game.stack:top()
if not (top and top.pages) then return "" end
local parts = {}
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
local top = Game.stack:top()
while top and top.pages do
for _, page in ipairs(top.pages) do
parts[#parts + 1] = table.concat(page, "\n")
end
Game.stack:pop()
if top.onDone then top.onDone() end
top = Game.stack:top()
end
return table.concat(parts, "\n")
end
+1
View File
@@ -178,6 +178,7 @@ check(#moves == 0, "the parked Pikachu of the confused beat stays put")
-- BillsHouseScript0 skips the whole entry beat for a statused starter
-- (CheckPikachuStatusCondition, scripts/BillsHouse.asm:45-46)
ow.pikachuBillsScene = nil
yellowGame.save.pikachuMapScriptActive = nil
moves = {}
yellowGame.save.party = { { species = "PIKACHU", hp = 12, status = "PAR" } }
PikachuFollower.onBillsHouseEnter(yellowGame, ow)
+1 -1
View File
@@ -42,7 +42,7 @@ local ow = {
Follower.onFanClubEntered({ save = save, data = Data }, ow)
check(ow.pikachuFanClubScene, "Fan Club disables normal Pikachu following")
check(ow.pikachuMapScriptActive, "Fan Club sets the map-script flag")
check(save.pikachuMapScriptActive, "Fan Club sets the map-script flag")
eq(ow.player.facing, "down", "Fan Club resets the player direction")
eq(moves[1] and moves[1][1], "up", "Fan Club starts with slide-up displacement")
eq(moves[1] and moves[1][2], 1, "Fan Club slide-up spans one tile")
+2 -2
View File
@@ -2562,8 +2562,8 @@ do
end
eq(fanfares, 1, "one caught fanfare per capture")
eq(tinks, 3, "three wobble tinks on a $43 capture")
check(fanfareAt and caughtAt and fanfareAt < caughtAt,
"Caught_Mon sounds with the caught text, not after its dismissal")
check(fanfareAt and caughtAt and caughtAt < fanfareAt,
"Caught_Mon sounds once the caught text is out, before its prompt")
eq(cb4.result, "caught", "the capture resolved the battle")
-- the nickname AskName that follows clears it (ClearSprites), so the
-- assertion is sampled while the caught text is up