mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 13:36:14 +02:00
Fix Gen 1/2 battle, menu, PC and audio parity gaps; wire luacheck into CI
CLOSES #1484, CLOSES #1486, CLOSES #1513, CLOSES #1517, CLOSES #1556, CLOSES #1564, CLOSES #1567
This commit is contained in:
@@ -83,15 +83,36 @@ return function(game)
|
||||
-- the announcement-time row performMove inserts directly as well as the
|
||||
-- ones animNext adds, which is the whole difference the fix makes.
|
||||
local function watchAnims(battle)
|
||||
local seen, mark = {}, {}
|
||||
local seen, mark, types = {}, {}, {}
|
||||
return seen, function()
|
||||
for _, row in ipairs(battle.queue) do
|
||||
if row.anim and not mark[row] then
|
||||
mark[row] = true
|
||||
seen[#seen + 1] = row.anim
|
||||
types[row.anim] = row.hit and row.hit.animType or false
|
||||
end
|
||||
end
|
||||
end, types
|
||||
end
|
||||
|
||||
-- engine/battle/animations.asm:427-437
|
||||
local function rideAndSample(battle, poll, frames, shotPath)
|
||||
local peak, shot = 0, false
|
||||
for i = 1, frames do
|
||||
poll()
|
||||
if i % 6 == 0 then U.tap(battle.game, "a") end
|
||||
local fx = battle.fx
|
||||
if fx and fx.shakeX and not battle.animPlaying then
|
||||
local dx = math.abs(fx.shakeX)
|
||||
if dx > peak then peak = dx end
|
||||
if dx > 0 and not shot then
|
||||
shot = true
|
||||
U.shot(battle.game, shotPath)
|
||||
end
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return peak
|
||||
end
|
||||
|
||||
local function listOf(seen)
|
||||
@@ -136,23 +157,28 @@ return function(game)
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 8)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
local seen, poll = watchAnims(battle)
|
||||
local seen, poll, types = watchAnims(battle)
|
||||
check("the battle reached the menu", waitPhase(battle, "menu", 60, poll))
|
||||
check("BIDE is the move on the list", pickBide(battle, poll))
|
||||
for _ = 1, 20 do poll() U.wait(1) end
|
||||
U.shot(game, DIR .. "/bug375_store_spiral.png")
|
||||
for _ = 1, 30 do poll() U.wait(1) end
|
||||
|
||||
local peak = rideAndSample(battle, poll, 180,
|
||||
DIR .. "/bug1564_store_shake.png")
|
||||
U.shot(game, DIR .. "/bug375_store_text.png")
|
||||
|
||||
check("the storing turn queued XSTATITEM_ANIM", has(seen, "XSTATITEM_ANIM"))
|
||||
check("and not BIDE's hit animation: " .. listOf(seen), not has(seen, "BIDE"))
|
||||
check("BIDE locked in for " .. tostring(battle.player.bideTurns) .. " turns",
|
||||
battle.player.bideTurns ~= nil)
|
||||
check("the storing spiral carries wAnimationType 6 (#1564)",
|
||||
types["XSTATITEM_ANIM"] == 6)
|
||||
check(("the screen shook, peak dx = %d px (want 3)"):format(peak), peak == 3)
|
||||
|
||||
-- ride out the locked turns: the storing text needs A presses and the menu
|
||||
-- never comes back until BIDE unleashes
|
||||
local released = false
|
||||
for _ = 1, 120 do
|
||||
for _ = 1, 220 do
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 6 do poll() U.wait(1) end
|
||||
if has(seen, "BIDE") then
|
||||
@@ -179,7 +205,7 @@ return function(game)
|
||||
foeBattle.enemy.mon.moves = { { id = "BIDE", pp = 10 } }
|
||||
foeBattle.enemy.curMoves = foeBattle.enemy.mon.moves
|
||||
ow:pushBattle(foeBattle)
|
||||
local foeSeen, foePoll = watchAnims(foeBattle)
|
||||
local foeSeen, foePoll, foeTypes = watchAnims(foeBattle)
|
||||
check("the foe's battle reached the menu",
|
||||
waitPhase(foeBattle, "menu", 60, foePoll))
|
||||
pickBide(foeBattle, foePoll) -- ours does not matter here, the foe's does
|
||||
@@ -192,10 +218,20 @@ return function(game)
|
||||
check("the foe's storing turn queued XSTATITEM_DUPLICATE_ANIM: "
|
||||
.. listOf(foeSeen), has(foeSeen, "XSTATITEM_DUPLICATE_ANIM"))
|
||||
|
||||
local foePeak = rideAndSample(foeBattle, foePoll, 180,
|
||||
DIR .. "/bug1564_enemy_store_shake.png")
|
||||
check("the foe's spiral carries wAnimationType 3 (#1564)",
|
||||
foeTypes["XSTATITEM_DUPLICATE_ANIM"] == 3)
|
||||
check(("the foe's shake peaked at %d px (want 6)"):format(foePeak),
|
||||
foePeak == 6)
|
||||
|
||||
U.log("The pad is yours in a battle where both sides know only BIDE, so")
|
||||
U.log("pick FIGHT then BIDE and watch turn one: the screen palette flashes")
|
||||
U.log("white and balls spiral inward, silently, and then it says storing")
|
||||
U.log("energy. No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("white and balls spiral inward, silently. Then, still in silence, the")
|
||||
U.log("whole battle screen creeps sideways 1 px at a time out to 3 px and")
|
||||
U.log("back, twice, and only then does it say storing energy. On the foe's")
|
||||
U.log("turn the same creep goes out to 6 px and takes twice as long.")
|
||||
U.log("No flash on the RATTATA and no BIDE thud until the turn it")
|
||||
U.log("unleashes, where the thud and its animation come after the text and")
|
||||
U.log("before the enemy HP bar slides down. The foe's spiral looks the same.")
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
-- scripts/Route16Gate1F.asm:38 / home/overworld.asm:1224
|
||||
-- data/maps/force_bike_surf.asm:5
|
||||
-- POKEPORT_DRIVER=tests/drivers/bike_gate_wall_bug1548_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1548 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
|
||||
-- Watch the sprite after each teleport: it must never overlap the gate
|
||||
-- building's black outline. Compare .bazinga/august21p2/media/1548-1.jpg.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(25)
|
||||
end
|
||||
U.wait(60)
|
||||
end
|
||||
local function landed()
|
||||
local ow = game.overworld
|
||||
local p = ow.player
|
||||
return p.cellX, p.cellY, ow.map:isWalkableCell(p.cellX, p.cellY)
|
||||
end
|
||||
local function bikeless()
|
||||
game.save.inventory.BICYCLE = nil
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
end
|
||||
|
||||
game.save.player.name = "PROBE"
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
|
||||
bikeless()
|
||||
|
||||
local fm = game.data.field.forcedMovement
|
||||
check("field.forcedMovement carries the Route 16 cells",
|
||||
fm and fm.tiles and fm.tiles.ROUTE_16 ~= nil)
|
||||
|
||||
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
|
||||
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
|
||||
bikeless()
|
||||
U.teleport(game, c[1], c[2], c[3], "left")
|
||||
U.wait(20)
|
||||
settle()
|
||||
local x, y, walk = landed()
|
||||
U.log(("%s (%d,%d) facing left -> (%d,%d) walkable=%s")
|
||||
:format(c[1], c[2], c[3], x, y, tostring(walk)))
|
||||
check(("%s (%d,%d): the refusal leaves the player on a walkable cell")
|
||||
:format(c[1], c[2], c[3]), walk)
|
||||
end
|
||||
|
||||
bikeless()
|
||||
U.teleport(game, "ROUTE_16", 17, 10, "left")
|
||||
U.wait(20)
|
||||
settle()
|
||||
U.shot(game, SHOT_DIR .. "/bug1548_route16_after.png")
|
||||
|
||||
bikeless()
|
||||
U.teleport(game, "ROUTE_16", 16, 10, "right")
|
||||
U.wait(20)
|
||||
U.hold(game, "right", 24)
|
||||
settle()
|
||||
local bx, by = landed()
|
||||
U.log(("stepped onto ROUTE_16 (17,10) from the west -> (%d,%d)"):format(bx, by))
|
||||
check("a shove with open ground behind it still moves one cell",
|
||||
bx == 16 and by == 10)
|
||||
|
||||
bikeless()
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.stack:push(OverworldState, "ROUTE_16", 18, 10, "left")
|
||||
game.overworld:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
|
||||
U.wait(20)
|
||||
local cx, cy, cwalk = landed()
|
||||
U.log(("boot inside the wall (18,10) -> (%d,%d) walkable=%s")
|
||||
:format(cx, cy, tostring(cwalk)))
|
||||
check("a save parked inside the gate wall is lifted out on load", cwalk)
|
||||
settle()
|
||||
local dx, dy, dwalk = landed()
|
||||
U.log(("after the refusal that follows -> (%d,%d) walkable=%s")
|
||||
:format(dx, dy, tostring(dwalk)))
|
||||
check("and the refusal it lands on cannot push it back in", dwalk)
|
||||
U.shot(game, SHOT_DIR .. "/bug1548_repaired.png")
|
||||
|
||||
game.save.inventory.BICYCLE = 1
|
||||
game.save.onBike, game.save.forcedBike = false, nil
|
||||
U.teleport(game, "ROUTE_16", 17, 10, "left")
|
||||
U.wait(30)
|
||||
local ex, ey, ewalk = landed()
|
||||
check("with a BICYCLE the forced tile mounts instead of shoving",
|
||||
game.save.onBike == true and ex == 17 and ey == 10 and ewalk)
|
||||
|
||||
U.log(ok and "all clear" or "a check failed")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Driver: #1517's other half, the error handler that hid the crash.
|
||||
--
|
||||
-- boot.lua's deferErrhand picks `love.errorhandler or love.errhand`, so a
|
||||
-- love.errorhandler that returns nil ends love.run's `while func do` loop and
|
||||
-- the process leaves to the OS with no error screen and nothing on stdout.
|
||||
-- LOVE 11.5 pre-populates love.errhand only, so main.lua capturing
|
||||
-- love.errorhandler captured nil and every Lua error in every shipped build
|
||||
-- exited silently. Measured on the installed 11.5:
|
||||
-- love.errorhandler type: nil / love.errhand type: function
|
||||
--
|
||||
-- Nothing here can be a unit test: the deliverable is whether a human sees
|
||||
-- LOVE's blue error screen or an app that vanishes.
|
||||
--
|
||||
-- POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/errorhandler_probe_bug1517_test.lua love .
|
||||
--
|
||||
-- Pre-fix: the window closes on the first drawn frame after the probe arms,
|
||||
-- exit 1, no output. Post-fix: the blue screen reads "bug1517: error-handler
|
||||
-- probe" plus the lua-error.log hint, and stays up until it is dismissed.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
U.log("[1517] love.errorhandler at boot was: "
|
||||
.. (rawget(love, "errorhandler") and "a function" or "nil"))
|
||||
U.log("[1517] love.errhand is: " .. type(rawget(love, "errhand")))
|
||||
|
||||
U.wait(30)
|
||||
|
||||
-- Raise from a draw callback, not from this coroutine: main.lua resumes the
|
||||
-- driver under coroutine.resume and prints its errors itself, which is the
|
||||
-- one path that does NOT reach love.errorhandler. StateStack:draw only
|
||||
-- calls the states it is actually holding, so the probe goes on the live
|
||||
-- top state rather than on game.overworld, which is not on the stack while
|
||||
-- the title screen or the load menu is up.
|
||||
local target = game.stack:top()
|
||||
U.log("[1517] arming the probe on " .. tostring(target and target.screenId))
|
||||
local realDraw = target.draw
|
||||
target.draw = function(self, ...)
|
||||
if realDraw then realDraw(self, ...) end
|
||||
error("bug1517: error-handler probe")
|
||||
end
|
||||
|
||||
U.log("[1517] armed; the next drawn frame raises from love.draw")
|
||||
U.log("[1517] you should now see LOVE's blue error screen, not a closed app")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -70,15 +70,18 @@ return function(game)
|
||||
U.tap(game, "right")
|
||||
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
|
||||
|
||||
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
|
||||
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
|
||||
-- SELECT armed the row here for #1427; since #1567 it is filtered out of the
|
||||
-- TM/HM pocket entirely (engine/items/tmhm.asm:207), so these taps must leave
|
||||
-- the list exactly as shot 03 has it: no hollow ▷, no "Where should this be
|
||||
-- moved to?", no reorder.
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("04-tmhm-armed")
|
||||
shot("04-tmhm-select-ignored")
|
||||
U.tap(game, "up")
|
||||
shot("05-tmhm-destination")
|
||||
shot("05-tmhm-still-numbered")
|
||||
U.tap(game, "a")
|
||||
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
|
||||
U.tap(game, "b")
|
||||
shot("06-tmhm-unchanged")
|
||||
|
||||
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Assertion driver: a KEY ITEM and an HM through the player's item PC.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1486_test.lua \
|
||||
-- perl -e 'alarm 300; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
--
|
||||
-- Issue #1486. What the unit tier cannot show: the two screens a player
|
||||
-- actually looks at. .DepositItem (engine/events/pokecenter_pc.asm:504) reads
|
||||
-- CANT_TOSS only to force x1 and skip .AskQuantity, and PlaceMenuItemQuantity
|
||||
-- (engine/menus/menu_2.asm:18) draws no ×NN under such a row.
|
||||
--
|
||||
-- Shots land in /tmp/gold-bug1486:
|
||||
--
|
||||
-- 01-key-items-pocket.png the DEPOSIT chooser on the KEY ITEMS pocket
|
||||
-- 02-deposited.png "Deposited 1 BICYCLE(S)." -- before the fix this
|
||||
-- is the unchanged pocket with no message at all
|
||||
-- 03-pc-list.png the PC list: POTION with its ×03, BICYCLE and
|
||||
-- HM01 with no ×NN at all
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1486"
|
||||
local fails = 0
|
||||
|
||||
local function ok(cond, msg)
|
||||
if cond then print("[1486] ok " .. msg)
|
||||
else fails = fails + 1 print("[1486] FAIL " .. msg) end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 4)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save = game.save
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
save.party = { Mon.new(game.data, "CYNDAQUIL", 10) }
|
||||
save.inventory = {}
|
||||
save.pcItems = { POTION = 3 }
|
||||
assert(Bag.add(save, "BICYCLE", 1, game.data), "BICYCLE into the bag")
|
||||
assert(Bag.add(save, "HM_CUT", 1, game.data), "HM01 into the bag")
|
||||
|
||||
local function topId()
|
||||
local top = game.stack:top()
|
||||
return top and top.screenId or nil
|
||||
end
|
||||
|
||||
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", 4, 4, "up"),
|
||||
"setMap CHERRYGROVE_POKECENTER_1F failed")
|
||||
U.wait(5)
|
||||
local pcX, pcY
|
||||
for cy = 0, w.map.heightCells - 1 do
|
||||
for cx = 0, w.map.widthCells - 1 do
|
||||
if w.map:cellCollision(cx, cy) == 0x93 then pcX, pcY = cx, cy end
|
||||
end
|
||||
end
|
||||
assert(pcX, "no COLL_PC tile in the Pokecenter")
|
||||
assert(w:setMap("CHERRYGROVE_POKECENTER_1F", pcX, pcY + 1, "up"),
|
||||
"setMap onto the PC tile failed")
|
||||
U.wait(5)
|
||||
|
||||
tap("a", 8) -- PCScript
|
||||
tap("a", 4) -- the turn-on line
|
||||
tap("down", 4)
|
||||
tap("a", 4) -- <PLAYER>'s PC
|
||||
tap("a", 4)
|
||||
tap("a", 6) -- both PokecenterPlayersPCText pages
|
||||
ok(topId() == "Gen2ItemPcMenu",
|
||||
"<PLAYER>'s PC opens the item PC (top: " .. tostring(topId()) .. ")")
|
||||
|
||||
-- DEPOSIT ITEM -> the PACK chooser -> the KEY ITEMS pocket.
|
||||
tap("down", 4)
|
||||
tap("a", 6)
|
||||
tap("right", 4) -- ITEM -> BALL
|
||||
tap("right", 4) -- BALL -> KEY ITEMS
|
||||
U.shot(game, out .. "/01-key-items-pocket.png")
|
||||
tap("a", 6) -- the BICYCLE row: x1, no prompt
|
||||
ok(save.pcItems.BICYCLE == 1,
|
||||
"the BICYCLE landed in the PC (" .. tostring(save.pcItems.BICYCLE) .. ")")
|
||||
ok(save.inventory.BICYCLE == nil,
|
||||
"and left the bag (" .. tostring(save.inventory.BICYCLE) .. ")")
|
||||
U.shot(game, out .. "/02-deposited.png")
|
||||
tap("a", 4) -- the Deposited line
|
||||
|
||||
-- The same, one pocket over: an HM.
|
||||
tap("right", 4) -- KEY ITEMS -> TM/HM
|
||||
tap("a", 6)
|
||||
ok(save.pcItems.HM_CUT == 1,
|
||||
"HM01 landed in the PC (" .. tostring(save.pcItems.HM_CUT) .. ")")
|
||||
ok(save.inventory.HM_CUT == nil,
|
||||
"and left the bag (" .. tostring(save.inventory.HM_CUT) .. ")")
|
||||
tap("a", 4)
|
||||
tap("b", 6) -- close the PACK
|
||||
|
||||
-- WITHDRAW ITEM: the list is where PlaceMenuItemQuantity shows.
|
||||
tap("up", 4)
|
||||
tap("a", 6)
|
||||
U.shot(game, out .. "/03-pc-list.png")
|
||||
tap("a", 6) -- the first row, x1 or the selector
|
||||
print(("[1486] %d failures"):format(fails))
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
-- #1556, the two battle jingles: _NewDexDataText's sound_slot_machine_start
|
||||
-- (data/text/common_3.asm:285) on a first catch, and _LearnedMoveText's
|
||||
-- sound_dex_fanfare_50_79 (data/text/common_3.asm:119) on a level-up move.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_battle.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-battle (default)
|
||||
--
|
||||
-- Both ride the message queue's `sfx` + `waitSfx` keys, which the "Gotcha!"
|
||||
-- line and the "grew to level" line already used -- the two lines next door
|
||||
-- to them did not. So the ear is comparing neighbours: the catch jingle must
|
||||
-- be followed by a SECOND, different one over the #DEX line, and the level-up
|
||||
-- fanfare by a second over "learned".
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-battle"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local function battleScreen()
|
||||
local top = game.stack:top()
|
||||
return (top and top.battle) and top or nil
|
||||
end
|
||||
|
||||
local function tapUntil(predicate, tries, btn)
|
||||
for _ = 1, tries or 400 do
|
||||
if predicate() then return true end
|
||||
U.tap(game, btn or "a")
|
||||
U.wait(2)
|
||||
end
|
||||
return predicate()
|
||||
end
|
||||
|
||||
-- ---- 1. a first catch: "Gotcha!" then the #DEX line ---------------------
|
||||
save.party = { Mon.new(data, "CYNDAQUIL", 20) }
|
||||
save.inventory = { MASTER_BALL = 1 }
|
||||
save.pokedexReceived = true
|
||||
save.pokedex = { seen = {}, caught = {} }
|
||||
|
||||
local wild = Mon.new(data, "PIDGEY", 3)
|
||||
assert(wild, "the cache carries no PIDGEY")
|
||||
assert(world:startBattle({ wild = wild }), "the wild battle refused to start")
|
||||
assert(tapUntil(function()
|
||||
local screen = battleScreen()
|
||||
return screen ~= nil and screen.phase == "menu"
|
||||
end), "the battle never reached BattleMenu")
|
||||
local screen = battleScreen()
|
||||
|
||||
-- BattleMenuHeader's 2x2 grid: 1 FIGHT / 2 PkMn, 3 PACK / 4 RUN.
|
||||
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
|
||||
if screen.menuIndex <= 2 then U.tap(game, "down") U.wait(3) end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
local pack = game.stack:top()
|
||||
assert(pack and pack.rows, "the battle PACK did not open")
|
||||
local ballRow
|
||||
for index, row in ipairs(pack.rows) do
|
||||
if row.id == "MASTER_BALL" then ballRow = index end
|
||||
end
|
||||
assert(ballRow, "the battle PACK does not show the MASTER BALL")
|
||||
for _ = 2, ballRow do U.tap(game, "down") U.wait(2) end
|
||||
reset()
|
||||
U.tap(game, "a")
|
||||
U.wait(180) -- the throw, the wobbles, "Gotcha!"
|
||||
report("01 catch:",
|
||||
"Sfx_CaughtMon, then Sfx_SlotMachineStart over the #DEX line")
|
||||
U.shot(game, out .. "/01-caught.png")
|
||||
for _ = 1, 6 do U.tap(game, "a") U.wait(20) end
|
||||
U.shot(game, out .. "/02-dex-line.png")
|
||||
for _ = 1, 30 do
|
||||
if not battleScreen() then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
-- ---- 2. a level-up that teaches a move ---------------------------------
|
||||
local SPECIES = "CYNDAQUIL"
|
||||
local def = data.pokemon and data.pokemon[SPECIES]
|
||||
local target
|
||||
for _, entry in ipairs((def and def.levelMoves) or {}) do
|
||||
if entry.level and entry.level > 6 and not target then target = entry.level end
|
||||
end
|
||||
if not target then
|
||||
U.log("SKIP 02 -- no level-up move row for " .. SPECIES)
|
||||
else
|
||||
local hero = Mon.new(data, SPECIES, target - 1)
|
||||
local growth = Mon.growthFor(data, def.growthRate)
|
||||
hero.experience = Mon.experienceForLevel(growth, target) - 1
|
||||
-- Only three moves, so LearnMove takes the free slot rather than ForgetMove
|
||||
-- (engine/pokemon/learn.asm:23-29).
|
||||
while hero.moves and #hero.moves > 3 do table.remove(hero.moves) end
|
||||
save.party = { hero }
|
||||
save.inventory = {}
|
||||
U.log(("levelling %s %d -> %d for its %s row")
|
||||
:format(SPECIES, hero.level, target, tostring(target)))
|
||||
|
||||
local prey = Mon.new(data, "PIDGEY", 2)
|
||||
prey.hp = 1
|
||||
assert(world:startBattle({ wild = prey }), "the second battle refused")
|
||||
assert(tapUntil(function()
|
||||
local s = battleScreen()
|
||||
return s ~= nil and s.phase == "menu"
|
||||
end), "the second battle never reached BattleMenu")
|
||||
screen = battleScreen()
|
||||
if screen.menuIndex % 2 == 0 then U.tap(game, "left") U.wait(3) end
|
||||
if screen.menuIndex > 2 then U.tap(game, "up") U.wait(3) end
|
||||
reset()
|
||||
U.tap(game, "a") -- FIGHT
|
||||
U.wait(6)
|
||||
U.tap(game, "a") -- the first move
|
||||
U.wait(240)
|
||||
report("02 level-up move:",
|
||||
"Sfx_DexFanfare5079 twice: \"grew to level\" and \"learned\"")
|
||||
U.shot(game, out .. "/03-level-up.png")
|
||||
for _ = 1, 8 do U.tap(game, "a") U.wait(30) end
|
||||
U.shot(game, out .. "/04-learned.png")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("done -- the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,196 @@
|
||||
-- #1556: Bill's PC -- the deposit / withdraw / release cry, and the six
|
||||
-- SFX_WRONG refusals (engine/pokemon/bills_pc.asm).
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_billspc.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-billspc (default)
|
||||
--
|
||||
-- Eight moments, each seeded, driven through the real screen and screenshotted.
|
||||
-- The log names what sounded; the ear is the point. DepositPokemon and
|
||||
-- TryWithdrawPokemon both `call PlayMonCry` right after RemoveMonFromPartyOrBox
|
||||
-- (:1762 and :1817), and PlayMonCry itself bails on an EGG
|
||||
-- (home/pokemon.asm:113), so a boxed egg must move in silence.
|
||||
--
|
||||
-- The run ends with the PC screen open so a human takes the controls there.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Boxes = require("src.core.gen2.Boxes")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-billspc"
|
||||
|
||||
local heard = {}
|
||||
local realPlay, realCry = Sound.play, Sound.playCry
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
Sound.playCry = function(data, species, clip)
|
||||
heard[#heard + 1] = "cry:" .. tostring(species)
|
||||
return realCry(data, species, clip)
|
||||
end
|
||||
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save = game.save
|
||||
local data = game.data
|
||||
|
||||
local function mon(species, level)
|
||||
return Mon.new(data, species, level)
|
||||
end
|
||||
|
||||
local function openBox(mode)
|
||||
Screens.push(game, "Gen2BoxMenu", {
|
||||
save = save, mode = mode,
|
||||
onClose = function() game.stack:pop() end,
|
||||
})
|
||||
U.wait(8)
|
||||
return game.stack:top()
|
||||
end
|
||||
|
||||
local function close()
|
||||
while game.stack:top() and game.stack:top().submenuRows do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(4)
|
||||
end
|
||||
|
||||
local function fillBox(index, n, species)
|
||||
local box = Boxes.box(save, index)
|
||||
for i = #box, 1, -1 do box[i] = nil end
|
||||
for _ = 1, n do box[#box + 1] = mon(species or "RATTATA", 5) end
|
||||
end
|
||||
|
||||
local function setParty(n, species)
|
||||
save.party = {}
|
||||
for _ = 1, n do save.party[#save.party + 1] = mon(species or "CYNDAQUIL", 10) end
|
||||
end
|
||||
|
||||
save.currentBox = 1
|
||||
|
||||
-- ---- 1. WITHDRAW a mon: its cry ------------------------------------------
|
||||
setParty(1)
|
||||
fillBox(1, 1, "PIDGEY")
|
||||
local menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("a") -- the submenu
|
||||
tap("a", 12) -- WITHDRAW
|
||||
report("01 withdraw:", "cry:PIDGEY")
|
||||
U.shot(game, out .. "/01-withdraw-cry.png")
|
||||
tap("a") -- clear anything on screen
|
||||
close()
|
||||
|
||||
-- ---- 2. DEPOSIT it back: the cry again -----------------------------------
|
||||
setParty(3)
|
||||
save.party[#save.party + 1] = mon("PIDGEY", 5)
|
||||
fillBox(1, 0)
|
||||
menu = openBox("deposit")
|
||||
for _ = 1, 3 do tap("down") end
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12) -- DEPOSIT
|
||||
report("02 deposit:", "cry:PIDGEY")
|
||||
U.shot(game, out .. "/02-deposit-cry.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 3. WITHDRAW into a full party: SFX_WRONG (:1845) --------------------
|
||||
setParty(Boxes.PARTY_SIZE)
|
||||
fillBox(1, 1, "PIDGEY")
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("03 withdraw, party full:", "Sfx_Wrong + \"You can't take any more\"")
|
||||
U.shot(game, out .. "/03-party-full.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 4. DEPOSIT into a full box: SFX_WRONG (:1790) ----------------------
|
||||
setParty(3)
|
||||
fillBox(1, Boxes.MONS_PER_BOX)
|
||||
menu = openBox("deposit")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("04 deposit, box full:", "Sfx_Wrong + \"The BOX is full.\"")
|
||||
U.shot(game, out .. "/04-box-full.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 5. DEPOSIT the last healthy mon: SFX_WRONG -------------------------
|
||||
setParty(2)
|
||||
save.party[2].hp = 0
|
||||
fillBox(1, 0)
|
||||
menu = openBox("deposit")
|
||||
reset()
|
||||
tap("a")
|
||||
tap("a", 12)
|
||||
report("05 deposit, last healthy:", "Sfx_Wrong + \"can't deposit the last\"")
|
||||
U.shot(game, out .. "/05-last-mon.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 6. RELEASE a boxed mon: its cry (:1866) ----------------------------
|
||||
setParty(3)
|
||||
fillBox(1, 1, "SENTRET")
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("select", 8) -- RELEASE asks first
|
||||
tap("up") -- the YES/NO box defaults to NO
|
||||
tap("a", 14)
|
||||
report("06 release:", "cry:SENTRET before \"was released.\"")
|
||||
U.shot(game, out .. "/06-release-cry.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 7. RELEASE an EGG: SFX_WRONG, no cry (:1625) -----------------------
|
||||
setParty(3)
|
||||
fillBox(1, 0)
|
||||
local egg = mon("TOGEPI", 5)
|
||||
egg.isEgg = true
|
||||
Boxes.box(save, 1)[1] = egg
|
||||
menu = openBox("withdraw")
|
||||
reset()
|
||||
tap("select", 10)
|
||||
report("07 release an EGG:", "Sfx_Wrong, and NO cry:TOGEPI")
|
||||
U.shot(game, out .. "/07-release-egg.png")
|
||||
tap("a")
|
||||
close()
|
||||
|
||||
-- ---- 8. MOVE into a full box: SFX_WRONG (:1567) -------------------------
|
||||
setParty(3)
|
||||
fillBox(1, 1, "HOOTHOOT")
|
||||
fillBox(2, Boxes.MONS_PER_BOX)
|
||||
menu = openBox("move")
|
||||
reset()
|
||||
tap("a") -- the MOVE/STATS/CANCEL submenu
|
||||
tap("a", 8) -- MOVE -> the insert cursor
|
||||
tap("right", 8) -- box 2, which is full
|
||||
tap("a", 10)
|
||||
report("08 move into a full box:", "Sfx_Wrong + \"There's no room!\"")
|
||||
U.shot(game, out .. "/08-no-room.png")
|
||||
|
||||
Sound.play, Sound.playCry = realPlay, realCry
|
||||
U.log("done -- the PC is open; the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,203 @@
|
||||
-- #1556: the bump sound, which the Gen 2 port never wired.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_bump.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-bump (default)
|
||||
--
|
||||
-- Nothing here can be asserted by a unit test: the deliverable is the sound.
|
||||
-- What the log gives a reader is the RATE, which is the half that is easy to
|
||||
-- get wrong -- .BumpSound is `call CheckSFX / ret c`
|
||||
-- (engine/overworld/player_movement.asm:771), a busy-channel test rather than
|
||||
-- a frame counter, so a held direction re-rings only once the previous Sfx_Bump
|
||||
-- has stopped. A per-frame count means Sound.sfxBusy() is not seeing it.
|
||||
--
|
||||
-- Five moments, in the cart's own terms:
|
||||
-- 1. a wall .CheckLandPerms carry -> .bump (:264-265)
|
||||
-- 2. an NPC .CheckNPC = 0 -> .bump (:267-269)
|
||||
-- 3. a ledge hop .TryJump's carry returns ABOVE .NotMoving (:80-81),
|
||||
-- so the hop is Sfx_JumpOverLedge and NO bump
|
||||
-- 4. a map connection the step is taken; silence
|
||||
-- 5. a map edge with no connection behind it: the border block is a wall
|
||||
--
|
||||
-- The run ends in the overworld so a human takes the controls where it stops.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Permissions = require("src.world.gen2.Permissions")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-bump"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
|
||||
local function reset() heard = {} end
|
||||
|
||||
local function report(label)
|
||||
local counts, order = {}, {}
|
||||
for _, name in ipairs(heard) do
|
||||
if not counts[name] then order[#order + 1] = name end
|
||||
counts[name] = (counts[name] or 0) + 1
|
||||
end
|
||||
local parts = {}
|
||||
for _, name in ipairs(order) do
|
||||
parts[#parts + 1] = ("%s x%d"):format(name, counts[name])
|
||||
end
|
||||
U.log(label, #parts > 0 and table.concat(parts, ", ") or "(silence)")
|
||||
return counts
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
|
||||
local function walkable(map, x, y)
|
||||
return map:inBounds(x, y) and map:isWalkable(x, y)
|
||||
end
|
||||
|
||||
local DELTA = { up = { 0, -1 }, down = { 0, 1 },
|
||||
left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
-- A standing cell whose neighbour in `dir` is a wall.
|
||||
local function findWall(map)
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
if walkable(map, x, y) then
|
||||
for dir, d in pairs(DELTA) do
|
||||
local nx, ny = x + d[1], y + d[2]
|
||||
if map:inBounds(nx, ny) and not map:isWalkable(nx, ny) then
|
||||
return x, y, dir
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 1. a wall -----------------------------------------------------------
|
||||
assert(w:setMap("PLAYERS_HOUSE_1F", 4, 4, "down"), "setMap PLAYERS_HOUSE_1F")
|
||||
U.wait(5)
|
||||
local wx, wy, wdir = findWall(w.map)
|
||||
if wx then
|
||||
assert(w:setMap("PLAYERS_HOUSE_1F", wx, wy, wdir))
|
||||
U.wait(8)
|
||||
reset()
|
||||
U.hold(game, wdir, 90)
|
||||
U.wait(5)
|
||||
local counts = report("01 wall (" .. wdir .. ", 90 frames held):")
|
||||
U.log(" Sfx_Bump over 90 held frames:", counts.Sfx_Bump or 0,
|
||||
"-- a per-frame count here means the CheckSFX gate is not working")
|
||||
U.shot(game, out .. "/01-wall.png")
|
||||
else
|
||||
U.log("SKIP 01 wall -- no wall cell found in PLAYERS_HOUSE_1F")
|
||||
end
|
||||
|
||||
-- ---- 2. an NPC -----------------------------------------------------------
|
||||
local npc, npcDir
|
||||
for _, e in ipairs(w.entities or {}) do
|
||||
if e ~= w.player and e.cellX and not e.bigObject then
|
||||
for dir, d in pairs(DELTA) do
|
||||
local sx, sy = e.cellX - d[1], e.cellY - d[2]
|
||||
if walkable(w.map, sx, sy) then npc, npcDir = { sx, sy }, dir break end
|
||||
end
|
||||
end
|
||||
if npc then break end
|
||||
end
|
||||
if npc then
|
||||
assert(w:setMap("PLAYERS_HOUSE_1F", npc[1], npc[2], npcDir))
|
||||
U.wait(8)
|
||||
reset()
|
||||
U.hold(game, npcDir, 45)
|
||||
U.wait(5)
|
||||
report("02 NPC (" .. npcDir .. "):")
|
||||
U.shot(game, out .. "/02-npc.png")
|
||||
else
|
||||
U.log("SKIP 02 NPC -- no reachable NPC in PLAYERS_HOUSE_1F")
|
||||
end
|
||||
|
||||
-- ---- 3. a ledge ----------------------------------------------------------
|
||||
local ROUTES = { "ROUTE_29", "ROUTE_30", "ROUTE_31", "ROUTE_32",
|
||||
"ROUTE_33", "ROUTE_34", "ROUTE_35", "ROUTE_36" }
|
||||
local hopped = false
|
||||
for _, mapId in ipairs(ROUTES) do
|
||||
if w:setMap(mapId, 5, 5, "down") then
|
||||
U.wait(5)
|
||||
local map = w.map
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
local facings = Permissions.ledgeFacings(map:cellCollision(x, y))
|
||||
if facings then
|
||||
for dir, on in pairs(facings) do
|
||||
local d = on and DELTA[dir]
|
||||
if d and walkable(map, x + d[1] * 2, y + d[2] * 2) then
|
||||
assert(w:setMap(mapId, x, y, dir))
|
||||
U.wait(8)
|
||||
reset()
|
||||
U.hold(game, dir, 40)
|
||||
U.wait(20)
|
||||
local counts = report(
|
||||
("03 ledge hop (%s %s):"):format(mapId, dir))
|
||||
U.log(" want Sfx_JumpOverLedge and NO Sfx_Bump; bumps:",
|
||||
counts.Sfx_Bump or 0)
|
||||
U.shot(game, out .. "/03-ledge.png")
|
||||
hopped = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
end
|
||||
if hopped then break end
|
||||
end
|
||||
if not hopped then U.log("SKIP 03 ledge -- no hoppable ledge found") end
|
||||
|
||||
-- ---- 4 and 5. map edges --------------------------------------------------
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"), "setMap NEW_BARK_TOWN")
|
||||
U.wait(5)
|
||||
local DIR_CONN = { up = "north", down = "south", left = "west",
|
||||
right = "east" }
|
||||
local map = w.map
|
||||
local function edgeCell(dir)
|
||||
local d = DELTA[dir]
|
||||
for y = 0, map.heightCells - 1 do
|
||||
for x = 0, map.widthCells - 1 do
|
||||
if walkable(map, x, y)
|
||||
and not map:inBounds(x + d[1], y + d[2]) then
|
||||
return x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, dir in ipairs({ "up", "down", "left", "right" }) do
|
||||
local conn = map:connection(DIR_CONN[dir])
|
||||
local ex, ey = edgeCell(dir)
|
||||
if ex then
|
||||
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
|
||||
U.wait(8)
|
||||
reset()
|
||||
U.hold(game, dir, 40)
|
||||
U.wait(20)
|
||||
local counts = report(("%s edge %s (connection: %s):")
|
||||
:format(conn and "04" or "05", dir,
|
||||
tostring(conn and conn.mapId or "none")))
|
||||
U.log(" want", conn and "silence" or "Sfx_Bump", "-- bumps:",
|
||||
counts.Sfx_Bump or 0)
|
||||
U.shot(game, out .. ("/0%s-edge-%s.png"):format(conn and "4" or "5", dir))
|
||||
assert(w:setMap("NEW_BARK_TOWN", ex, ey, dir))
|
||||
U.wait(5)
|
||||
end
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
assert(w:setMap("NEW_BARK_TOWN", 5, 5, "down"))
|
||||
U.log("done -- the controls are yours; walk into anything")
|
||||
end
|
||||
@@ -0,0 +1,207 @@
|
||||
-- #1556: the field jingles that ride a TX_SOUND at the end of a string --
|
||||
-- RARE CANDY's level-up, "X learned MOVE!", the trophy -- plus the TM/HM
|
||||
-- refusals, which are `call PlaySFX` next to the message instead.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1556_fanfare.lua \
|
||||
-- perl -e 'alarm 420; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1556-fanfare (default)
|
||||
--
|
||||
-- The one deliberate SILENCE is moment 4: TeachTMHM's `.compatible` arm is
|
||||
-- `callfar KnowsMove / jr c, .nope` (engine/items/tmhm.asm:139-140) with no
|
||||
-- PlaySFX at all, so "X already knows MOVE!" must stay mute. Only the
|
||||
-- INCOMPATIBLE arm rings (:131).
|
||||
--
|
||||
-- The box is supposed to HOLD for the fanfare before it takes a button:
|
||||
-- TextCommand_SOUND is PlaySFX then WaitSFX (home/text.asm), which
|
||||
-- TextBox.soundOpts models with auto.wait.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1556-fanfare"
|
||||
|
||||
local heard = {}
|
||||
local realPlay = Sound.play
|
||||
Sound.play = function(data, name)
|
||||
heard[#heard + 1] = name
|
||||
return realPlay(data, name)
|
||||
end
|
||||
local function reset() heard = {} end
|
||||
local function report(label, want)
|
||||
U.log(label, #heard > 0 and table.concat(heard, ", ") or "(silence)")
|
||||
U.log(" want:", want)
|
||||
end
|
||||
|
||||
local function tap(button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
local w = game.world
|
||||
assert(w and w.map, "gold world did not boot")
|
||||
local save, data = game.save, game.data
|
||||
|
||||
local SPECIES = "CYNDAQUIL"
|
||||
local def = data.pokemon and data.pokemon[SPECIES]
|
||||
assert(def, "the cache carries no " .. SPECIES)
|
||||
|
||||
-- Which TMs this species may and may not learn, straight off its BASE_TMHM.
|
||||
local canLearn, cannotLearn = nil, nil
|
||||
local allowed = {}
|
||||
for _, moveId in ipairs(def.tmhm or {}) do allowed[moveId] = true end
|
||||
for itemId, item in pairs(data.items or {}) do
|
||||
if item.teaches and tostring(itemId):sub(1, 3) == "TM_" then
|
||||
if allowed[item.teaches] then
|
||||
canLearn = canLearn or itemId
|
||||
else
|
||||
cannotLearn = cannotLearn or itemId
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log("TM that fits:", tostring(canLearn),
|
||||
"-- TM that does not:", tostring(cannotLearn))
|
||||
|
||||
local function seed()
|
||||
local mon = Mon.new(data, SPECIES, 12)
|
||||
save.party = { mon }
|
||||
return mon
|
||||
end
|
||||
|
||||
local function openPack()
|
||||
local pack = PackMenu.new(game, {
|
||||
save = save, world = w, onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
U.wait(6)
|
||||
return pack
|
||||
end
|
||||
|
||||
local field = game.overworld or game.stack:top()
|
||||
local function popToField()
|
||||
for _ = 1, 12 do
|
||||
if game.stack:top() == field or not game.stack:top() then break end
|
||||
game.stack:pop()
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- 1. RARE CANDY: "X grew to level N!" + the level-up fanfare ---------
|
||||
local mon = seed()
|
||||
save.inventory = { RARE_CANDY = 2 }
|
||||
reset()
|
||||
game:usePartyItem("RARE_CANDY")
|
||||
U.wait(8)
|
||||
tap("a", 30) -- the party pick
|
||||
report("01 rare candy:", "Sfx_DexFanfare5079 under \"grew to level 13!\"")
|
||||
U.shot(game, out .. "/01-rare-candy.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
|
||||
-- ---- 2. a TM the mon CAN learn: "X learned MOVE!" + the fanfare ---------
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 30) -- the party pick
|
||||
report("02 TM " .. canLearn .. ":",
|
||||
"Sfx_DexFanfare5079 under \"learned ...!\"")
|
||||
U.shot(game, out .. "/02-tm-learned.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 02 -- no TM in this cache that " .. SPECIES .. " can learn")
|
||||
end
|
||||
|
||||
-- ---- 3. a TM the mon CANNOT learn: SFX_WRONG (tmhm.asm:131) ------------
|
||||
if cannotLearn then
|
||||
mon = seed()
|
||||
save.inventory = { [cannotLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = cannotLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("03 TM " .. cannotLearn .. ":",
|
||||
"Sfx_Wrong under \"can't learn ...!\"")
|
||||
U.shot(game, out .. "/03-tm-refused.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 03 -- every TM in this cache fits " .. SPECIES)
|
||||
end
|
||||
|
||||
-- ---- 4. a TM the mon ALREADY KNOWS: SILENCE ----------------------------
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
local moveId = data.items[canLearn].teaches
|
||||
mon.moves = mon.moves or {}
|
||||
mon.moves[1] = { id = moveId, pp = 10, maxPp = 10 }
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("04 TM already known:", "SILENCE -- KnowsMove has no PlaySFX")
|
||||
U.shot(game, out .. "/04-tm-already-known.png")
|
||||
tap("a", 10)
|
||||
popToField()
|
||||
end
|
||||
|
||||
-- ---- 5. a TM on an EGG: SFX_WRONG (tmhm.asm:104-111), a regression -----
|
||||
if canLearn then
|
||||
mon = seed()
|
||||
mon.isEgg = true
|
||||
save.inventory = { [canLearn] = 1 }
|
||||
local pack = openPack()
|
||||
reset()
|
||||
pack:openTeachParty({ id = canLearn })
|
||||
U.wait(10)
|
||||
tap("a", 20)
|
||||
report("05 TM on an EGG:", "Sfx_Wrong, and the list stays up")
|
||||
U.shot(game, out .. "/05-tm-egg.png")
|
||||
tap("b", 6)
|
||||
popToField()
|
||||
end
|
||||
|
||||
-- ---- 6. the trophy box: _SentTrophyHomeText's fanfare ------------------
|
||||
if data.items and data.items.NORMAL_BOX then
|
||||
seed()
|
||||
save.inventory = { NORMAL_BOX = 1 }
|
||||
save.decorations = save.decorations or {}
|
||||
local pack = openPack()
|
||||
pack:rebuild()
|
||||
local row
|
||||
for index, entry in ipairs(pack.rows) do
|
||||
if entry.id == "NORMAL_BOX" then row = index end
|
||||
end
|
||||
if row then
|
||||
pack.index = row
|
||||
reset()
|
||||
pack:useSelected()
|
||||
U.wait(20)
|
||||
report("06 trophy box:", "Sfx_DexFanfare5079 with the trophy line")
|
||||
U.shot(game, out .. "/06-trophy.png")
|
||||
tap("a", 6)
|
||||
else
|
||||
U.log("SKIP 06 -- the NORMAL BOX is not in the ITEM pocket here")
|
||||
end
|
||||
popToField()
|
||||
else
|
||||
U.log("SKIP 06 -- this cache has no NORMAL_BOX")
|
||||
end
|
||||
|
||||
Sound.play = realPlay
|
||||
U.log("done -- the controls are yours")
|
||||
end
|
||||
@@ -0,0 +1,105 @@
|
||||
-- #1567: the TM/HM pocket's row order.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1567_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1567 (default)
|
||||
--
|
||||
-- Nothing here can be asserted: the bug IS the order rows land in.
|
||||
-- TMHM_DisplayPocketItems walks the fixed 57-byte wTMsHMs array from 1 to 57
|
||||
-- and prints every non-zero slot (engine/items/tmhm.asm:341), so the cart's
|
||||
-- pocket is always TM01..TM50 then HM01..HM07 no matter what the player picked
|
||||
-- up first. The port drew it from the acquisition-ordered bag list instead.
|
||||
--
|
||||
-- The seed is deliberately scrambled -- gold_bug1425_test.lua seeds its TMs
|
||||
-- already in numeric order, which is why its screenshots never showed this.
|
||||
--
|
||||
-- The run ends with the PACK still open on the TM pocket, so a human takes the
|
||||
-- controls exactly where the screenshots stop.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
|
||||
-- Picked up back to front: TM/HM numbers 57, 50, 51, 5, 1, 53. TM_ROAR is the
|
||||
-- one on the far side of the ITEM_C3 hole (id $c4, number 5), so it also pins
|
||||
-- that the number and not the item id is what sorts.
|
||||
local SEED = {
|
||||
{ "HM_WATERFALL", 1 },
|
||||
{ "TM_NIGHTMARE", 2 },
|
||||
{ "HM_CUT", 1 },
|
||||
{ "TM_ROAR", 3 },
|
||||
{ "TM_DYNAMICPUNCH", 12 },
|
||||
{ "HM_SURF", 1 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1567"
|
||||
|
||||
local function shot(name)
|
||||
U.wait(3)
|
||||
U.shot(game, ("%s/%s.png"):format(out, name))
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local save = game.save
|
||||
save.inventory = {}
|
||||
save.bagOrder = {}
|
||||
for _, entry in ipairs(SEED) do
|
||||
local id, count = entry[1], entry[2]
|
||||
if not (game.data.items and game.data.items[id]) then
|
||||
U.log("[driver] SKIP", id, "-- not in this cache")
|
||||
else
|
||||
save.inventory[id] = count
|
||||
table.insert(Bag.order(save), id)
|
||||
end
|
||||
end
|
||||
-- A POTION and a SUPER POTION so the ITEM pocket can be compared against the
|
||||
-- TM pocket at the end: SELECT must still arm a row over there.
|
||||
for _, entry in ipairs({ { "POTION", 5 }, { "SUPER_POTION", 2 } }) do
|
||||
if game.data.items and game.data.items[entry[1]] then
|
||||
save.inventory[entry[1]] = entry[2]
|
||||
table.insert(Bag.order(save), entry[1])
|
||||
end
|
||||
end
|
||||
|
||||
U.log("[driver] pickup order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
local pack = PackMenu.new(game, { save = save, world = game.world,
|
||||
onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
|
||||
shot("00-items") -- POTION over SUPER POTION, pickup order
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
-- TM01 DYNAMICPUNCH ×12, TM05 ROAR ×03, TM50 NIGHTMARE ×02, then HM01 CUT,
|
||||
-- HM03 SURF, HM07 WATERFALL with no ×NN at all (engine/items/tmhm.asm:390).
|
||||
-- Before the fix this read HM07, TM50, HM01, TM05, TM01, HM03.
|
||||
shot("01-tmhm-numbered")
|
||||
|
||||
local order = {}
|
||||
for i = 1, #pack.rows do order[i] = pack.rows[i].id end
|
||||
U.log("[driver] TM/HM rows:", table.concat(order, " "))
|
||||
|
||||
-- engine/items/tmhm.asm:207 filters SELECT out of this pocket: no hollow ▷,
|
||||
-- no "Where should this be moved to?".
|
||||
U.tap(game, "select")
|
||||
shot("02-tmhm-select-ignored")
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("03-tmhm-select-ignored-row2")
|
||||
|
||||
-- The ITEM pocket, where SELECT still arms (engine/items/pack.asm:1290).
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "select")
|
||||
shot("04-items-select-arms")
|
||||
U.tap(game, "b")
|
||||
|
||||
-- Back to the TM pocket for the human.
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.tap(game, "right")
|
||||
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
|
||||
end
|
||||
@@ -0,0 +1,197 @@
|
||||
-- #1513: exp credit has to be per THIS appearance of the enemy's active mon.
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037), and AI_Switch farcalls it right
|
||||
-- after EnemySwitch (engine/battle/ai/items.asm:697), so the moment the rival
|
||||
-- rotates, the only mon still credited is the one the player has on the field.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 POKEPORT_IDENTITY=gold-dev \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_exp_participants_bug1513.lua love .
|
||||
--
|
||||
-- What to look for, in order:
|
||||
-- 00 CYNDAQUIL is out against JOEY's GEODUDE.
|
||||
-- 01 TOTODILE has been switched in through the real party menu, so BOTH
|
||||
-- party mons have now "met" GEODUDE.
|
||||
-- 02 "JOEY withdrew GEODUDE!" -- the rotation the ticket is about.
|
||||
-- 03 the exp lines for the KO of the mon that came IN (PIDGEY). Only
|
||||
-- TOTODILE may be named here. A "CYNDAQUIL gained N EXP. Points!" line
|
||||
-- in this shot is the bug.
|
||||
-- 04 the party screen: CYNDAQUIL's level and EXP bar are pixel-identical to
|
||||
-- shot 01's, TOTODILE's have moved.
|
||||
-- 05 GEODUDE comes back out and is KO'd. CYNDAQUIL still gains nothing --
|
||||
-- that half is correct cart behavior (ram/wram.asm:775-776, "All bits
|
||||
-- cleared if the enemy faints") and must NOT be "fixed".
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local function battleScreen(game)
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function runToPhase(game, screen, phase, frames)
|
||||
for _ = 1, (frames or 600) do
|
||||
if screen.phase == phase then return true end
|
||||
if screen.battle.over then return false end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Page the queue out, shooting the first box that carries `needle`.
|
||||
local function pageUntilMenu(game, screen, out, needles)
|
||||
local shot = {}
|
||||
for _ = 1, 1200 do
|
||||
local message = screen.message
|
||||
if message then
|
||||
for needle, path in pairs(needles) do
|
||||
if not shot[needle] and message:find(needle, 1, true) then
|
||||
U.shot(game, out .. "/" .. path)
|
||||
shot[needle] = message
|
||||
end
|
||||
end
|
||||
end
|
||||
if screen.phase == "menu" or screen.phase == "done"
|
||||
or screen.battle.over then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return shot
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1513"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
|
||||
local lead = Mon.new(game.data, "CYNDAQUIL", 20)
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local bench = Mon.new(game.data, "TOTODILE", 20)
|
||||
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, bench }
|
||||
|
||||
local foe1 = Mon.new(game.data, "GEODUDE", 8)
|
||||
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local foe2 = Mon.new(game.data, "PIDGEY", 8)
|
||||
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
-- attributes[6] is the low byte of TRNATTR_AI_ITEM_SWITCH: OFTEN
|
||||
-- (src/battle/gen2/Ai.lua:1307). The real Rival1 class is SWITCH_SOMETIMES
|
||||
-- (pokegold:data/trainers/attributes.asm), the same path at a lower roll.
|
||||
assert(world:startBattle({ trainer = { class = "YOUNGSTER", name = "JOEY",
|
||||
party = { foe1, foe2 }, attributes = { 0, 0, 0, 0, 0, 0x01, 0 } } }),
|
||||
"trainer startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
assert(runToPhase(game, screen, "menu", 400), "never reached the menu")
|
||||
U.shot(game, out .. "/00-lead-out.png")
|
||||
|
||||
------------------------------------------------- the player's own switch
|
||||
-- Through the real party menu, so the human sees the same path they used.
|
||||
screen:chooseMenu("party")
|
||||
for _ = 1, 240 do
|
||||
if screen.battle.player == bench then break end
|
||||
local top = game.stack:top()
|
||||
if top ~= screen then
|
||||
U.tap(game, "down")
|
||||
U.wait(3)
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
else
|
||||
U.wait(2)
|
||||
end
|
||||
end
|
||||
check(screen.battle.player == bench, "TOTODILE was switched in")
|
||||
check(screen.battle.participants[1] and screen.battle.participants[2],
|
||||
"both party mons are credited against GEODUDE")
|
||||
pageUntilMenu(game, screen, out, {})
|
||||
U.shot(game, out .. "/01-switched.png")
|
||||
print(("[driver] before the rotation: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
local leadExp = lead.experience
|
||||
local benchExp = bench.experience
|
||||
|
||||
------------------------------------------------------- the AI's rotation
|
||||
-- Perish Song at one turn left is CheckAbleToSwitch's maximum score, the
|
||||
-- cheapest way to make the AI commit to the rotation on demand.
|
||||
screen.battle:volatile(screen.battle.enemy).perish = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
local seen = pageUntilMenu(game, screen, out, {
|
||||
["withdrew"] = "02-rival-withdrew.png",
|
||||
["gained"] = "03-exp-lines.png",
|
||||
})
|
||||
check(seen["withdrew"] ~= nil, "JOEY withdrew GEODUDE")
|
||||
check(screen.battle.enemy == foe2, "and sent PIDGEY out")
|
||||
check(screen.battle.participants[1] == nil,
|
||||
"CYNDAQUIL lost its credit when the rival rotated")
|
||||
check(screen.battle.participants[2] == true,
|
||||
"and TOTODILE, the mon on the field, kept it")
|
||||
|
||||
------------------------------------------------------------ the KO payout
|
||||
if not screen.battle.over and (foe2.hp or 0) > 0 then
|
||||
if screen.phase ~= "menu" then
|
||||
runToPhase(game, screen, "menu", 400)
|
||||
end
|
||||
foe2.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
local paid = pageUntilMenu(game, screen, out, {
|
||||
["gained"] = "03-exp-lines.png",
|
||||
})
|
||||
if paid["gained"] then print("[driver] exp box: " .. paid["gained"]) end
|
||||
end
|
||||
print(("[driver] after the PIDGEY KO: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
check(lead.experience == leadExp,
|
||||
"CYNDAQUIL earned NOTHING from the mon it never faced")
|
||||
check(bench.experience > benchExp, "TOTODILE was paid for it")
|
||||
|
||||
if screen.phase == "menu" then
|
||||
screen:chooseMenu("party")
|
||||
U.wait(20)
|
||||
U.shot(game, out .. "/04-party-exp.png")
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
if game.stack:top() ~= screen then
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------- GEODUDE comes back and faints
|
||||
-- Correct cart behavior: no residual credit for a previous appearance.
|
||||
leadExp = lead.experience
|
||||
benchExp = bench.experience
|
||||
if not screen.battle.over then
|
||||
runToPhase(game, screen, "menu", 600)
|
||||
if screen.phase == "menu" and screen.battle.enemy == foe1 then
|
||||
foe1.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
pageUntilMenu(game, screen, out, { ["gained"] = "05-geodude-ko.png" })
|
||||
print(("[driver] after the GEODUDE KO: cyndaquil %d totodile %d")
|
||||
:format(lead.experience, bench.experience))
|
||||
check(lead.experience == leadExp,
|
||||
"CYNDAQUIL still earns nothing when GEODUDE comes back (correct)")
|
||||
check(bench.experience > benchExp, "and TOTODILE takes the whole share")
|
||||
else
|
||||
print("[driver] GEODUDE never came back out; second half skipped")
|
||||
end
|
||||
end
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " exp participant checks failed")
|
||||
end
|
||||
print("[driver] #1513 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -221,6 +221,13 @@ return function(game)
|
||||
stored[i] = mon(species, 10 + i)
|
||||
end
|
||||
Boxes.rename(save, 2, "GRASS")
|
||||
-- $5d for a held item, $5c for MAIL (engine/pokemon/bills_pc.asm:1079-1094).
|
||||
-- A boxed mon can never hold mail, so the letter goes on a party mon.
|
||||
stored[1].item = "BERRY"
|
||||
save.party[2].item = "FLOWER_MAIL"
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
Mail.set(save, 2, Mail.entry("FLOWER_MAIL", "HI THERE!",
|
||||
save.player.name or "GOLD", save.player.id or 0, save.party[2].species))
|
||||
|
||||
local pc = PcMenu.new(game, { save = save })
|
||||
show("20-pc-menu", pc)
|
||||
@@ -228,12 +235,22 @@ return function(game)
|
||||
pc.pickIndex = 2
|
||||
show("21-pc-changebox", pc)
|
||||
|
||||
show("22-pc-withdraw", BoxMenu.new(game, {
|
||||
save = save, mode = "withdraw",
|
||||
}))
|
||||
show("23-pc-deposit", BoxMenu.new(game, {
|
||||
save = save, mode = "deposit",
|
||||
}))
|
||||
-- Browsing paints the 7x7 block in BillsPCOrangePalette and only .PrepSubmenu
|
||||
-- swaps the mon's colours in (engine/pokemon/bills_pc.asm:305-309, :356-369,
|
||||
-- engine/gfx/cgb_layouts.asm:284-289); the icon is BG palette 0 either way.
|
||||
local wd = BoxMenu.new(game, { save = save, mode = "withdraw" })
|
||||
show("22-pc-withdraw", wd)
|
||||
wd.index = wd:total() -- CANCEL
|
||||
wd:ensureVisible()
|
||||
show("22b-pc-withdraw-cancel", wd)
|
||||
wd.index = 1
|
||||
wd:ensureVisible()
|
||||
wd.phase = "submenu"
|
||||
show("22c-pc-withdraw-submenu", wd)
|
||||
local dep = BoxMenu.new(game, { save = save, mode = "deposit" })
|
||||
show("23-pc-deposit", dep)
|
||||
dep.index = 2 -- the mon holding FLOWER MAIL
|
||||
show("23b-pc-deposit-mail", dep)
|
||||
|
||||
-- The two clock screens NEW GAME and Mom open (timeset.asm InitClock and
|
||||
-- SetDayOfWeek), each at its picker rather than at its opening page.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
-- Driver: #1517, a Gen 2 stat block in a Gen 1 party.
|
||||
--
|
||||
-- Gen 1's party_struct carries one Special word (macros/ram.asm:28-37) and
|
||||
-- PrintStatsBox reads four fixed cells, wLoadedMonAttack/Defense/Speed/Special
|
||||
-- (engine/pokemon/status_screen.asm:255-296). Gen 2 splits that word into
|
||||
-- SpclAtk/SpclDef (pokegold macros/ram.asm:29-42), so a mod that wrote a Gen 2
|
||||
-- block over a Yellow party leaves the port with no `special` to print.
|
||||
--
|
||||
-- cp -R ~/Library/Application\ Support/LOVE/pokemon-love2d/yellow \
|
||||
-- ~/Library/Application\ Support/LOVE/bug1517/yellow
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_GAME=yellow POKEPORT_IDENTITY=bug1517 \
|
||||
-- POKEPORT_TOUCH=0 POKEPORT_SPEED=8 SHOT_DIR=/tmp/bug1517 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/summary_stats_bug1517_test.lua love .
|
||||
--
|
||||
-- The copy is not optional: a fresh POKEPORT_IDENTITY has no cache and the
|
||||
-- boot hangs with no output.
|
||||
--
|
||||
-- Pre-fix the LOVE window vanishes the moment STATS is chosen, with no error
|
||||
-- screen and nothing on stdout (that silence is the other half of #1517).
|
||||
-- Post-fix the status screen opens and SPECIAL reads the CalcStats value
|
||||
-- logged below. The run ends on the open screen, so a human takes the
|
||||
-- controls at exactly the frame the reporter's build died on.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/bug1517"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SummaryMenu = require("src.ui.SummaryMenu")
|
||||
|
||||
local function top() return game.stack:top() end
|
||||
|
||||
U.newGame(game)
|
||||
U.wait(20)
|
||||
|
||||
-- BUTTERFREE because the reporter's party led with one, and CONFUSION is a
|
||||
-- special move, so the same block would have crashed Damage.applyStage.
|
||||
local species = game.data.pokemon.BUTTERFREE and "BUTTERFREE" or "PIDGEY"
|
||||
local def = game.data.pokemon[species]
|
||||
local mon = Pokemon.new(game.data, species, 21)
|
||||
local want = Stats.calc(def, 21, mon.dvs, mon.statExp)
|
||||
|
||||
-- the shape src/battle/gen2/Mon.lua Mon.stats produces: no `special`
|
||||
mon.stats = {
|
||||
hp = want.hp, attack = want.attack, defense = want.defense,
|
||||
speed = want.speed, specialAttack = want.special, specialDefense = want.special,
|
||||
}
|
||||
mon.hp = mon.stats.hp
|
||||
game.save.party = { mon }
|
||||
U.log(("[1517] fixture: %s :L21, stats.special = %s, CalcStats says %d")
|
||||
:format(species, tostring(mon.stats.special), want.special))
|
||||
|
||||
SaveData.validate(game.save, game.data)
|
||||
local after = game.save.party[1].stats
|
||||
U.log("[1517] after SaveData.validate, special = " .. tostring(after.special)
|
||||
.. ", specialAttack = " .. tostring(after.specialAttack))
|
||||
U.log("[1517] the repair layer is SaveData.validate -> scrubKnownMon -> "
|
||||
.. "Stats.ensure; a nil above means it did not run")
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function cursorTo(menu, field, wanted)
|
||||
for _ = 1, 40 do
|
||||
if not menu or menu[field] == wanted then return menu and menu[field] == wanted end
|
||||
U.tap(game, menu[field] < wanted and "down" or "up")
|
||||
U.wait(3)
|
||||
end
|
||||
return menu[field] == wanted
|
||||
end
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local menu = top()
|
||||
if not (menu and menu.screenId == "StartMenu") then
|
||||
U.log("[1517] FAIL no start menu; top = " .. tostring(menu and menu.screenId))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local row
|
||||
for i, it in ipairs(menu.items or {}) do
|
||||
if it.label == "POKéMON" then row = i break end
|
||||
end
|
||||
if not (row and cursorTo(menu, "index", row)) then
|
||||
U.log("[1517] FAIL could not reach the POKéMON row")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
local party = top()
|
||||
if not (party and party.screenId == "PartyMenu") then
|
||||
U.log("[1517] FAIL no party menu; top = " .. tostring(party and party.screenId))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, DIR .. "/01-party-list.png")
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local subRow
|
||||
for i, it in ipairs(party.subItems or {}) do
|
||||
if it.action == "stats" then subRow = i break end
|
||||
end
|
||||
if not (subRow and cursorTo(party, "subIndex", subRow)) then
|
||||
U.log("[1517] FAIL could not reach the STATS row")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
U.shot(game, DIR .. "/02-stats-selected.png")
|
||||
|
||||
U.log("[1517] opening STATS -- pre-fix the app dies here, silently")
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
|
||||
local screen = top()
|
||||
local opened = getmetatable(screen) == SummaryMenu or screen.screenId == "SummaryMenu"
|
||||
U.log("[1517] SURVIVED, top = " .. tostring(screen and screen.screenId))
|
||||
U.shot(game, DIR .. "/03-status-screen.png")
|
||||
U.log(("[1517] %s: SPECIAL on screen must read %d")
|
||||
:format(opened and "read it off the frame" or "WRONG SCREEN", want.special))
|
||||
U.log("[1517] shots in " .. DIR .. " -- the status screen is yours")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- A Gen 1 party mon carries one Special word (macros/ram.asm:28-37) and
|
||||
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48,
|
||||
-- constants/battle_constants.asm:11-17), so a block missing a key is not a
|
||||
-- Gen 1 record and the load-time repair must rebuild it. Gen 2 splits it
|
||||
-- into SpclAtk/SpclDef (pokegold macros/ram.asm:29-42). #1517
|
||||
|
||||
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")
|
||||
|
||||
local Stats = require("src.pokemon.Stats")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local data = {
|
||||
pokemon = { FIXMON_A = { id = "FIXMON_A", name = "FIXMON A", types = { "GRASS" },
|
||||
baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 } } },
|
||||
moves = { FIX_TACKLE = { id = "FIX_TACKLE", name = "TACKLE", pp = 35 } },
|
||||
items = {}, maps = { FIXMAP = { id = "FIXMAP" } },
|
||||
constants = { fallbackMove = "FIX_TACKLE" },
|
||||
}
|
||||
local DEF = data.pokemon.FIXMON_A
|
||||
local DVS = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 }
|
||||
|
||||
-- src/battle/gen2/Mon.lua Mon.stats' shape: no `special`
|
||||
local function gen2Shaped()
|
||||
return {
|
||||
species = "FIXMON_A", level = 21, hp = 62, exp = 9000,
|
||||
dvs = { hp = 15, attack = 9, defense = 8, speed = 8, special = 8 },
|
||||
statExp = {},
|
||||
stats = { hp = 62, attack = 27, defense = 29, speed = 37,
|
||||
specialAttack = 8, specialDefense = 8 },
|
||||
moves = { { id = "FIX_TACKLE", pp = 35 } },
|
||||
}
|
||||
end
|
||||
|
||||
do
|
||||
local mon = gen2Shaped()
|
||||
eq(mon.stats.special, nil, "premise: a Gen 2 block has no `special`")
|
||||
Stats.ensure(DEF, mon)
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
for _, key in ipairs(Stats.ORDER) do
|
||||
eq(mon.stats[key], want[key], "ensure rebuilds " .. key .. " from CalcStats")
|
||||
end
|
||||
eq(mon.stats.specialAttack, nil, "and drops the Gen 2 keys")
|
||||
eq(mon.stats.specialDefense, nil, "both of them")
|
||||
end
|
||||
|
||||
do
|
||||
local mon = gen2Shaped()
|
||||
local save = { party = { mon }, boxes = {}, inventory = {}, pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
|
||||
SaveData.validate(save, data)
|
||||
check(type(save.party[1].stats.special) == "number",
|
||||
"validate repairs a party mon carrying a Gen 2 stat block")
|
||||
local ok = pcall(string.format, "%3d", save.party[1].stats.special)
|
||||
check(ok, "SummaryMenu's ('%3d'):format over stats.special no longer raises")
|
||||
ok = pcall(Stats.applyStage, save.party[1].stats.special, 0)
|
||||
check(ok, "and Damage's applyStage over curStats.special no longer raises")
|
||||
end
|
||||
|
||||
-- a box mon reached through the same pass
|
||||
do
|
||||
local save = { party = {}, boxes = { { gen2Shaped() } }, inventory = {},
|
||||
pcItems = {},
|
||||
player = { map = "FIXMAP", x = 1, y = 1, name = "RED", id = 1 } }
|
||||
SaveData.validate(save, data)
|
||||
check(type(save.boxes[1][1].stats.special) == "number",
|
||||
"the repair reaches box mons too")
|
||||
end
|
||||
|
||||
do -- a complete block is never rewritten
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
local mon = { species = "FIXMON_A", level = 21, hp = 5, dvs = DVS, statExp = {},
|
||||
stats = { hp = want.hp, attack = 999, defense = want.defense,
|
||||
speed = want.speed, special = want.special },
|
||||
moves = { { id = "FIX_TACKLE", pp = 35 } } }
|
||||
Stats.ensure(DEF, mon)
|
||||
eq(mon.stats.attack, 999, "a complete block is returned untouched")
|
||||
eq(mon.hp, 5, "and a stored current HP below the max is kept")
|
||||
end
|
||||
|
||||
do -- a mon with no block at all still gets one (#233, #304 unchanged)
|
||||
local mon = { species = "FIXMON_A", level = 21, dvs = DVS, statExp = {} }
|
||||
Stats.ensure(DEF, mon)
|
||||
local want = Stats.calc(DEF, 21, DVS, {})
|
||||
eq(mon.stats.special, want.special, "a box mon with no stats still gets them")
|
||||
eq(mon.hp, want.hp, "and a missing current HP fills to the maximum")
|
||||
end
|
||||
|
||||
do -- no species definition: nothing to rebuild from, so leave it alone
|
||||
local mon = { species = "MISSINGNO", level = 21,
|
||||
stats = { hp = 62, attack = 27 } }
|
||||
Stats.ensure(nil, mon)
|
||||
eq(mon.stats.attack, 27, "an unknown species leaves a partial block as-is")
|
||||
eq(mon.stats.special, nil, "rather than inventing one")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -851,6 +851,83 @@ do
|
||||
check("the trainer's PP is untouched", refuseParty[1].moves[1].pp, 35)
|
||||
end
|
||||
|
||||
-- ResetBattleParticipants falls through into AddBattleParticipant
|
||||
-- (engine/battle/core.asm:3033 and :3037), so every ENEMY-initiated mon change
|
||||
-- wipes both bitfields and re-credits the mon the player has out. The player's
|
||||
-- own send-outs only ever call AddBattleParticipant (core.asm:2655, :2681,
|
||||
-- :3783, :4989, :5014).
|
||||
do
|
||||
local function creditBattle()
|
||||
local party = {
|
||||
Mon.new(DATA, "CYNDAQUIL", 20, { dvs = perfect }),
|
||||
Mon.new(DATA, "TOTODILE", 20, { dvs = perfect }),
|
||||
}
|
||||
local foes = {
|
||||
Mon.new(DATA, "GEODUDE", 8, { dvs = perfect }),
|
||||
Mon.new(DATA, "PIDGEY", 8, { dvs = perfect }),
|
||||
}
|
||||
for _, mon in ipairs(party) do
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
end
|
||||
for _, mon in ipairs(foes) do
|
||||
mon.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
end
|
||||
local battle = Battle.new({
|
||||
data = DATA, party = party,
|
||||
-- attributes[6] is the low byte of the switch flags: OFTEN.
|
||||
trainer = { class = "YOUNGSTER", name = "JOEY", party = foes,
|
||||
attributes = { 0, 0, 0, 0, 0, 0x01, 0 } },
|
||||
random = zeroRandom,
|
||||
})
|
||||
battle:switch(2)
|
||||
return battle, party, foes
|
||||
end
|
||||
|
||||
-- AI_Switch (engine/battle/ai/items.asm:697).
|
||||
local rotate, rotateParty, rotateFoes = creditBattle()
|
||||
check("both mons are credited before the rotation",
|
||||
rotate.participants[1] and rotate.participants[2], true)
|
||||
rotate:volatile(rotate.enemy).perish = 1
|
||||
check("the AI rotated", rotate:enemyTrySwitchOrItem(), true)
|
||||
check("the rotation installed the second foe", rotate.enemy, rotateFoes[2])
|
||||
check("the bench mon lost its credit", rotate.participants[1], nil)
|
||||
check("only the mon on the field keeps it", rotate.participants[2], true)
|
||||
local benchExp = rotateParty[1].experience
|
||||
local activeExp = rotateParty[2].experience
|
||||
rotate:awardExperience(rotate.enemy)
|
||||
check("the bench mon earns nothing from the new foe",
|
||||
rotateParty[1].experience, benchExp)
|
||||
check("and the mon that faced it is still paid",
|
||||
rotateParty[2].experience > activeExp, true)
|
||||
|
||||
-- ForceEnemySwitch (engine/battle/core.asm:2937), reached only from
|
||||
-- BattleCommand_ForceSwitch (effect_commands.asm:4999).
|
||||
local roar, _, roarFoes = creditBattle()
|
||||
roar.firstMover = "enemy"
|
||||
Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH(roar, roar.player, roar.enemy,
|
||||
nil, "ROAR", true)
|
||||
check("Roar dragged the second foe out", roar.enemy, roarFoes[2])
|
||||
check("the bench mon lost its credit to Roar", roar.participants[1], nil)
|
||||
check("and the mon on the field keeps it", roar.participants[2], true)
|
||||
|
||||
-- engine/battle/move_effects/baton_pass.asm:59.
|
||||
local baton, _, batonFoes = creditBattle()
|
||||
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(baton, baton.enemy)
|
||||
check("the baton passed to the second foe", baton.enemy, batonFoes[2])
|
||||
check("the bench mon lost its credit to the baton",
|
||||
baton.participants[1], nil)
|
||||
check("and the mon on the field keeps it", baton.participants[2], true)
|
||||
|
||||
-- PassedBattleMonEntrance only adds (engine/battle/core.asm:5014): the
|
||||
-- player's own baton pass must NOT wipe the set.
|
||||
local playerBaton, playerBatonParty = creditBattle()
|
||||
Battle.MOVE_EFFECTS.EFFECT_BATON_PASS(playerBaton, playerBaton.player)
|
||||
check("the player's baton pass moved the lead back in",
|
||||
playerBaton.player, playerBatonParty[1])
|
||||
check("and credited both mons",
|
||||
playerBaton.participants[1] and playerBaton.participants[2], true)
|
||||
end
|
||||
|
||||
-- Switching costs the turn and adds the newcomer to the participant set.
|
||||
local switchParty = {
|
||||
Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect }),
|
||||
|
||||
@@ -483,6 +483,72 @@ packInput:press("right")
|
||||
pack:update(0)
|
||||
check("pocket wraps around", pack:pocket().id, "ITEM")
|
||||
|
||||
-- engine/items/tmhm.asm:341 -- TMHM_DisplayPocketItems walks wTMsHMs 1..57, so
|
||||
-- the pocket is TM01..TM50 then HM01..HM07 whatever order the player picked
|
||||
-- them up in, and tmhm.asm:207 keeps SELECT out of it entirely.
|
||||
do
|
||||
local tmSave2 = Save.newGame()
|
||||
tmSave2.inventory = {
|
||||
HM_WATERFALL = 1, TM_NIGHTMARE = 2, HM_CUT = 1, TM_ROAR = 3,
|
||||
TM_DYNAMICPUNCH = 12, TM_LEGACY = 1,
|
||||
}
|
||||
tmSave2.bagOrder = {
|
||||
"HM_WATERFALL", "TM_NIGHTMARE", "HM_CUT", "TM_ROAR", "TM_DYNAMICPUNCH",
|
||||
"TM_LEGACY",
|
||||
}
|
||||
local tmGame2, tmInput2 = newGame(tmSave2)
|
||||
tmGame2.data.items = {
|
||||
TM_DYNAMICPUNCH = { id = "TM_DYNAMICPUNCH", name = "TM01",
|
||||
pocket = "TM_HM", index = 191, tmNumber = 1 },
|
||||
TM_ROAR = { id = "TM_ROAR", name = "TM05", pocket = "TM_HM",
|
||||
index = 195, tmNumber = 5 },
|
||||
TM_NIGHTMARE = { id = "TM_NIGHTMARE", name = "TM50", pocket = "TM_HM",
|
||||
index = 240, tmNumber = 50 },
|
||||
HM_CUT = { id = "HM_CUT", name = "HM01", pocket = "TM_HM",
|
||||
index = 241, tmNumber = 51 },
|
||||
HM_WATERFALL = { id = "HM_WATERFALL", name = "HM07", pocket = "TM_HM",
|
||||
index = 247, tmNumber = 57 },
|
||||
-- A cache (or a mod) with no tmNumber falls back to the ItemNames index.
|
||||
TM_LEGACY = { id = "TM_LEGACY", name = "TM??", pocket = "TM_HM",
|
||||
index = 300 },
|
||||
}
|
||||
local tmPack = PackMenu.new(tmGame2, { pocket = "TM_HM" })
|
||||
local function ids(rows)
|
||||
local out = {}
|
||||
for i = 1, #rows do out[i] = rows[i].id end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
check("TM/HM pocket is TM-number ordered, not pickup ordered",
|
||||
ids(tmPack.rows),
|
||||
"TM_DYNAMICPUNCH,TM_ROAR,TM_NIGHTMARE,HM_CUT,HM_WATERFALL,TM_LEGACY")
|
||||
check("the first row is TM01", tmPack.rows[1].id, "TM_DYNAMICPUNCH")
|
||||
check("and a tmNumber-less item lands after HM07",
|
||||
tmPack.rows[#tmPack.rows].id, "TM_LEGACY")
|
||||
check("the TM keeps its count", tmPack.rows[1].showCount, true)
|
||||
check("the HM shows none", tmPack.rows[4].showCount, false)
|
||||
|
||||
tmPack.index = 1
|
||||
tmInput2:press("select")
|
||||
tmPack:update(0)
|
||||
check("SELECT cannot arm a TM/HM row", tmPack.switching, nil)
|
||||
check("and prints no move prompt", tmPack.message, nil)
|
||||
|
||||
-- The other three pockets still reorder on SELECT (pack.asm:1290).
|
||||
tmSave2.inventory.POTION = 1
|
||||
tmSave2.inventory.SUPER_POTION = 1
|
||||
tmSave2.bagOrder = { "POTION", "SUPER_POTION" }
|
||||
tmGame2.data.items.POTION =
|
||||
{ id = "POTION", name = "POTION", pocket = "ITEM", index = 1 }
|
||||
tmGame2.data.items.SUPER_POTION =
|
||||
{ id = "SUPER_POTION", name = "SUPER POTION", pocket = "ITEM", index = 2 }
|
||||
tmPack.pocketIndex = 1
|
||||
tmPack:rebuild()
|
||||
tmPack.index = 1
|
||||
tmInput2:press("select")
|
||||
tmPack:update(0)
|
||||
check("but the ITEM pocket still arms", tmPack.switching, 1)
|
||||
end
|
||||
|
||||
-- CANCEL sits one past the last row.
|
||||
check("cancel is past the end", pack:total(), #pack.rows + 1)
|
||||
pack.index = pack:total()
|
||||
|
||||
@@ -310,7 +310,8 @@ do
|
||||
eq(save.inventory.TM_HEADBUTT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- .TryDepositItem's .no_toss: a KEY ITEM stays in the bag, silently.
|
||||
-- .DepositItem: CANT_TOSS only skips .AskQuantity. A KEY ITEM deposits x1,
|
||||
-- and .Submenu withdraws it back the same way (issue #1486).
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
@@ -320,9 +321,63 @@ do
|
||||
press(pc, input, "right", "right") -- ITEM -> BALL -> KEY_ITEM pocket
|
||||
press(pc, input, "a") -- choose the BICYCLE
|
||||
eq(pc.qtyState, nil, "no quantity selector for a KEY ITEM")
|
||||
eq(pc.message, nil, "no message either: .no_toss is a bare ret")
|
||||
eq(save.pcItems.BICYCLE, nil, "and the BICYCLE never leaves the bag")
|
||||
eq(save.inventory.BICYCLE, 1, "still there")
|
||||
eq(save.pcItems.BICYCLE, 1, "the BICYCLE lands in the PC")
|
||||
eq(save.inventory.BICYCLE, nil, "and leaves the bag")
|
||||
eq(pc.message.pages[1][1], "Deposited 1", "_PlayersPCDepositItemsText")
|
||||
press(pc, input, "a") -- clear it
|
||||
press(pc, input, "b") -- close the PACK
|
||||
press(pc, input, "up", "a") -- WITHDRAW ITEM
|
||||
eq(pc.phase, "withdraw", "the PC item list opens on the KEY ITEM")
|
||||
press(pc, input, "a") -- the BICYCLE row
|
||||
eq(pc.qtyState, nil, "no quantity selector on the way back either")
|
||||
eq(save.inventory.BICYCLE, 1, "the BICYCLE is back in the bag")
|
||||
eq(save.pcItems.BICYCLE, nil, "and out of the PC")
|
||||
end
|
||||
|
||||
-- The TM_HM pocket's HMs are CANT_TOSS too, and deposit prompt-free x1.
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game, input = newGame(save)
|
||||
Bag.add(save, "HM_CUT", 1, game.data)
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
press(pc, input, "down", "a") -- DEPOSIT ITEM
|
||||
press(pc, input, "right", "right", "right") -- ITEM -> BALL -> KEY_ITEM -> TM_HM
|
||||
press(pc, input, "a") -- choose HM01
|
||||
eq(pc.qtyState, nil, "no quantity selector for an HM")
|
||||
eq(save.pcItems.HM_CUT, 1, "HM01 lands in the PC")
|
||||
eq(save.inventory.HM_CUT, nil, "and leaves the bag")
|
||||
end
|
||||
|
||||
-- PlaceMenuItemQuantity: the PC list draws no xNN for a CANT_TOSS row.
|
||||
do
|
||||
local save = newSave(1)
|
||||
local game = newGame(save)
|
||||
save.pcItems = { POTION = 3, HM_CUT = 1 }
|
||||
local pc = ItemPcMenu.new(game, { save = save, items = ITEMS })
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local TIMES = "\xc3\x97"
|
||||
local saved = { print = Chrome.print, box = Chrome.box,
|
||||
cursor = Chrome.cursor }
|
||||
local printed = {}
|
||||
Chrome.print = function(text, x, y)
|
||||
printed[#printed + 1] = { text = text, x = x, y = y }
|
||||
end
|
||||
Chrome.box = function() end
|
||||
Chrome.cursor = function() end
|
||||
pc.phase = "withdraw"
|
||||
pc:rebuild()
|
||||
local drew, err = pcall(function() pc:drawList() end)
|
||||
Chrome.print, Chrome.box, Chrome.cursor = saved.print, saved.box, saved.cursor
|
||||
check(drew, "the PC list draws: " .. tostring(err))
|
||||
local rowY = {}
|
||||
for i, row in ipairs(pc.rows) do rowY[row.id] = i * 2 end
|
||||
local counts = {}
|
||||
for _, p in ipairs(printed) do
|
||||
if p.x == 7 then counts[p.y] = p.text end
|
||||
end
|
||||
eq(counts[rowY.POTION + 1], TIMES .. " 3", "the POTION stack keeps its xNN")
|
||||
eq(counts[rowY.HM_CUT + 1], nil,
|
||||
"and the HM row draws none (PlaceMenuItemQuantity .done)")
|
||||
end
|
||||
|
||||
-- An empty bag never opens the PACK (.CheckItemsInBag).
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
-- T4: a mod may not reach a Gen 2 engine module from a Gen 1 game.
|
||||
--
|
||||
-- src/battle/gen2/Mon.lua returns { hp, attack, defense, speed, specialAttack,
|
||||
-- specialDefense }; a Gen 1 party mon carries `special` instead. A Yellow mod
|
||||
-- that required that module and ran refreshStats over save.party wrote the Gen
|
||||
-- 2 block onto Gen 1 mons, and every reader of stats.special raised from then
|
||||
-- on (#1517). The require shim refuses it now, whatever the manifest declares:
|
||||
-- engine_internals is a disclosure, not a generation gate.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local PROBE = [[
|
||||
local mod = ...
|
||||
local out = mod.exports
|
||||
local function attempt(name)
|
||||
local ok, result = pcall(require, name)
|
||||
if ok then return nil, type(result) end
|
||||
return tostring(result), nil
|
||||
end
|
||||
out.attempt = attempt
|
||||
out.monErr, out.monType = attempt("src.battle.gen2.Mon")
|
||||
out.saveErr = attempt("src.core.gen2.Save")
|
||||
out.worldErr = attempt("src.ui.gen2.Chrome")
|
||||
out.game2Err = attempt("src.core.Game2")
|
||||
out.semverErr, out.semverType = attempt("src.mods.Semver")
|
||||
out.statsErr, out.statsType = attempt("src.pokemon.Stats")
|
||||
out.compatErr, out.compatType = attempt("src.mods.Gen2Compat")
|
||||
out.loggerErr, out.loggerType = attempt("src.core.Logger")
|
||||
]]
|
||||
|
||||
local function manifest(id, permissions)
|
||||
return ('{"id":"%s","name":"%s","version":"1.0.0","entry":"main.lua",'
|
||||
.. '"api":2,"games":["all"]%s}'):format(id, id, permissions or "")
|
||||
end
|
||||
|
||||
-- ------- Gen 1 boot: refused, and the permission does not unlock it
|
||||
|
||||
local FILES = {
|
||||
["mods/gen2_declared/manifest.json"] =
|
||||
manifest("gen2_declared", ',"permissions":["engine_internals"]'),
|
||||
["mods/gen2_declared/main.lua"] = PROBE,
|
||||
["mods/gen2_undeclared/manifest.json"] = manifest("gen2_undeclared"),
|
||||
["mods/gen2_undeclared/main.lua"] = PROBE,
|
||||
}
|
||||
|
||||
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
|
||||
"the Gen 2 mon module is not loaded before the Gen 1 boot")
|
||||
|
||||
local run = T.sdk.loadMods({ "mods/gen2_declared", "mods/gen2_undeclared" },
|
||||
{ fs = T.sdk.memfs(FILES), data = {}, generation = 1 })
|
||||
T.eq(#run.errors, 0,
|
||||
"a mod that only probes still loads clean (" .. tostring(run.errors[1]) .. ")")
|
||||
|
||||
local declared = run.loader.exports.gen2_declared or {}
|
||||
local undeclared = run.loader.exports.gen2_undeclared or {}
|
||||
|
||||
T.check(declared.monErr, "src.battle.gen2.Mon is refused on a Gen 1 game")
|
||||
T.check(declared.monErr and declared.monErr:find("src.battle.gen2.Mon", 1, true),
|
||||
"the refusal names the module: " .. tostring(declared.monErr))
|
||||
T.check(declared.monErr and declared.monErr:find("gen2_declared", 1, true),
|
||||
"and the mod that asked for it")
|
||||
T.check(declared.monErr and declared.monErr:find("mod.game", 1, true),
|
||||
"and the surface to use instead")
|
||||
T.eq(declared.monType, nil, "nothing came back for the mod to call")
|
||||
T.eq(package.loaded["src.battle.gen2.Mon"], nil,
|
||||
"and the module was never loaded at all")
|
||||
|
||||
T.check(declared.saveErr, "src.core.gen2.Save is refused the same way")
|
||||
T.check(declared.worldErr, "so is src.ui.gen2.Chrome")
|
||||
T.check(declared.game2Err and declared.game2Err:find("src.core.Game2", 1, true),
|
||||
"and src.core.Game2, the Gen 2 service owner: " .. tostring(declared.game2Err))
|
||||
|
||||
T.check(undeclared.monErr,
|
||||
"a mod with no permissions is refused for the same reason")
|
||||
T.eq(declared.monErr and declared.monErr:gsub("gen2_declared", "X"),
|
||||
undeclared.monErr and undeclared.monErr:gsub("gen2_undeclared", "X"),
|
||||
"engine_internals is a disclosure, not a generation gate: same refusal")
|
||||
|
||||
-- ------- what the gate does NOT touch
|
||||
|
||||
T.eq(declared.semverErr, nil, "the supported requires still resolve")
|
||||
T.eq(declared.semverType, "table", "and hand back the module")
|
||||
T.eq(declared.statsErr, nil, "src.pokemon.Stats is still published to mods")
|
||||
T.eq(declared.compatErr, nil,
|
||||
"src.mods.Gen2Compat is not a Gen 2 engine module: it is the adapter table")
|
||||
T.eq(declared.compatType, "table", "so it still answers")
|
||||
T.eq(declared.loggerErr, nil,
|
||||
"an undeclared engine require is still a warning, not a block")
|
||||
T.eq(declared.loggerType, "table", "and still resolves")
|
||||
T.eq(undeclared.loggerErr, nil,
|
||||
"including for a mod that declared no permissions at all")
|
||||
|
||||
-- a lazy require made long after the entry chunk ran is gated too: the mod's
|
||||
-- own require is what the shim identifies, not the load phase
|
||||
do
|
||||
local err = declared.attempt and declared.attempt("src.world.gen2.World")
|
||||
T.check(err and err:find("src.world.gen2.World", 1, true),
|
||||
"a require made after load is refused too: " .. tostring(err))
|
||||
end
|
||||
|
||||
-- the shim only judges a mod's own require; engine and harness callers keep
|
||||
-- the module they asked for
|
||||
do
|
||||
local ok, module = pcall(require, "src.world.gen2.WorldAPI")
|
||||
T.check(ok, "a non-mod caller still reaches a Gen 2 module: " .. tostring(module))
|
||||
T.eq(type(module), "table", "and gets the real one")
|
||||
end
|
||||
|
||||
run.release()
|
||||
|
||||
-- ------- an unguarded reach fails the whole mod, on the boot error feed
|
||||
|
||||
local HARD = {
|
||||
["mods/gen2_hard/manifest.json"] = manifest("gen2_hard",
|
||||
',"permissions":["engine_internals"]'),
|
||||
["mods/gen2_hard/main.lua"] = [[
|
||||
local mod = ...
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
mod.exports.reached = Mon ~= nil
|
||||
]],
|
||||
}
|
||||
local hard = T.sdk.loadMods({ "mods/gen2_hard" },
|
||||
{ fs = T.sdk.memfs(HARD), data = {}, generation = 1 })
|
||||
T.eq(hard.loader.mods.gen2_hard.state, "failed",
|
||||
"a mod that reaches across the generation does not load")
|
||||
T.eq(hard.loader.exports.gen2_hard, nil, "and publishes nothing")
|
||||
T.check(hard.errors[1] and hard.errors[1]:find("src.battle.gen2.Mon", 1, true),
|
||||
"the boot error feed names the module: " .. tostring(hard.errors[1]))
|
||||
hard.release()
|
||||
|
||||
-- ------- Gen 2 boot: the same require is the mod's own generation
|
||||
|
||||
local gen2 = T.sdk.loadMods({ "mods/gen2_declared" },
|
||||
{ fs = T.sdk.memfs(FILES), data = {}, generation = 2 })
|
||||
local onGold = gen2.loader.exports.gen2_declared or {}
|
||||
T.eq(onGold.monErr, nil,
|
||||
"on a Gen 2 game the same require is answered: " .. tostring(onGold.monErr))
|
||||
T.eq(onGold.monType, "table", "with the real Gen 2 mon module")
|
||||
T.eq(onGold.game2Err, nil, "and src.core.Game2 is this game's service owner")
|
||||
gen2.release()
|
||||
|
||||
T.finish("cross_generation_require")
|
||||
@@ -311,6 +311,46 @@ ow.transitioning = false -- undo the queued warp transition
|
||||
Game.save.onBike = false
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
|
||||
-- home/overworld.asm:1224
|
||||
local function settle(o)
|
||||
drainText()
|
||||
for _ = 1, 60 do o:updateScriptMoves(); o.player:update() end
|
||||
end
|
||||
for _, c in ipairs({ { "ROUTE_16", 17, 10 }, { "ROUTE_16", 17, 11 },
|
||||
{ "ROUTE_18", 33, 8 }, { "ROUTE_18", 33, 9 } }) do
|
||||
Game.save.inventory.BICYCLE = nil
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
pushOW(c[1], c[2], c[3], "left")
|
||||
ow = OW
|
||||
check(not ow.map:isWalkableCell(c[2] + 1, c[3]),
|
||||
("%s (%d,%d): the cell behind a left-facing arrival is the gate wall")
|
||||
:format(c[1], c[2], c[3]))
|
||||
settle(ow)
|
||||
eq(#ow.scriptMoves, 0,
|
||||
("%s (%d,%d): the refusal shove settles"):format(c[1], c[2], c[3]))
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
("%s (%d,%d): the bikeless Cycling Road refusal never shoves into the "
|
||||
.. "gate wall"):format(c[1], c[2], c[3]))
|
||||
end
|
||||
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
pushOW("ROUTE_16", 17, 10, "right")
|
||||
ow = OW
|
||||
settle(ow)
|
||||
eq(ow.player.cellX, 16, "an unobstructed refusal shove still moves one cell")
|
||||
eq(ow.player.cellY, 10, "and only along the arrival axis")
|
||||
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
ow:setMap("ROUTE_16", 18, 10, "left", { via = "boot" })
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"a save parked inside the Route 16 gate wall is lifted out on load")
|
||||
eq(ow.player.cellY, 10, "the repair stays on the row it was saved on")
|
||||
settle(ow)
|
||||
check(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY),
|
||||
"and the refusal it lands on cannot push it back in")
|
||||
popToOW()
|
||||
Game.save.onBike, Game.save.forcedBike = false, nil
|
||||
|
||||
-- Seafoam B4F: only the stairs square (dbmapcoord 7,11) refuses, and only
|
||||
-- until BOTH boulders are down (CheckBothEventsSet)
|
||||
Game.save.flags["EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE"] = nil
|
||||
|
||||
@@ -42,7 +42,8 @@ local function trace(tb)
|
||||
if row.text then
|
||||
out[#out + 1] = { text = (row.text:gsub("\n", " ")) }
|
||||
elseif row.anim then
|
||||
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer }
|
||||
out[#out + 1] = { anim = row.anim, isPlayer = row.attackerIsPlayer,
|
||||
hit = row.hit }
|
||||
elseif row.drain then
|
||||
out[#out + 1] = { drain = true }
|
||||
end
|
||||
@@ -95,6 +96,9 @@ do
|
||||
check(spiral ~= nil, "the storing turn plays XSTATITEM_ANIM")
|
||||
eq(rows[spiral] and rows[spiral].isPlayer, true,
|
||||
"on the player's side of the field")
|
||||
-- effects.asm:1461-1471
|
||||
eq(rows[spiral] and rows[spiral].hit and rows[spiral].hit.animType, 6,
|
||||
"PlayBattleAnimation2 stamps wAnimationType 6 on the player's turn (#1564)")
|
||||
local used = indexOfText(rows, "used BIDE!")
|
||||
local storing = indexOfText(rows, "storing energy!")
|
||||
check(used and spiral and used < spiral,
|
||||
@@ -119,6 +123,8 @@ do
|
||||
local dup = indexOfAnim(rows, "XSTATITEM_DUPLICATE_ANIM")
|
||||
check(dup ~= nil, "the foe's storing turn plays XSTATITEM_DUPLICATE_ANIM")
|
||||
check(dup and not rows[dup].isPlayer, "attributed to the enemy side")
|
||||
eq(rows[dup] and rows[dup].hit and rows[dup].hit.animType, 3,
|
||||
"and wAnimationType 3 on the enemy's turn (#1564)")
|
||||
end
|
||||
|
||||
-- locked turns: .BideCheck decrements with no text of its own in pokered and
|
||||
@@ -151,6 +157,10 @@ do
|
||||
check(unleashed ~= nil, "the release prints UnleashedEnergyText")
|
||||
check(hit ~= nil, "the release plays BIDE's own animation (#375)")
|
||||
eq(rows[hit] and rows[hit].isPlayer, true, "from the player's side")
|
||||
-- core.asm:3526-3528 -> effects.asm:1476-1477
|
||||
check(rows[hit] and rows[hit].hit == nil,
|
||||
".UnleashEnergy rejoins PlayCurrentMoveAnimation, which zeroes "
|
||||
.. "wAnimationType")
|
||||
check(unleashed and hit and unleashed < hit,
|
||||
"the animation follows the text")
|
||||
local drainAt
|
||||
|
||||
Reference in New Issue
Block a user