mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-15 07:41:21 +02:00
fix merge conflicts with dev
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
-- #1091: does GAME SPEED change the wild encounter rate per STEP?
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1091_test.lua love .
|
||||
local Encounter = require("src.battle.gen2.Encounter")
|
||||
local Player = require("src.world.gen2.Player")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local rolls, hits, steps = 0, 0, 0
|
||||
|
||||
local realTriggers = Encounter.triggers
|
||||
Encounter.triggers = function(rate, random)
|
||||
rolls = rolls + 1
|
||||
if realTriggers(rate, random) then hits = hits + 1 end
|
||||
return false
|
||||
end
|
||||
|
||||
local World = require("src.world.gen2.World")
|
||||
local ticks = 0
|
||||
local realWorldStep = World.step
|
||||
World.step = function(self)
|
||||
ticks = ticks + 1
|
||||
return realWorldStep(self)
|
||||
end
|
||||
|
||||
local realUpdate = Player.update
|
||||
Player.update = function(self)
|
||||
local landed = realUpdate(self)
|
||||
if landed then steps = steps + 1 end
|
||||
return landed
|
||||
end
|
||||
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function clearDirs()
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
game.input.state[d] = false
|
||||
end
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
|
||||
local function measure(speed, yields)
|
||||
world:setMap("ROUTE_29", 44, 12, "right")
|
||||
wait(20)
|
||||
world.wildCooldown = 0
|
||||
game.speedOverride = speed
|
||||
rolls, hits, steps, ticks = 0, 0, 0, 0
|
||||
local dir = "right"
|
||||
for i = 1, yields do
|
||||
if i % 8 == 0 then dir = (dir == "right") and "left" or "right" end
|
||||
clearDirs()
|
||||
game.input.state[dir] = true
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
coroutine.yield()
|
||||
end
|
||||
clearDirs()
|
||||
game.speedOverride = 1
|
||||
wait(5)
|
||||
return rolls, hits, steps, ticks
|
||||
end
|
||||
|
||||
local r1, h1, s1, t1 = measure(1, 900)
|
||||
local r2, h2, s2, t2 = measure(200, 60)
|
||||
print(("[driver] 1X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
|
||||
:format(t1, s1, r1, h1, s1 > 0 and r1 / s1 or 0))
|
||||
print(("[driver] 200X frames=%d cells=%d rolls=%d hits=%d rolls/cell=%.3f")
|
||||
:format(t2, s2, r2, h2, s2 > 0 and r2 / s2 or 0))
|
||||
|
||||
Encounter.triggers = realTriggers
|
||||
Player.update = realUpdate
|
||||
World.step = realWorldStep
|
||||
local rate1 = s1 > 0 and r1 / s1 or 0
|
||||
local rate2 = s2 > 0 and r2 / s2 or 0
|
||||
assert(s1 > 20 and s2 > 20, "not enough walked cells to judge")
|
||||
assert(math.abs(rate1 - rate2) < 0.05,
|
||||
("rolls per cell changed with GAME SPEED: %.3f vs %.3f"):format(rate1, rate2))
|
||||
print("[driver] PASS encounter rolls are one per walked cell at every speed")
|
||||
end
|
||||
@@ -0,0 +1,79 @@
|
||||
-- #1097 (and the absorbed #1116): walking out of Dark Cave and pressing UP
|
||||
-- puts the player inside the mountain on Route 31.
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1097_test.lua love .
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
return function(game)
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function clearDirs()
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
game.input.state[d] = false
|
||||
end
|
||||
end
|
||||
local function hold(dir, frames, trace)
|
||||
local last
|
||||
for _ = 1, frames do
|
||||
clearDirs()
|
||||
game.input.state[dir] = true
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
coroutine.yield()
|
||||
local w = game.world
|
||||
local at = ("%s (%d,%d)"):format(w.map.id, w.player.cellX, w.player.cellY)
|
||||
if trace and at ~= last then print(" " .. at); last = at end
|
||||
end
|
||||
clearDirs()
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
|
||||
-- Route31CheckMomCallCallback (maps/Route31.asm:14) fires on every NEWMAP
|
||||
-- arrival until the errand is over; set its event so the walk is not spent
|
||||
-- behind a phone call. tests/drivers/gold/flag_names.lua:1132.
|
||||
if world.events then world.events:set(64, true) end
|
||||
|
||||
-- maps/DarkCaveVioletEntrance.asm warp 1 is (3,15) -> ROUTE_31 warp 3.
|
||||
world:setMap("DARK_CAVE_VIOLET_ENTRANCE", 3, 14, "down")
|
||||
wait(20)
|
||||
hold("down", 40)
|
||||
-- the ROUTE_31 arrival runs Route31CheckMomCallCallback (maps/Route31.asm:14)
|
||||
for _ = 1, 900 do
|
||||
if not world:busy() then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
wait(20)
|
||||
print(("[driver] out of the cave at %s (%d,%d)")
|
||||
:format(world.map.id, world.player.cellX, world.player.cellY))
|
||||
assert(world.map.id == "ROUTE_31", "did not reach ROUTE_31")
|
||||
|
||||
print("[driver] holding UP:")
|
||||
hold("up", 120, true)
|
||||
wait(30)
|
||||
local m, x, y = world.map.id, world.player.cellX, world.player.cellY
|
||||
print(("[driver] ended at %s (%d,%d)"):format(m, x, y))
|
||||
-- Anything above y=5 on Route 31 in that column is the inside of the
|
||||
-- mountain; the honest outcomes are re-entering the cave or bumping.
|
||||
local inside = (m == "ROUTE_31" and y < 5)
|
||||
assert(not inside, ("walked into the mountain at (%d,%d)"):format(x, y))
|
||||
-- The other two Dark Cave mouths, same shape: maps/Route46.asm warp 3 is
|
||||
-- (14,5) and maps/Route45.asm warp 1 is (2,5), both COLL_CAVE tiles with
|
||||
-- ordinary FLOOR above them in the collision data.
|
||||
for _, c in ipairs({ { "ROUTE_46", 14, 5 }, { "ROUTE_45", 2, 5 } }) do
|
||||
local mapId, wx, wy = c[1], c[2], c[3]
|
||||
world:setMap(mapId, wx, wy, "up")
|
||||
wait(20)
|
||||
hold("up", 90)
|
||||
wait(20)
|
||||
if world.map.id == mapId then
|
||||
assert(world.player.cellY >= wy,
|
||||
("%s: walked above the cave mouth to (%d,%d)")
|
||||
:format(mapId, world.player.cellX, world.player.cellY))
|
||||
end
|
||||
print(("[driver] %s mouth ended at %s (%d,%d)"):format(
|
||||
mapId, world.map.id, world.player.cellX, world.player.cellY))
|
||||
end
|
||||
|
||||
print("[driver] PASS the cave mouth cannot be walked through")
|
||||
end
|
||||
@@ -0,0 +1,78 @@
|
||||
-- #1104: the Ruins of Alph ladders warp somewhere else.
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1104_test.lua love .
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- maps/RuinsOfAlphInnerChamber.asm:76, maps/RuinsOfAlphOutside.asm:126,
|
||||
-- maps/RuinsOfAlphKabutoChamber.asm and its three siblings.
|
||||
local CASES = {
|
||||
{ "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13, "RUINS_OF_ALPH_OUTSIDE", 10, 13 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 10, 13, "RUINS_OF_ALPH_INNER_CHAMBER", 10, 13 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 2, 17, "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 14, 7, "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 2, 29, "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 16, 33, "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9 },
|
||||
{ "RUINS_OF_ALPH_OUTSIDE", 17, 11, "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7 },
|
||||
{ "RUINS_OF_ALPH_KABUTO_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 14, 7, "down" },
|
||||
{ "RUINS_OF_ALPH_HO_OH_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 17, "down" },
|
||||
{ "RUINS_OF_ALPH_OMANYTE_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 2, 29, "down" },
|
||||
{ "RUINS_OF_ALPH_AERODACTYL_CHAMBER", 3, 9, "RUINS_OF_ALPH_OUTSIDE", 16, 33, "down" },
|
||||
{ "RUINS_OF_ALPH_RESEARCH_CENTER", 2, 7, "RUINS_OF_ALPH_OUTSIDE", 17, 11, "down" },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function clearDirs()
|
||||
for _, d in ipairs({ "up", "down", "left", "right" }) do
|
||||
game.input.state[d] = false
|
||||
end
|
||||
end
|
||||
-- Hold until the map changes, then let the load settle and STOP: holding
|
||||
-- past the arrival would walk the player off the landing tile.
|
||||
local function walkUntilWarp(dir, frames, fromMap)
|
||||
for _ = 1, frames do
|
||||
clearDirs()
|
||||
game.input.state[dir] = true
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
coroutine.yield()
|
||||
if game.world.map and game.world.map.id ~= fromMap then
|
||||
clearDirs()
|
||||
-- The ARRIVAL cell is the one the warp table names. Standing on a
|
||||
-- door/cave/staircase tile then forces one step DOWN off it
|
||||
-- (engine/overworld/player_movement.asm:201-213), so the settled cell
|
||||
-- is legitimately one row lower on an outdoor mouth.
|
||||
local ax, ay = game.world.player.cellX, game.world.player.cellY
|
||||
for _ = 1, 45 do coroutine.yield() end
|
||||
return game.world.map.id, ax, ay,
|
||||
game.world.player.cellX, game.world.player.cellY
|
||||
end
|
||||
end
|
||||
clearDirs()
|
||||
return game.world.map and game.world.map.id, nil, nil
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
|
||||
local bad = 0
|
||||
for _, c in ipairs(CASES) do
|
||||
local src, wx, wy, wantMap, wantX, wantY = c[1], c[2], c[3], c[4], c[5], c[6]
|
||||
-- default approach is from below, the way the report describes facing the
|
||||
-- ladder from below; a chamber's exit sits on the bottom row instead
|
||||
local dir = c[7] or "up"
|
||||
local sy = (dir == "up") and wy + 1 or wy - 1
|
||||
world:setMap(src, wx, sy, dir)
|
||||
wait(12)
|
||||
local from = ("%s (%d,%d)"):format(src, world.player.cellX, world.player.cellY)
|
||||
local m, ax, ay, sx, sy = walkUntilWarp(dir, 90, src)
|
||||
local ok = (m == wantMap and ax == wantX and ay == wantY)
|
||||
if not ok then bad = bad + 1 end
|
||||
print(("[driver] %-12s %s -> %s (%s,%s) settled (%s,%s) want %s (%d,%d)")
|
||||
:format(ok and "OK" or "MISMATCH", from, tostring(m),
|
||||
tostring(ax), tostring(ay), tostring(sx), tostring(sy),
|
||||
wantMap, wantX, wantY))
|
||||
end
|
||||
print(("[driver] %s ruins warps: %d mismatches")
|
||||
:format(bad == 0 and "PASS" or "FAIL", bad))
|
||||
end
|
||||
@@ -0,0 +1,100 @@
|
||||
-- #1155: "MAGNITUDE always rolls a 4".
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1155_test.lua love .
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local ROLLS = 15
|
||||
|
||||
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 drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1155"
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
local caster = Mon.new(game.data, "DUNSPARCE", 50)
|
||||
local def = assert(game.data.moves.MAGNITUDE, "MAGNITUDE is not in moves.lua")
|
||||
caster.moves = { { id = "MAGNITUDE", pp = def.pp, maxPp = def.pp } }
|
||||
print("[driver] MAGNITUDE stored effect = " .. tostring(def.effect)
|
||||
.. ", stored power = " .. tostring(def.power))
|
||||
game.save.party = { caster }
|
||||
|
||||
local seen, damages, shown = {}, {}, 0
|
||||
local screen
|
||||
for i = 1, ROLLS do
|
||||
if not (screen and game.stack:top() == screen and not screen.battle.over) then
|
||||
local dummy = Mon.new(game.data, "SNORLAX", 60)
|
||||
dummy.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = dummy }), "startBattle failed")
|
||||
screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
end
|
||||
local foe = screen.battle.enemy
|
||||
local before = foe.hp
|
||||
caster.moves[1].pp = def.pp
|
||||
screen:submit({ kind = "move", move = "MAGNITUDE" })
|
||||
for _, event in ipairs(screen.queue) do
|
||||
local number = event.text and event.text:match("^Magnitude (%d+)")
|
||||
if number then
|
||||
seen[number] = (seen[number] or 0) + 1
|
||||
if shown < 1 then
|
||||
U.wait(1)
|
||||
U.shot(game, out .. "/01-magnitude-text.png")
|
||||
shown = 1
|
||||
end
|
||||
end
|
||||
end
|
||||
drain(game, screen, 400)
|
||||
local dealt = before - foe.hp
|
||||
if dealt > 0 then damages[dealt] = true end
|
||||
if i % 10 == 0 then U.wait(5) end
|
||||
end
|
||||
|
||||
local numbers = {}
|
||||
for number, count in pairs(seen) do
|
||||
numbers[#numbers + 1] = number .. "x" .. count
|
||||
end
|
||||
table.sort(numbers)
|
||||
print("[driver] magnitudes over " .. ROLLS .. " uses: "
|
||||
.. table.concat(numbers, " "))
|
||||
local distinctPowers = 0
|
||||
for _ in pairs(damages) do distinctPowers = distinctPowers + 1 end
|
||||
|
||||
local failures = {}
|
||||
local function check(ok, what)
|
||||
print((ok and "[ok] " or "[FAIL] ") .. what)
|
||||
if not ok then failures[#failures + 1] = what end
|
||||
end
|
||||
check(#numbers > 1, "MAGNITUDE rolls more than one number")
|
||||
check(seen["4"] == nil or seen["4"] < ROLLS,
|
||||
"and is not stuck on MAGNITUDE 4")
|
||||
check(distinctPowers > 1, "the rolled power reaches the damage (" ..
|
||||
distinctPowers .. " distinct damage figures)")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " magnitude checks failed")
|
||||
end
|
||||
print("[driver] #1155 clear; shot in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
-- #1157: SONIC BOOM has to deal a flat 20, not a rolled 8-10.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1157_test.lua love .
|
||||
|
||||
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 drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1157"
|
||||
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
|
||||
|
||||
-- The reporter's own matchup, three times over.
|
||||
local caster = Mon.new(game.data, "MAGNEMITE", 20)
|
||||
local boom = assert(game.data.moves.SONICBOOM, "SONICBOOM missing")
|
||||
print("[driver] SONICBOOM effect = " .. tostring(boom.effect)
|
||||
.. ", stored power = " .. tostring(boom.power))
|
||||
caster.moves = { { id = "SONICBOOM", pp = boom.pp, maxPp = boom.pp } }
|
||||
game.save.party = { caster }
|
||||
|
||||
-- SONIC BOOM is a 90% move, so a miss (0) is legal and simply does not
|
||||
-- count: what matters is that every use that CONNECTS is 20.
|
||||
local dealt, hits, wrong = {}, 0, 0
|
||||
for i = 1, 6 do
|
||||
local rattata = Mon.new(game.data, "RATTATA", 11)
|
||||
rattata.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = rattata }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
local before = rattata.hp
|
||||
caster.hp = caster.maxHp or caster.hp
|
||||
caster.moves[1].pp = boom.pp
|
||||
screen:submit({ kind = "move", move = "SONICBOOM" })
|
||||
drain(game, screen, 400)
|
||||
dealt[i] = before - rattata.hp
|
||||
if dealt[i] > 0 then
|
||||
hits = hits + 1
|
||||
if dealt[i] ~= 20 then wrong = wrong + 1 end
|
||||
end
|
||||
if i == 1 then U.shot(game, out .. "/01-sonicboom.png") end
|
||||
for _ = 1, 400 do
|
||||
if not game.stack:top() or not game.stack:top().battle then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(45)
|
||||
end
|
||||
print("[driver] SONIC BOOM dealt " .. table.concat(dealt, ", ")
|
||||
.. " (0 is a miss)")
|
||||
check(hits >= 3, "SONIC BOOM connected at least three times")
|
||||
check(wrong == 0, "and every hit was exactly 20")
|
||||
|
||||
-- The two siblings on the same command.
|
||||
local dragon = Mon.new(game.data, "DRATINI", 25)
|
||||
local rage = assert(game.data.moves.DRAGON_RAGE, "DRAGON_RAGE missing")
|
||||
local toss = assert(game.data.moves.SEISMIC_TOSS, "SEISMIC_TOSS missing")
|
||||
dragon.moves = {
|
||||
{ id = "DRAGON_RAGE", pp = rage.pp, maxPp = rage.pp },
|
||||
{ id = "SEISMIC_TOSS", pp = toss.pp, maxPp = toss.pp },
|
||||
}
|
||||
game.save.party = { dragon }
|
||||
local target = Mon.new(game.data, "SNORLAX", 60)
|
||||
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = target }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
local before = target.hp
|
||||
screen:submit({ kind = "move", move = "DRAGON_RAGE" })
|
||||
drain(game, screen, 400)
|
||||
local rageDealt = before - target.hp
|
||||
before = target.hp
|
||||
screen:submit({ kind = "move", move = "SEISMIC_TOSS" })
|
||||
drain(game, screen, 400)
|
||||
local tossDealt = before - target.hp
|
||||
U.shot(game, out .. "/02-siblings.png")
|
||||
print(("[driver] DRAGON RAGE %d, SEISMIC TOSS %d (user is L%d)")
|
||||
:format(rageDealt, tossDealt, dragon.level))
|
||||
check(rageDealt == 40, "DRAGON RAGE deals exactly 40")
|
||||
check(tossDealt == dragon.level, "SEISMIC TOSS deals the user's level")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " constant-damage checks failed")
|
||||
end
|
||||
print("[driver] #1157 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
-- #1158: you could not see the trainer's incoming mon before the switch offer.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1158_test.lua love .
|
||||
|
||||
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
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1158"
|
||||
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
|
||||
|
||||
-- SHIFT is the only style that offers at all (CheckWhetherToAskSwitch).
|
||||
game.options = game.options or {}
|
||||
game.options.battleStyle = "SHIFT"
|
||||
|
||||
local lead = Mon.new(game.data, "TYPHLOSION", 40)
|
||||
lead.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local bench = Mon.new(game.data, "FERALIGATR", 40)
|
||||
bench.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
game.save.party = { lead, bench }
|
||||
|
||||
local foe1 = Mon.new(game.data, "PIDGEY", 5)
|
||||
foe1.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
local foe2 = Mon.new(game.data, "PIDGEOTTO", 20)
|
||||
foe2.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
assert(world:startBattle({ trainer = { class = "FALKNER", name = "FALKNER",
|
||||
party = { foe1, foe2 } } }), "trainer startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
runToPhase(game, screen, "menu", 400)
|
||||
|
||||
foe1.hp = 1
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
|
||||
-- Page by page, collecting what the box actually showed.
|
||||
local pages, sawBoxWithName = {}, false
|
||||
local incoming = foe2.nickname or foe2.species or "PIDGEOTTO"
|
||||
for _ = 1, 900 do
|
||||
local message = screen.message
|
||||
if message and pages[#pages] ~= message
|
||||
and (screen.phase == "shift-intro" or screen.phase == "ask-shift") then
|
||||
pages[#pages + 1] = message
|
||||
if #pages == 1 then U.shot(game, out .. "/01-first-page.png") end
|
||||
if message:find(incoming, 1, true) then
|
||||
U.shot(game, out .. "/02-names-the-mon.png")
|
||||
sawBoxWithName = true
|
||||
end
|
||||
end
|
||||
if screen.phase == "ask-shift" and (screen.messageTimer or 0) <= 0 then
|
||||
break
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
U.shot(game, out .. "/03-yes-no.png")
|
||||
|
||||
for i, page in ipairs(pages) do
|
||||
print(("[driver] page %d: %s"):format(i, (page:gsub("\n", " / "))))
|
||||
end
|
||||
check(sawBoxWithName,
|
||||
"the incoming " .. incoming .. " is named in the box before the yes/no")
|
||||
check(screen.phase == "ask-shift",
|
||||
"and the prompt still ends on the YES/NO question")
|
||||
check(#pages > 1, "the offer runs as pages, not one over-long line")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " send-out order checks failed")
|
||||
end
|
||||
print("[driver] #1158 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,131 @@
|
||||
-- #1159 (Fly / Dig leave the user on the field) and #1160 (64x64 front pics).
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1159_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
|
||||
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 drain(game, screen, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if screen.phase == "menu" and #screen.queue == 0 and not screen.anim then
|
||||
return true
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- One drawPic call with love.graphics.draw intercepted: how many blits it made
|
||||
-- and where the first one landed.
|
||||
local function probePic(screen, mon, back)
|
||||
local G = love.graphics
|
||||
local real = G.draw
|
||||
local count, x, y = 0, nil, nil
|
||||
G.draw = function(image, a, b, ...)
|
||||
count = count + 1
|
||||
if count == 1 then
|
||||
-- draw(image, x, y, ...) or draw(image, quad, x, y, ...)
|
||||
if type(a) == "number" then x, y = a, b else x, y = b, (...) end
|
||||
end
|
||||
end
|
||||
local ok, err = pcall(screen.drawPic, screen, mon, back)
|
||||
G.draw = real
|
||||
if not ok then error(err) end
|
||||
return count, x, y
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1159"
|
||||
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
|
||||
|
||||
------------------------------------------------------------------ #1159
|
||||
local digger = Mon.new(game.data, "SANDSHREW", 30)
|
||||
local dig = assert(game.data.moves.DIG, "DIG missing from the Gold cache")
|
||||
digger.moves = { { id = "DIG", pp = dig.pp, maxPp = dig.pp } }
|
||||
game.save.party = { digger }
|
||||
local target = Mon.new(game.data, "SNORLAX", 40)
|
||||
target.moves = { { id = "SPLASH", pp = 40, maxPp = 40 } }
|
||||
assert(world:startBattle({ wild = target }), "startBattle failed")
|
||||
local screen = battleScreen(game)
|
||||
drain(game, screen, 300)
|
||||
|
||||
local before = probePic(screen, screen.battle.player, true)
|
||||
check(before > 0, "the back pic is on the field before DIG")
|
||||
U.shot(game, out .. "/01-before-dig.png")
|
||||
|
||||
screen:submit({ kind = "move", move = "DIG" })
|
||||
drain(game, screen, 600)
|
||||
|
||||
check(BattleState.isVanished(screen.battle.player) == true,
|
||||
"SUBSTATUS_UNDERGROUND is set after the charge turn")
|
||||
local underground = probePic(screen, screen.battle.player, true)
|
||||
print("[driver] back-pic draws while underground: " .. underground)
|
||||
check(underground == 0, "the back pic is GONE for the charge turn")
|
||||
U.shot(game, out .. "/02-underground.png")
|
||||
|
||||
-- The enemy front pic is untouched by the player's own dig.
|
||||
local foe = probePic(screen, screen.battle.enemy, false)
|
||||
check(foe > 0, "the enemy front pic is unaffected")
|
||||
|
||||
-- Turn two: CheckCharge clears the bit and AppearUserRaiseSub redraws it.
|
||||
screen:submit({ kind = "move", move = "DIG" })
|
||||
drain(game, screen, 600)
|
||||
check(BattleState.isVanished(screen.battle.player) == false,
|
||||
"the substatus is cleared when the stored attack lands")
|
||||
local after = probePic(screen, screen.battle.player, true)
|
||||
print("[driver] back-pic draws after the attack: " .. after)
|
||||
check(after > 0, "the back pic is back on the field")
|
||||
U.shot(game, out .. "/03-resurfaced.png")
|
||||
|
||||
------------------------------------------------------------------ #1160
|
||||
-- A stubbed pic of each size through the same placement code. trueColor is
|
||||
-- set so the probe never enters the GBC palette shader.
|
||||
local realPic = screen.pic
|
||||
local function place(size, back)
|
||||
local data = love.image.newImageData(size, size)
|
||||
local image = love.graphics.newImage(data)
|
||||
screen.pic = function() return image, true, "mods/front" .. size .. ".png" end
|
||||
local n, x, y = probePic(screen, screen.battle.enemy, back)
|
||||
screen.pic = realPic
|
||||
return n, x, y
|
||||
end
|
||||
|
||||
local _, x56, y56 = place(56, false)
|
||||
print(("[driver] 56x56 front at (%s, %s)"):format(tostring(x56), tostring(y56)))
|
||||
check(x56 == 96 and y56 == 0, "vanilla 56x56 front pic is unmoved at (96, 0)")
|
||||
|
||||
local _, x40, y40 = place(40, false)
|
||||
print(("[driver] 40x40 front at (%s, %s)"):format(tostring(x40), tostring(y40)))
|
||||
check(x40 == 104 and y40 == 16,
|
||||
"a small 40x40 front pic still stands on the box's ground line")
|
||||
|
||||
local _, x64, y64 = place(64, false)
|
||||
print(("[driver] 64x64 front at (%s, %s)"):format(tostring(x64), tostring(y64)))
|
||||
check(x64 == 96 and y64 == 0,
|
||||
"a 64x64 front pic pins to the box corner and overlaps the HUD")
|
||||
|
||||
if #failures > 0 then
|
||||
error(#failures .. " checks failed")
|
||||
end
|
||||
print("[driver] #1159 / #1160 fixed; shots in " .. out)
|
||||
end
|
||||
@@ -0,0 +1,82 @@
|
||||
-- #1167: the Ilex Forest hidden Ether and hidden Super Potion.
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1167_test.lua love .
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local HiddenItems = require("src.world.gen2.HiddenItems")
|
||||
|
||||
local CASES = {
|
||||
{ name = "ETHER", x = 27, y = 1, event = 136 },
|
||||
{ name = "SUPER_POTION", x = 17, y = 7, event = 137 },
|
||||
{ name = "FULL_HEAL", x = 9, y = 17, event = 138 },
|
||||
}
|
||||
|
||||
local FACE = {
|
||||
up = { 0, 1 }, down = { 0, -1 }, left = { 1, 0 }, right = { -1, 0 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local function wait(n) for _ = 1, n do coroutine.yield() end end
|
||||
local function tap(btn)
|
||||
table.insert(game.input.pressQueue, btn)
|
||||
game.input.state[btn] = true
|
||||
coroutine.yield()
|
||||
game.input.state[btn] = false
|
||||
coroutine.yield()
|
||||
end
|
||||
|
||||
wait(30)
|
||||
local world = game.world
|
||||
game.save.party = { Mon.new(game.data, "PIDGEY", 5) }
|
||||
world:setMap("ILEX_FOREST", 9, 18, "up")
|
||||
wait(30)
|
||||
assert(world.map.id == "ILEX_FOREST", "not in ILEX_FOREST")
|
||||
|
||||
-- what the engine itself thinks it has for this map
|
||||
local listed = HiddenItems.unfound(world.map.def, world.events)
|
||||
print("[driver] engine hidden items on ILEX_FOREST:")
|
||||
for _, row in ipairs(listed) do
|
||||
print((" (%2d,%2d) item=%s flag=%d")
|
||||
:format(row.x, row.y, tostring(row.item), row.event))
|
||||
end
|
||||
|
||||
local function bagCount(name)
|
||||
local n = 0
|
||||
for _, row in ipairs((game.save.bag and game.save.bag.items) or {}) do
|
||||
if row.item == name or row.id == name then n = n + (row.count or 1) end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local bad = 0
|
||||
for _, c in ipairs(CASES) do
|
||||
local got = false
|
||||
for dir, d in pairs(FACE) do
|
||||
local sx, sy = c.x + d[1], c.y + d[2]
|
||||
if world.map:isWalkable(sx, sy) then
|
||||
world:setMap("ILEX_FOREST", sx, sy, dir)
|
||||
wait(20)
|
||||
world.player.facing = dir
|
||||
local before = bagCount(c.name)
|
||||
tap("a")
|
||||
wait(60)
|
||||
for _ = 1, 240 do
|
||||
if not world:busy() then break end
|
||||
tap("a")
|
||||
end
|
||||
wait(20)
|
||||
if world.events:get(c.event) or bagCount(c.name) > before then
|
||||
got = true
|
||||
print(("[driver] OK %-13s at (%2d,%2d) picked up facing %s")
|
||||
:format(c.name, c.x, c.y, dir))
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not got then
|
||||
bad = bad + 1
|
||||
print(("[driver] MISSING %-13s at (%2d,%2d)"):format(c.name, c.x, c.y))
|
||||
end
|
||||
end
|
||||
print(("[driver] %s ilex hidden items: %d missing")
|
||||
:format(bad == 0 and "PASS" or "FAIL", bad))
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
-- #1208: the launcher's save-slot line read a Gold save with Gen 1 eyes
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1208_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GoldSave = require("src.core.gen2.Save")
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local save = game.save
|
||||
assert(save and save.player, "gold save did not boot")
|
||||
|
||||
save.player.badges = { ZEPHYR = true, HIVE = true, PLAIN = true, FOG = true,
|
||||
STORM = true, MINERAL = true, GLACIER = true, RISING = true }
|
||||
save.player.kantoBadges = { BOULDER = true, CASCADE = true }
|
||||
save.pokedex = save.pokedex or {}
|
||||
save.pokedex.caught = { [155] = true, [158] = true, [152] = true }
|
||||
|
||||
local ok, err = game:writeSave()
|
||||
if not ok then
|
||||
U.log("FAIL gold writeSave:", tostring(err))
|
||||
else
|
||||
local want = GoldSave.summary(save)
|
||||
local active = SaveData.activeSlot("gold")
|
||||
local row
|
||||
for _, slot in ipairs(SaveData.listSlots("gold")) do
|
||||
if slot.id == active then row = slot end
|
||||
end
|
||||
if not (row and row.meta) then
|
||||
U.log("FAIL launcher has no slot row for", tostring(active))
|
||||
else
|
||||
U.log(("launcher: %d badges - %s - %d caught"):format(
|
||||
row.meta.badges, row.meta.timeText, row.meta.dexCount))
|
||||
U.log(("continue: %d badges - %d:%02d - %d caught"):format(
|
||||
want.badges, want.hours, want.minutes, want.caught))
|
||||
if row.meta.badges == want.badges and row.meta.dexCount == want.caught then
|
||||
U.log("PASS launcher summary matches CONTINUE for slot", tostring(active))
|
||||
else
|
||||
U.log("FAIL launcher summary disagrees with CONTINUE")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
-- GameActivity exposes an optional Android host seam without requiring any
|
||||
-- host implementation (or its native libraries) in the stock build.
|
||||
-- Self-contained: luajit tests/engine/android_host_extension_test.lua
|
||||
local path = "mobile/android/love/src/main/java/org/love2d/android/GameActivity.java"
|
||||
local file = assert(io.open(path, "rb"))
|
||||
local source = file:read("*a")
|
||||
file:close()
|
||||
|
||||
local function check(value, message)
|
||||
if not value then error(message, 2) end
|
||||
end
|
||||
|
||||
local function position(text)
|
||||
local p = source:find(text, 1, true)
|
||||
check(p, "missing: " .. text)
|
||||
return p
|
||||
end
|
||||
|
||||
check(source:find("protected String[] getHostLibraries()", 1, true),
|
||||
"host libraries are an overridable protected extension")
|
||||
check(source:find("return new String[0];", 1, true),
|
||||
"the vanilla host library list is empty")
|
||||
|
||||
local cpp = position('libraries[0] = "c++_shared";')
|
||||
local mpg = position('libraries[1] = "mpg123";')
|
||||
local openal = position('libraries[2] = "openal";')
|
||||
local host = position("System.arraycopy(hostLibraries, 0, libraries, 3, hostLibraries.length);")
|
||||
local love = position('libraries[libraries.length - 1] = "love";')
|
||||
check(cpp < mpg and mpg < openal and openal < host and host < love,
|
||||
"optional libraries load after dependencies while liblove remains last")
|
||||
|
||||
for _, hook in ipairs({
|
||||
"onHostCreateBeforeSDL", "onHostCreateAfterSDL", "onHostResume",
|
||||
"onHostPause", "onHostDestroy",
|
||||
}) do
|
||||
check(source:find("protected void " .. hook, 1, true),
|
||||
hook .. " is a protected extension hook")
|
||||
end
|
||||
|
||||
check(position("onHostCreateBeforeSDL(savedInstanceState);") <
|
||||
position("super.onCreate(savedInstanceState);") and
|
||||
position("super.onCreate(savedInstanceState);") <
|
||||
position("onHostCreateAfterSDL(savedInstanceState);"),
|
||||
"create hooks bracket SDL creation")
|
||||
check(position("super.onResume();") < position("onHostResume();"),
|
||||
"resume hook runs after SDL resumes")
|
||||
check(position("onHostPause();") < position("super.onPause();"),
|
||||
"pause hook runs before SDL pauses")
|
||||
check(position("onHostDestroy();") < position("super.onDestroy();"),
|
||||
"destroy hook runs before SDL destruction")
|
||||
|
||||
check(not source:lower():find("openxr", 1, true),
|
||||
"generic Android activity must not require OpenXR")
|
||||
check(not source:find("QuestActivity", 1, true) and
|
||||
not source:find("QuestBridge", 1, true),
|
||||
"generic Android activity must not require Quest classes")
|
||||
|
||||
print("android_host_extension_test: ok")
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Optional native-host display lifecycle. The default path is inert; a fake
|
||||
-- backend proves callback order and arguments without graphics or a ROM.
|
||||
-- luajit tests/engine/host_display_test.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
local HostDisplay = require("src.core.HostDisplay")
|
||||
|
||||
-- Vanilla: no backend, no state requirement, and no invented return value.
|
||||
HostDisplay.setBackend(nil)
|
||||
eq(HostDisplay.update(1 / 60), nil, "default update is a no-op")
|
||||
eq(HostDisplay.beginFrame("game", {}), nil, "default beginFrame is a no-op")
|
||||
eq(HostDisplay.endFrame("game", {}), nil, "default endFrame is a no-op")
|
||||
|
||||
-- Bad installation fails at the boundary instead of producing a later frame
|
||||
-- error whose source is difficult for a native host to diagnose.
|
||||
local ok, err = pcall(HostDisplay.setBackend, function() end)
|
||||
check(not ok, "non-table backend is rejected")
|
||||
check(tostring(err):find("table or nil", 1, true) ~= nil,
|
||||
"backend type error explains the accepted shape")
|
||||
|
||||
local calls = {}
|
||||
local subject = { tag = "launcher-instance" }
|
||||
local fake = {}
|
||||
function fake:update(dt)
|
||||
calls[#calls + 1] = { "update", self, dt }
|
||||
return "updated"
|
||||
end
|
||||
function fake:beginFrame(kind, gotSubject)
|
||||
calls[#calls + 1] = { "begin", self, kind, gotSubject }
|
||||
return "begun"
|
||||
end
|
||||
function fake:endFrame(kind, gotSubject)
|
||||
calls[#calls + 1] = { "end", self, kind, gotSubject }
|
||||
return "ended"
|
||||
end
|
||||
|
||||
HostDisplay.setBackend(fake)
|
||||
eq(HostDisplay.update(0.25), "updated", "update return is forwarded")
|
||||
eq(HostDisplay.beginFrame("launcher", subject), "begun",
|
||||
"beginFrame return is forwarded")
|
||||
eq(HostDisplay.endFrame("launcher", subject), "ended",
|
||||
"endFrame return is forwarded")
|
||||
eq(#calls, 3, "each lifecycle callback fires exactly once")
|
||||
eq(calls[1][1], "update", "update is first")
|
||||
eq(calls[1][2], fake, "update receives the backend as self")
|
||||
eq(calls[1][3], 0.25, "update receives dt")
|
||||
eq(calls[2][1], "begin", "beginFrame precedes endFrame")
|
||||
eq(calls[2][3], "launcher", "beginFrame receives the frame kind")
|
||||
eq(calls[2][4], subject, "beginFrame receives the drawn subject")
|
||||
eq(calls[3][1], "end", "endFrame is last")
|
||||
eq(calls[3][3], "launcher", "endFrame receives the frame kind")
|
||||
eq(calls[3][4], subject, "endFrame receives the drawn subject")
|
||||
|
||||
-- Every callback is optional. Replacing and clearing a backend must not retain
|
||||
-- callbacks from the old host across a restart or test process.
|
||||
local partialCalls = 0
|
||||
HostDisplay.setBackend({
|
||||
endFrame = function() partialCalls = partialCalls + 1 end,
|
||||
})
|
||||
eq(HostDisplay.update(1), nil, "missing optional update remains a no-op")
|
||||
eq(HostDisplay.beginFrame("editor", {}), nil,
|
||||
"missing optional beginFrame remains a no-op")
|
||||
HostDisplay.endFrame("editor", {})
|
||||
eq(partialCalls, 1, "present optional callback still runs")
|
||||
|
||||
HostDisplay.setBackend(nil)
|
||||
HostDisplay.endFrame("game", {})
|
||||
eq(partialCalls, 1, "clearing backend detaches old callbacks")
|
||||
|
||||
T.finish("host display")
|
||||
@@ -0,0 +1,114 @@
|
||||
-- #1100 / #1135: the launcher gear offered TOUCH PAD, VIBRATION and the
|
||||
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")
|
||||
|
||||
-- The rows are mobile-gated the same way OptionsMenu's are, so the suite has
|
||||
-- to look like a phone; POKEPORT_TOUCH=1 is the other way in.
|
||||
local realGetOS = love.system.getOS
|
||||
love.system.getOS = function() return "Android" end
|
||||
|
||||
local LauncherSettings = require("src.import.LauncherSettings")
|
||||
local TouchControls = require("src.core.TouchControls")
|
||||
|
||||
local function labels(model)
|
||||
local out = {}
|
||||
for _, section in ipairs(model.sections) do
|
||||
for _, row in ipairs(section.rows) do out[#out + 1] = row.label end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function findRow(model, label)
|
||||
for _, section in ipairs(model.sections) do
|
||||
for _, row in ipairs(section.rows) do
|
||||
if row.label == label then return row end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function has(model, label)
|
||||
for _, l in ipairs(labels(model)) do
|
||||
if l == label then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local edited = 0
|
||||
local hooks = { editTouchControls = function() edited = edited + 1 end }
|
||||
|
||||
local gold = LauncherSettings.open(hooks, "gold")
|
||||
check(has(gold, "TOUCH PAD"), "Gold's gear offers TOUCH PAD")
|
||||
check(has(gold, "VIBRATION"), "and VIBRATION")
|
||||
check(has(gold, "TOUCH CONTROLS"), "and the layout editor")
|
||||
|
||||
-- Every write has to land in the gold block: the flat keys beside it are
|
||||
-- Red's, and Gold's boot never reads them (src/core/gen2/Save.lua:299).
|
||||
-- loadOptions seeds the flat Gen 1 defaults, so the check is that the Gold
|
||||
-- rows leave them exactly as they found them.
|
||||
local flatPad = gold.opts.touchControls
|
||||
local flatBuzz = gold.opts.haptics
|
||||
|
||||
local pad = findRow(gold, "TOUCH PAD")
|
||||
local before = pad.value()
|
||||
pad.step(1)
|
||||
check(pad.value() ~= before, "stepping TOUCH PAD flips it")
|
||||
check(type(gold.opts.gold) == "table", "into the gold block")
|
||||
eq(gold.opts.gold.touchControls.enabled, false, "which now carries enabled")
|
||||
eq(gold.opts.touchControls, flatPad, "leaving the flat Gen 1 key alone")
|
||||
|
||||
local buzz = findRow(gold, "VIBRATION")
|
||||
local buzzBefore = buzz.value()
|
||||
buzz.step(1)
|
||||
check(buzz.value() ~= buzzBefore, "stepping VIBRATION moves the level")
|
||||
eq(gold.opts.gold.haptics, TouchControls.normalizeHaptics(gold.opts.gold.haptics),
|
||||
"VIBRATION stores a level the shared module knows")
|
||||
eq(gold.opts.haptics, flatBuzz, "also without touching Red's")
|
||||
|
||||
findRow(gold, "TOUCH CONTROLS").action()
|
||||
eq(edited, 1, "the editor row reaches the host hook")
|
||||
|
||||
-- The Gen 1 gear is untouched by the extraction: same three rows, still on
|
||||
-- the flat table.
|
||||
local red = LauncherSettings.open(hooks, "red")
|
||||
check(has(red, "TOUCH PAD"), "Red still offers TOUCH PAD")
|
||||
check(has(red, "VIBRATION"), "and VIBRATION")
|
||||
check(has(red, "TOUCH CONTROLS"), "and the layout editor")
|
||||
findRow(red, "TOUCH PAD").step(1)
|
||||
eq(red.opts.touchControls.enabled, false, "writing to the flat key")
|
||||
eq(red.opts.gold, nil, "with no gold block invented")
|
||||
eq(findRow(red, "TOUCH PAD").value() ~= nil, true, "and reading it back")
|
||||
|
||||
-- With no hook (the standalone save editor's shape) the editor row is gone
|
||||
-- rather than dead, on both sides.
|
||||
eq(has(LauncherSettings.open(nil, "gold"), "TOUCH CONTROLS"), false,
|
||||
"no hook, no editor row on Gold")
|
||||
eq(has(LauncherSettings.open(nil, "red"), "TOUCH CONTROLS"), false,
|
||||
"nor on Red")
|
||||
-- The Edit row hands the screen to the host, and the host has to know WHICH
|
||||
-- game's block to write the dragged layout into: TouchControlsEditor persists
|
||||
-- to opts.gold only when it was loaded with version = "gold". Source-shape
|
||||
-- checks, the same way tests/engine/touch_controls_pad_cursor_test.lua pins
|
||||
-- the handoff either side of this one.
|
||||
local function read(path)
|
||||
local f = assert(io.open(path, "r"))
|
||||
local src = f:read("*a")
|
||||
f:close()
|
||||
return src
|
||||
end
|
||||
|
||||
local importerSrc = read("src/import/RomImporter.lua")
|
||||
check(importerSrc:find("self.onEditTouchControls(version)", 1, true) ~= nil,
|
||||
"the gear hands the launcher tab to the host")
|
||||
|
||||
local mainSrc = read("main.lua")
|
||||
local body = mainSrc:match("local function openTouchControlsEditor%((.-)%)")
|
||||
eq(body, "version", "main.lua's opener takes that tab")
|
||||
check(mainSrc:match("TouchEditor%.load%({%s*version = version") ~= nil,
|
||||
"and loads the editor with it, so Gold's layout lands in the gold block")
|
||||
|
||||
love.system.getOS = realGetOS
|
||||
|
||||
T.finish("launcher gold touch rows")
|
||||
@@ -0,0 +1,90 @@
|
||||
-- Launcher modal focus routing. The immediate-mode focus ring is shared by
|
||||
-- the main launcher and its mod dialogs, so arrows and an armed confirmation
|
||||
-- must be handled before the modal key guard returns.
|
||||
-- luajit tests/engine/launcher_modal_focus_input_test.lua
|
||||
|
||||
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 Kit = require("src.ui.kit.Kit")
|
||||
local RomImporter = require("src.import.RomImporter")
|
||||
|
||||
local function resetFocus()
|
||||
Kit.focusId = "modal-primary"
|
||||
Kit._navQueue = nil
|
||||
Kit._activateId = nil
|
||||
end
|
||||
|
||||
local function importer(field)
|
||||
return setmetatable({ _flex = true, [field] = {} }, RomImporter)
|
||||
end
|
||||
|
||||
local modalFields = {
|
||||
"_modConfirm", "_modVersions", "_modReleaseNotes", "_findDetails",
|
||||
}
|
||||
|
||||
for _, field in ipairs(modalFields) do
|
||||
resetFocus()
|
||||
local imp = importer(field)
|
||||
imp:keypressed("right")
|
||||
eq(Kit._navQueue, "right", field .. " accepts focus navigation")
|
||||
check(imp._ringArmed, field .. " navigation arms the focus ring")
|
||||
|
||||
imp:keypressed("return")
|
||||
eq(Kit._activateId, "modal-primary",
|
||||
field .. " activates the focused modal control")
|
||||
check(imp[field] ~= nil, field .. " remains open until its button dispatches")
|
||||
end
|
||||
|
||||
-- Enter without prior focus navigation must not invent an activation. This is
|
||||
-- the vanilla compatibility contract behind the launcher's legacy Enter-to-
|
||||
-- play shortcut; a modal simply absorbs that otherwise-unhandled key.
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modConfirm")
|
||||
imp:keypressed("return")
|
||||
eq(Kit._activateId, nil, "unarmed modal Enter does not activate focus")
|
||||
check(not imp._ringArmed, "unarmed modal Enter does not arm focus")
|
||||
end
|
||||
|
||||
-- A focused text field remains exclusive. The modal fix must not move the
|
||||
-- ring when an arrow is intended for a launcher text-input state.
|
||||
do
|
||||
resetFocus()
|
||||
local imp = setmetatable({
|
||||
_flex = true,
|
||||
_findSearchFocus = true,
|
||||
findQuery = "PIKA",
|
||||
}, RomImporter)
|
||||
imp:keypressed("right")
|
||||
eq(Kit._navQueue, nil, "focused search field does not navigate the ring")
|
||||
check(not imp._ringArmed, "focused search field does not arm the ring")
|
||||
end
|
||||
|
||||
-- Escape remains owned by the existing modal close path.
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_findDetails")
|
||||
imp:keypressed("escape")
|
||||
eq(imp._findDetails, nil, "Escape closes details")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modReleaseNotes")
|
||||
imp:keypressed("escape")
|
||||
eq(imp._modReleaseNotes, nil, "Escape closes release notes")
|
||||
end
|
||||
do
|
||||
resetFocus()
|
||||
local imp = importer("_modVersions")
|
||||
imp._modConfirm = {}
|
||||
imp:keypressed("escape")
|
||||
eq(imp._modConfirm, nil, "Escape closes confirmation")
|
||||
eq(imp._modVersions, nil, "Escape clears versions behind confirmation")
|
||||
end
|
||||
|
||||
resetFocus()
|
||||
T.finish("launcher modal focus input")
|
||||
@@ -51,7 +51,9 @@ check(mainSrc:find("prepareOverlayHandoff", 1, true) ~= nil
|
||||
and mainSrc:find("openTouchControlsEditor", 1, true) ~= nil,
|
||||
"main.lua mentions prepareOverlayHandoff + touch editor")
|
||||
-- prepare must run inside openTouchControlsEditor, not only openEditor
|
||||
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(%)(.-)\nend")
|
||||
-- The signature takes the launcher tab since #1100, so match any parameter
|
||||
-- list rather than pinning the arity.
|
||||
local touchOpen = mainSrc:match("local function openTouchControlsEditor%(.-%)(.-)\nend")
|
||||
check(touchOpen ~= nil, "openTouchControlsEditor body found")
|
||||
check(touchOpen:find("prepareOverlayHandoff", 1, true) ~= nil,
|
||||
"openTouchControlsEditor prepares overlay handoff like the save editor")
|
||||
|
||||
@@ -104,6 +104,34 @@ local function nameSet(list)
|
||||
return s
|
||||
end
|
||||
|
||||
-- Host-family compatibility is independent from the numeric shell revision.
|
||||
-- Missing fields preserve the historical ordinary-LOVE defaults.
|
||||
do
|
||||
check(Boot.canHost({}, 1, "love"),
|
||||
"canHost: legacy payload defaults to ordinary LOVE host and shell 1")
|
||||
check(Boot.canHost({ payloadHost = "love", minShell = 2 }, 2, "love"),
|
||||
"canHost: matching host and sufficient shell pass")
|
||||
check(not Boot.canHost({ payloadHost = "love", minShell = 2 }, 1, "love"),
|
||||
"canHost: newer shell requirement fails")
|
||||
check(not Boot.canHost({ payloadHost = "special", minShell = 1 }, 9, "love"),
|
||||
"canHost: a high shell revision cannot override a host mismatch")
|
||||
check(Boot.canHost({ payloadHost = "special", minShell = 3 }, 3, "special"),
|
||||
"canHost: a specialized host accepts its own payload")
|
||||
check(not Boot.canHost(nil, 1, "love"),
|
||||
"canHost: malformed payload metadata fails closed")
|
||||
end
|
||||
|
||||
-- Even an older mismatched-host payload is not ours to clean up.
|
||||
do
|
||||
local candidates = {
|
||||
{ name = "other-old.love", engine = "0.5.0", minShell = 1,
|
||||
payloadHost = "special" },
|
||||
}
|
||||
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
|
||||
eq(chosen, nil, "select: old payload for another host is not runnable")
|
||||
eq(#del, 0, "select: old payload for another host is not deleted")
|
||||
end
|
||||
|
||||
-- empty candidate list: nothing to run, nothing to delete
|
||||
do
|
||||
local chosen, del = Boot.select({}, "1.0.0", 1)
|
||||
@@ -127,6 +155,34 @@ do
|
||||
check(not d["b.love"], "select: the chosen payload is never deleted")
|
||||
end
|
||||
|
||||
-- A newer payload for another native host is neither selected nor deleted.
|
||||
-- It may be valid for another full package sharing this save directory.
|
||||
do
|
||||
local candidates = {
|
||||
{ name = "other.love", engine = "2.0.0", minShell = 1,
|
||||
payloadHost = "special" },
|
||||
{ name = "ours.love", engine = "1.5.0", minShell = 1,
|
||||
payloadHost = "love" },
|
||||
}
|
||||
local chosen, del = Boot.select(candidates, "1.0.0", 1, "love")
|
||||
eq(chosen, "ours.love", "select: chooses the matching host payload")
|
||||
eq(#del, 0, "select: incompatible newer host payload is retained")
|
||||
end
|
||||
|
||||
-- The same candidate set selects the specialized payload when the bundled
|
||||
-- package identifies as that host family.
|
||||
do
|
||||
local candidates = {
|
||||
{ name = "ordinary.love", engine = "2.1.0", minShell = 1,
|
||||
payloadHost = "love" },
|
||||
{ name = "special.love", engine = "2.0.0", minShell = 1,
|
||||
payloadHost = "special" },
|
||||
}
|
||||
local chosen, del = Boot.select(candidates, "1.0.0", 1, "special")
|
||||
eq(chosen, "special.love", "select: specialized package chooses its host payload")
|
||||
eq(#del, 0, "select: newer ordinary payload remains available to its host")
|
||||
end
|
||||
|
||||
-- skips payloads whose minShell is above the bundled shell, and KEEPS an
|
||||
-- otherwise-newer one for a future shell upgrade instead of deleting it
|
||||
do
|
||||
|
||||
@@ -252,10 +252,15 @@ do
|
||||
local outcome
|
||||
screen.onDone = function(result) outcome = result end
|
||||
|
||||
-- Battle lines end in `prompt` and PromptButton waits on A/B with no
|
||||
-- countdown (home/joypad.asm:383-412), so the drain presses.
|
||||
local function drain(cap)
|
||||
for _ = 1, (cap or 3000) do
|
||||
local waiting = (screen.messageTimer or 0) > 0
|
||||
if waiting then Input:overlayPressed("a") end
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
if waiting then Input:overlayReleased("a") end
|
||||
if screen.phase == "menu" or screen.phase == "done" then return true end
|
||||
end
|
||||
return false
|
||||
|
||||
@@ -159,10 +159,16 @@ local function newScreen(opts)
|
||||
return screen, battle, player, save, pushed
|
||||
end
|
||||
|
||||
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
|
||||
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
|
||||
-- player does rather than wait for a timer that never runs out.
|
||||
local function runToMenu(screen, cap)
|
||||
for _ = 1, (cap or 3000) do
|
||||
local waiting = (screen.messageTimer or 0) > 0
|
||||
if waiting then Input:overlayPressed("a") end
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
if waiting then Input:overlayReleased("a") end
|
||||
if screen.phase == "menu" then return true end
|
||||
end
|
||||
return false
|
||||
|
||||
@@ -157,10 +157,16 @@ local function newBattleOverWorld(inventory)
|
||||
return screen, battle, world, save, pushed, player
|
||||
end
|
||||
|
||||
-- Battle lines end in `prompt`, and PromptButton waits on A or B with no
|
||||
-- frame countdown (home/joypad.asm:383-412), so a drain has to press like a
|
||||
-- player does rather than wait for a timer that never runs out.
|
||||
local function runToMenu(screen, cap)
|
||||
for _ = 1, (cap or 3000) do
|
||||
local waiting = (screen.messageTimer or 0) > 0
|
||||
if waiting then Input:overlayPressed("a") end
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
if waiting then Input:overlayReleased("a") end
|
||||
if screen.phase == "menu" then return true end
|
||||
end
|
||||
return false
|
||||
|
||||
@@ -1119,10 +1119,10 @@ check("seismic toss deals the level",
|
||||
do
|
||||
check("static damage uses move power",
|
||||
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
|
||||
{ power = 40 }), 40)
|
||||
40), 40)
|
||||
check("sonic boom fixed damage is 20",
|
||||
Effects.fixedDamage("EFFECT_STATIC_DAMAGE", { level = 10 }, { hp = 50 }, nil,
|
||||
{ power = 20 }), 20)
|
||||
20), 20)
|
||||
|
||||
local boomBattle, _, boomWild = effectBattle({ "SONICBOOM" })
|
||||
boomWild.hp = 100
|
||||
|
||||
@@ -262,10 +262,21 @@ local function run(screen, frames)
|
||||
end
|
||||
end
|
||||
|
||||
-- One frame of a drain. Battle lines end in `prompt` and PromptButton waits
|
||||
-- on A or B with no countdown (home/joypad.asm:383-412); the stats box waits
|
||||
-- the same way (engine/battle/core.asm:7069), so a drain presses rather than
|
||||
-- idling for a timer that never runs out.
|
||||
local function drainStep(screen)
|
||||
local waiting = (screen.messageTimer or 0) > 0 or screen.phase == "stats-box"
|
||||
if waiting then Input:overlayPressed("a") end
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
if waiting then Input:overlayReleased("a") end
|
||||
end
|
||||
|
||||
local function runToMenu(screen, cap)
|
||||
for _ = 1, (cap or 3000) do
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
drainStep(screen)
|
||||
if screen.phase == "menu" then return true end
|
||||
end
|
||||
return false
|
||||
@@ -366,8 +377,7 @@ do
|
||||
|
||||
local crawled, restarted = false, false
|
||||
for _ = 1, 3000 do
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
drainStep(screen)
|
||||
if screen.shownLevel == 5 and (screen.shownExp or 0) > startExp then
|
||||
crawled = true
|
||||
end
|
||||
@@ -404,8 +414,7 @@ do
|
||||
local maxBefore = player.maxHp
|
||||
screen:submit({ kind = "move", move = "TACKLE" })
|
||||
for _ = 1, 3000 do
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
drainStep(screen)
|
||||
if screen.phase == "done" then break end
|
||||
end
|
||||
check(player.maxHp > maxBefore, "the level-up raised the maximum")
|
||||
@@ -601,8 +610,7 @@ do
|
||||
|
||||
local function runToPhase(screen, phase, cap)
|
||||
for _ = 1, (cap or 4000) do
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
drainStep(screen)
|
||||
if screen.phase == phase then return true end
|
||||
end
|
||||
return false
|
||||
@@ -622,9 +630,15 @@ do
|
||||
noBattle.enemy.hp = 1
|
||||
local noTurn = noBattle.turn
|
||||
noScreen:submit({ kind = "move", move = "TACKLE" })
|
||||
check(runToPhase(noScreen, "ask-shift"), "the KO stops on OfferSwitch")
|
||||
-- The `para` in BattleText_EnemyIsAboutToUseWillPlayerChangeMon splits the
|
||||
-- offer (data/text/battle.asm:222-231): the incoming mon is named on an
|
||||
-- earlier page, and only the last one carries the yes/no box (#1158).
|
||||
check(runToPhase(noScreen, "shift-intro"), "the KO stops on OfferSwitch")
|
||||
check((noScreen.message or ""):find("is about to use"),
|
||||
"with BattleText_EnemyIsAboutToUseWillPlayerChangeMon")
|
||||
check(runToPhase(noScreen, "ask-shift"), "and its pages reach the question")
|
||||
check((noScreen.message or ""):find("change POK"),
|
||||
"whose last page is the one YesNoBox opens over")
|
||||
|
||||
-- NO falls through to the send-out with nothing switched and nothing spent.
|
||||
noScreen.messageTimer = 0
|
||||
@@ -1083,8 +1097,7 @@ do
|
||||
-- not ring is anything from the faint onward.
|
||||
local latched, rangAfterFaint, sawExpLine = false, false, false
|
||||
for _ = 1, 3000 do
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
drainStep(screen)
|
||||
if screen.lowHealthAlarmDisabled then latched = true end
|
||||
if latched and siren then rangAfterFaint = true end
|
||||
if latched and (screen.message or ""):find("EXP") then sawExpLine = true end
|
||||
@@ -1155,7 +1168,9 @@ do
|
||||
eq(screen.phase, "refuse-move", "the disabled row is refused")
|
||||
eq(screen.message, "The move is DISABLED!", "with BattleText_TheMoveIsDisabled")
|
||||
eq(wild.hp, before, "and the enemy got no free turn")
|
||||
run(screen, 120)
|
||||
-- Both refusal lines end in `prompt` (data/text/battle.asm:315-323), so the
|
||||
-- list comes back on a press, not on a timer.
|
||||
tap("a")
|
||||
eq(screen.phase, "moves", "the list comes back")
|
||||
|
||||
-- The same for a spent row.
|
||||
@@ -1168,7 +1183,7 @@ do
|
||||
eq(screen.message, "There's no PP left for this move!",
|
||||
"with BattleText_TheresNoPPLeftForThisMove")
|
||||
eq(wild.hp, before, "and still no enemy turn")
|
||||
run(screen, 120)
|
||||
tap("a")
|
||||
eq(screen.phase, "moves", "and the list comes back again")
|
||||
end
|
||||
|
||||
@@ -1221,8 +1236,7 @@ end
|
||||
-- so the question is reached by pages rather than by one button.
|
||||
local function runToPhase(screen, phase, cap)
|
||||
for _ = 1, (cap or 900) do
|
||||
Input:step()
|
||||
screen:update(1 / 60)
|
||||
drainStep(screen)
|
||||
if screen.phase == phase then return true end
|
||||
end
|
||||
return false
|
||||
@@ -1236,11 +1250,15 @@ do
|
||||
check(runToPhase(screen, "ask-forget"), "and its pages run into the question")
|
||||
eq(screen.message, "move to make room\nfor EMBER?",
|
||||
"whose last page is the one YesNoBox opens over")
|
||||
run(screen, 60)
|
||||
local tap = tapper(screen)
|
||||
-- The question's own `prompt` is read first and YesNoBox opens after it,
|
||||
-- exactly as OfferSwitch does (engine/battle/core.asm:3298-3305), so the
|
||||
-- answer is the second press, not the first.
|
||||
tap("a")
|
||||
eq(screen.phase, "ask-forget", "the last page holds until it is read")
|
||||
tap("b")
|
||||
eq(screen.phase, "stop-learning", "NO there asks whether to stop learning")
|
||||
run(screen, 60)
|
||||
tap("a")
|
||||
tap("b")
|
||||
eq(screen.phase, "learn-intro", "and NO to THAT reprints the ask")
|
||||
check(runToPhase(screen, "ask-forget"), "which is a loop, not an exit")
|
||||
@@ -1251,8 +1269,8 @@ end
|
||||
do
|
||||
local screen, lead = learnScreen()
|
||||
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
|
||||
run(screen, 60)
|
||||
local tap = tapper(screen)
|
||||
tap("a") -- read the question
|
||||
tap("a") -- YES
|
||||
eq(screen.phase, "choose-forget", "YES opens the picker")
|
||||
tap("a") -- slot 1
|
||||
@@ -1263,9 +1281,9 @@ end
|
||||
do
|
||||
local screen, lead = learnScreen()
|
||||
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
|
||||
run(screen, 60)
|
||||
local tap = tapper(screen)
|
||||
tap("a")
|
||||
tap("a") -- read the question
|
||||
tap("a") -- YES
|
||||
eq(screen.phase, "choose-forget", "the picker is up")
|
||||
screen.forgetIndex = 4
|
||||
tap("a")
|
||||
@@ -1273,7 +1291,7 @@ do
|
||||
eq(screen.message, "HM moves can't be\nforgotten now.",
|
||||
"with MoveCantForgetHMText")
|
||||
eq(screen.forgetIndex, 1, "and `jr .loop` puts the cursor back on slot 1")
|
||||
run(screen, 60)
|
||||
tap("a")
|
||||
eq(screen.phase, "choose-forget", "the picker is still up after the line")
|
||||
eq(lead.moves[4].id, "SURF", "and SURF is still there")
|
||||
end
|
||||
|
||||
@@ -356,11 +356,14 @@ local optionsGame, optionsInput = newGame(Save.newGame())
|
||||
local options = OptionsMenu.new(optionsGame, {
|
||||
options = Save.defaultOptions(),
|
||||
})
|
||||
-- The cart's seven value rows, then the port's audio, speed and display
|
||||
-- rows, then CANCEL -- which is what makes this screen scroll.
|
||||
check("sixteen rows", #OptionsMenu.ROWS, 16)
|
||||
-- The cart's seven value rows, then CONTROLS, the port's audio, speed and
|
||||
-- display rows, the three touch rows and CANCEL -- which is what makes this
|
||||
-- screen scroll. The touch three are gated to mobile by buildRows; ROWS
|
||||
-- itself carries every descriptor.
|
||||
check("twenty rows", #OptionsMenu.ROWS, 20)
|
||||
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
|
||||
check("then the port's audio group", OptionsMenu.ROWS[8].key, "musicVol")
|
||||
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
|
||||
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
|
||||
check("last row is CANCEL", OptionsMenu.ROWS[#OptionsMenu.ROWS].cancel, true)
|
||||
check("starts on TEXT SPEED", options:row().key, "textSpeed")
|
||||
check("default text speed", options.options.textSpeed, "MID")
|
||||
@@ -401,7 +404,9 @@ local exiting = OptionsMenu.new(exitGame, {
|
||||
options = Save.defaultOptions(),
|
||||
onDone = function(o) savedOptions = o end,
|
||||
})
|
||||
exiting.index = #OptionsMenu.ROWS
|
||||
-- The screen's own rows, not ROWS: buildRows drops the touch three off a
|
||||
-- desktop, so the raw descriptor count overshoots CANCEL.
|
||||
exiting.index = #exiting.rows
|
||||
exitInput:press("a")
|
||||
exiting:update(0)
|
||||
check("CANCEL leaves", savedOptions ~= nil, true)
|
||||
|
||||
@@ -17,14 +17,17 @@ local Nests = require("src.core.gen2.Nests")
|
||||
-- Landmark indices from constants/landmark_constants.asm: Johto runs below
|
||||
-- PALLET_TOWN ($2e), Kanto from it up to ROUTE_28.
|
||||
local DATA = {
|
||||
maps = {
|
||||
-- gen2Maps, matching Game2's key for the Gold map table.
|
||||
gen2Maps = {
|
||||
ROUTE_29 = { landmark = 2 }, -- Johto
|
||||
ILEX_FOREST = { landmark = 11 }, -- Johto
|
||||
ROUTE_1 = { landmark = 0x2f }, -- Kanto
|
||||
UNION_CAVE_1F = { landmark = 9 }, -- Johto
|
||||
NOWHERE = { }, -- no landmark at all
|
||||
},
|
||||
encounters = {
|
||||
-- Game2 loads the encounter tables under gen2Encounters, not `encounters`:
|
||||
-- the flat name is Gen 1's and Gen2Compat only maps it for mods.
|
||||
gen2Encounters = {
|
||||
grass = {
|
||||
ROUTE_29 = { slots = {
|
||||
MORN = { { species = "PIDGEY" }, { species = "SENTRET" } },
|
||||
|
||||
@@ -417,22 +417,25 @@ check("only the written byte is stored", memCount, 1)
|
||||
local _, _, _, loadReport = Save.load("gold")
|
||||
check("load reports a clean save", Save.emptyReport(loadReport), true)
|
||||
|
||||
-- A second write backs the first up, so the previous file is always recoverable.
|
||||
-- A second write backs the first up, so the previous file is always
|
||||
-- recoverable. The file lives at saves/<version>/<slot>.lua since Gold grew
|
||||
-- launcher slots (#1107); the flat save_gold.lua is only the migration source.
|
||||
local SLOT = "saves/gold/slot1.lua"
|
||||
written.player.money = 5555
|
||||
Save.save(written)
|
||||
check("backup written", files["save_gold.lua.bak"] ~= nil, true)
|
||||
check("backup written", files[SLOT .. ".bak"] ~= nil, true)
|
||||
check("new value loads", Save.load("gold").player.money, 5555)
|
||||
|
||||
-- A corrupt main file falls back to the backup rather than losing the game.
|
||||
files["save_gold.lua"] = "this is not a lua table"
|
||||
files[SLOT] = "this is not a lua table"
|
||||
local recoveredSave, how = Save.load("gold")
|
||||
check("recovered from the backup", recoveredSave ~= nil, true)
|
||||
check("recovery reported", how, "bak")
|
||||
check("backup held the previous money", recoveredSave.player.money, 4321)
|
||||
|
||||
-- A staged .tmp is preferred over the backup: it is the newer of the two.
|
||||
files["save_gold.lua"] = nil
|
||||
files["save_gold.lua.tmp"] = files["save_gold.lua.bak"]
|
||||
files[SLOT] = nil
|
||||
files[SLOT .. ".tmp"] = files[SLOT .. ".bak"]
|
||||
local staged, stagedHow = Save.load("gold")
|
||||
check("recovered from the staged copy", staged ~= nil, true)
|
||||
check("staged recovery reported", stagedHow, "tmp")
|
||||
|
||||
@@ -3315,7 +3315,8 @@ local function grassLatchChecks()
|
||||
check(hp.jumping, "and it is a jump")
|
||||
check(not hp.grassShake, "no rustle spawns for the airborne cells")
|
||||
check(not hp.inGrass, "and IN_GRASS is clear for the whole hop")
|
||||
for _ = 1, PlayerMod.STEP_FRAMES + 1 do hopGrass:step() end
|
||||
-- A hop clears two cells, so it runs two step-times, not one (#1165).
|
||||
for _ = 1, PlayerMod.STEP_FRAMES * 2 + 1 do hopGrass:step() end
|
||||
check(hp.inGrass, "the landing tile latches it once the hop ends")
|
||||
end
|
||||
grassLatchChecks()
|
||||
|
||||
@@ -48,10 +48,15 @@ function fs.load(path)
|
||||
end
|
||||
function fs.getDirectoryItems(path)
|
||||
local items = {}
|
||||
-- -print plus a basename in Lua, not -printf: that is a GNU extension and
|
||||
-- BSD find (macOS) fails the whole call, which silently emptied the listing
|
||||
-- and left the probe mod undiscovered.
|
||||
local pipe = io.popen("find " .. quote(full(path))
|
||||
.. " -mindepth 1 -maxdepth 1 -printf '%f\\n' 2>/dev/null")
|
||||
.. " -mindepth 1 -maxdepth 1 -print 2>/dev/null")
|
||||
if pipe then
|
||||
for item in pipe:lines() do items[#items + 1] = item end
|
||||
for item in pipe:lines() do
|
||||
items[#items + 1] = item:match("[^/]+$") or item
|
||||
end
|
||||
pipe:close()
|
||||
end
|
||||
table.sort(items)
|
||||
|
||||
@@ -21,6 +21,7 @@ local Net = require("src.link.Net")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Session = require("src.link.Session")
|
||||
|
||||
local S = require("tests.harness").suite("mod link")
|
||||
local check, eq = S.check, S.eq
|
||||
@@ -529,11 +530,19 @@ local function linkGame(name, species, data)
|
||||
return { data = data or Data, save = save, stack = stack, input = mkInput() }
|
||||
end
|
||||
|
||||
-- LinkState talks to a Session, not to a raw transport (src/link/LinkState.lua
|
||||
-- :75 wraps every backend the same way), so a loopback end has to be wrapped
|
||||
-- here too or :update reaches for a getStatus the transport does not have.
|
||||
local function linkSession(transport, role)
|
||||
return Session.new(transport, { role = role, kind = "link" })
|
||||
end
|
||||
|
||||
-- two paired states, host already listening and guest already dialling
|
||||
local function pairStates(gameA, gameB)
|
||||
local netA, netB = Net.loopbackPair()
|
||||
local host, guest = LinkState.new(gameA), LinkState.new(gameB)
|
||||
host.net, guest.net = netA, netB
|
||||
host.net = linkSession(netA, "host")
|
||||
guest.net = linkSession(netB, "guest")
|
||||
host.stage, guest.stage = "hosting", "joining"
|
||||
gameA.stack:push(host)
|
||||
gameB.stack:push(guest)
|
||||
@@ -573,7 +582,7 @@ check(host.trade.strict, "a v2 verdict unpacks strictly")
|
||||
local gameOld = linkGame("RED", "PIDGEY")
|
||||
local oldNet, peerNet = Net.loopbackPair()
|
||||
local v1guest = LinkState.new(gameOld)
|
||||
v1guest.net = oldNet
|
||||
v1guest.net = linkSession(oldNet, "guest")
|
||||
v1guest.stage = "joining"
|
||||
gameOld.stack:push(v1guest)
|
||||
v1guest:update(1 / 60)
|
||||
@@ -591,7 +600,7 @@ check(not v1guest.trade.strict, "the v1 path keeps the old unpack rules")
|
||||
local gameLone = linkGame("RED", "PIDGEY")
|
||||
local loneNet, silentNet = Net.loopbackPair()
|
||||
local v1host = LinkState.new(gameLone)
|
||||
v1host.net = loneNet
|
||||
v1host.net = linkSession(loneNet, "host")
|
||||
v1host.stage = "hosting"
|
||||
gameLone.stack:push(v1host)
|
||||
v1host:update(1 / 60)
|
||||
|
||||
@@ -207,6 +207,11 @@ if type(storage) == "table" then
|
||||
T.check(type(normalBytes) == "string" and normalBytes ~= "",
|
||||
"first checkpoint anchor is durably represented before restart")
|
||||
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
|
||||
|
||||
-- Model a durable checkpoint captured by the previous shipped engine.
|
||||
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime state.
|
||||
checkpoint.identity.engineVersion = "0.1.79"
|
||||
|
||||
SaveData.resetSlotState()
|
||||
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
|
||||
titleRuntime.save.options = { volume = 9, bindings = {} }
|
||||
@@ -224,7 +229,14 @@ if type(storage) == "table" then
|
||||
version = "red", meta = { playthroughId = originalId },
|
||||
}, fs).savedAt, anchoredAt,
|
||||
"title bootstrap never rewrites the first normal save")
|
||||
T.same(checkpoints:capture(titleRuntime), checkpoint,
|
||||
local recaptured = checkpoints:capture(titleRuntime)
|
||||
T.eq(recaptured and recaptured.identity
|
||||
and recaptured.identity.engineVersion, Version.engine,
|
||||
"cross-version resume recaptures the running engine version")
|
||||
if recaptured and recaptured.identity then
|
||||
recaptured.identity.engineVersion = checkpoint.identity.engineVersion
|
||||
end
|
||||
T.same(recaptured, checkpoint,
|
||||
"bootstrapped overworld differentially recaptures the selected checkpoint")
|
||||
T.eq(_G.MOD_TITLE_RESTORE_COUNT, 1,
|
||||
"a successfully verified title resume emits checkpoint.restored exactly once")
|
||||
|
||||
@@ -64,6 +64,17 @@ local function freshGame(mapX, mapY)
|
||||
worldViewSize = function() return 160, 144 end,
|
||||
setSGBZones = function() end,
|
||||
}
|
||||
-- OverworldState is a singleton and :enter does not clear the movement
|
||||
-- latches, so a scenario that runs after a few thousand other checks can
|
||||
-- inherit a half-finished script and never walk. Reset them here: this
|
||||
-- suite asserts a frame budget, so it has to start from a known state.
|
||||
OverworldState.scriptMoves = {}
|
||||
OverworldState.pendingScripts = {}
|
||||
OverworldState.emote = nil
|
||||
OverworldState.engaging = false
|
||||
OverworldState.teleportOut = nil
|
||||
OverworldState.transitioning = nil
|
||||
OverworldState.wildEncounterGraceSteps = 0
|
||||
StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up")
|
||||
Game.overworld = OverworldState
|
||||
return pressed
|
||||
@@ -114,6 +125,11 @@ end
|
||||
-- keeps running straight into the lab.
|
||||
-- =====================================================================
|
||||
scenario(function()
|
||||
-- The trigger tile is in Pallet's north grass, so the escort walk rolls for
|
||||
-- wild encounters as it goes. Pin the stream: run standalone this suite got
|
||||
-- one draw sequence and run inside tests/run_tests.lua another, and one of
|
||||
-- them dropped a battle on top of the escort and ate the frame budget.
|
||||
math.randomseed(require("tests.harness").SEED)
|
||||
GameVersion.set("red")
|
||||
local pressed = freshGame(8, 2)
|
||||
local played = {}
|
||||
|
||||
+12
-4
@@ -2788,15 +2788,23 @@ do
|
||||
press("down")
|
||||
eq(om.index, 25, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 26, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, 21, "CANCEL keeps the last option boxes on screen")
|
||||
eq(om.index, 26, "cursor reaches DATE FORMAT")
|
||||
press("down")
|
||||
eq(om.index, 27, "cursor reaches TIME FORMAT")
|
||||
press("down")
|
||||
-- CANCEL is appended after the descriptor list rather than living in it, so
|
||||
-- it lands one past #rows and the window holds the last six boxes. Counted
|
||||
-- off #rows so the next row added here is not read as a wrap bug.
|
||||
local cancelRow = #om.rows + 1
|
||||
eq(om.index, cancelRow, "CANCEL stays the fixed final row")
|
||||
eq(om.scroll, cancelRow - 5, "CANCEL keeps the last option boxes on screen")
|
||||
om:draw() -- smoke: scrolled layout draws under the headless stub
|
||||
press("a")
|
||||
check(popped, "A on CANCEL closes the options menu")
|
||||
local om2 = OptionsMenu.new(og)
|
||||
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
|
||||
eq(om2.index, 26, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, 21, "wrapping to CANCEL scrolls to the tail")
|
||||
eq(om2.index, cancelRow, "up from the top wraps to CANCEL")
|
||||
eq(om2.scroll, cancelRow - 5, "wrapping to CANCEL scrolls to the tail")
|
||||
-- headless-safe: no love.audio, setters only update internal state
|
||||
require("src.core.Music").applyOptions(og.save.options)
|
||||
require("src.core.Sound").applyOptions(og.save.options)
|
||||
|
||||
Reference in New Issue
Block a user