Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment

This commit is contained in:
James Hall
2026-08-16 19:30:19 -05:00
committed by GitHub
233 changed files with 8377 additions and 45512 deletions
@@ -0,0 +1,323 @@
-- ..(engine/movie/title.asm ln 321 DrawPlayerCharacter, ball at ln 99)
return function(game)
local U = dofile("tests/drivers/util.lua")
local P = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local TitleState = require("src.ui.TitleState")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local REST_OBJ_MIN, REST_OBJ_MAX = 340, 520
local MENU_OBJ_SLACK = 8
-- ..(engine/movie/title.asm ln 321): 40x56 at (82,80), plus a margin
local BAND = { 74, 74, 132, 144 }
local fails = 0
local function check(label, ok, detail)
U.log(ok and "PASS" or "FAIL", label, detail or "")
if not ok then fails = fails + 1 end
return ok
end
local rendered = 0
local hostDraw = love.draw
love.draw = function(...)
local r = hostDraw(...)
rendered = rendered + 1
return r
end
local function waitRenderedFrames(frames)
local target = rendered + (frames or 2)
for _ = 1, 2400 do
if rendered >= target then return true end
U.wait(1)
end
return false
end
local function waitFor(pred, limit)
for _ = 1, limit or 1800 do
if pred() then return true end
U.wait(1)
end
return false
end
local function redrawCount(frames)
local m = 0
for _ = 1, frames or 4 do
waitRenderedFrames(1)
local n = #P.uiSpriteRedraws()
if n > m then m = n end
end
return m
end
local objRamp
local function isObj(R, G, B)
for _, c in ipairs(objRamp) do
if math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12
and math.abs(B - c[3]) <= 12 then
return true
end
end
return false
end
local function letterbox(img)
local W, H = img:getWidth(), img:getHeight()
local x1, y1, x2, y2 = W, H, -1, -1
for y = 0, H - 1 do
for x = 0, W - 1 do
local r, g, b = img:getPixel(x, y)
if r > 0.02 or g > 0.02 or b > 0.02 then
if x < x1 then x1 = x end
if x > x2 then x2 = x end
if y < y1 then y1 = y end
if y > y2 then y2 = y end
end
end
end
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
return x1, y1, bh / 144
end
local s = math.min(W / 160, H / 144)
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
end
local function census(path, bx1, by1, bx2, by2)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
local ok, img = pcall(function()
return love.image.newImageData(
love.filesystem.newFileData(bytes, "shot.png"))
end)
if not ok or not img then return nil end
local W, H = img:getWidth(), img:getHeight()
local ox, oy, scale = letterbox(img)
local x1 = ox + math.floor((bx1 or 0) * scale)
local y1 = oy + math.floor((by1 or 0) * scale)
local x2 = ox + math.ceil((bx2 or 160) * scale) - 1
local y2 = oy + math.ceil((by2 or 144) * scale) - 1
local c = { grey = 0, obj = 0, ink = 0, scale = scale, w = W, h = H }
for y = math.max(0, y1), math.min(H - 1, y2) do
for x = math.max(0, x1), math.min(W - 1, x2) do
local r, g, b = img:getPixel(x, y)
local R = math.floor(r * 255 + 0.5)
local G = math.floor(g * 255 + 0.5)
local B = math.floor(b * 255 + 0.5)
if R == G and G == B and R > 60 and R < 240 then c.grey = c.grey + 1 end
if R < 30 and G < 30 and B < 30 then c.ink = c.ink + 1 end
if isObj(R, G, B) then c.obj = c.obj + 1 end
end
end
local d = scale * scale
c.rawGrey, c.rawObj, c.rawInk = c.grey, c.obj, c.ink
c.grey = math.floor(c.grey / d + 0.5)
c.obj = math.floor(c.obj / d + 0.5)
c.ink = math.floor(c.ink / d + 0.5)
return c
end
local function report(tag, c)
U.log(string.format(
"%s: obj=%d grey=%d ink=%d (raw obj=%d grey=%d at %dx%d, scale %.2f)",
tag, c.obj, c.grey, c.ink, c.rawObj, c.rawGrey, c.w, c.h, c.scale))
end
local version = GameVersion.get()
U.wait(5)
U.tap(game, "start")
local title
check("reached the title screen", waitFor(function()
title = game.stack:top()
return getmetatable(title) == TitleState
end, 300))
if getmetatable(title) ~= TitleState then
U.log("BUG #1214 DRIVER ABORTED: never reached TitleState")
love.event.quit(1)
return
end
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
check("COLORS is ogred", P.mode == "ogred", P.mode)
check("usesSpriteObp() true, so the OBJ ramp is baked",
P.usesSpriteObp() == true)
objRamp = { P.ogObj()[2], P.ogObj()[3] }
U.log("version:", tostring(version), "boot-ROM OBJ ramp:",
string.format("(%d,%d,%d)/(%d,%d,%d)",
objRamp[1][1], objRamp[1][2], objRamp[1][3],
objRamp[2][1], objRamp[2][2], objRamp[2][3]))
check("title reached the loop phase",
waitFor(function() return title.phase == "loop" end))
check("mon cycle reached the hold beat",
waitFor(function() return title.scrollPhase == "hold" end))
waitRenderedFrames(3)
local restObj
do
check("nothing is occluding the title", game.stack:top() == title
and title.titleUiBox == nil)
check("at rest the four player draws are recorded for replay",
redrawCount(6) == 4, "#" .. #P.uiSpriteRedraws())
local shot = SHOT_DIR .. "/bug1214_1_ogred_rest.png"
if U.shot(game, shot) then
local c = census(shot)
local band = census(shot, BAND[1], BAND[2], BAND[3], BAND[4])
report("REST screen", c)
report("REST player band", band)
restObj = c.obj
check("#1214 REST: player wears the boot-ROM OBJ ramp (expect ~427"
.. " canvas px, 12141 raw on a 1024x768 window)",
c.obj >= REST_OBJ_MIN and c.obj <= REST_OBJ_MAX, c.obj)
check("#1214 REST: no DMG grey in the player band (r==g==b, 60<r<240)",
band.grey == 0, band.grey)
end
end
do
check("caught the mon sliding through the player bbox",
waitFor(function()
return title.scrollPhase == "in" and title.monOffset
and title.monOffset >= 10 and title.monOffset <= 60
end))
local before = title.monOffset
local shot = SHOT_DIR .. "/bug1214_2_ogred_midscroll.png"
if U.shot(game, shot) then
local c = census(shot)
local band = census(shot, BAND[1], BAND[2], BAND[3], BAND[4])
U.log("mid-scroll monOffset:", tostring(before), "->",
tostring(title.monOffset))
report("MIDSCROLL screen", c)
report("MIDSCROLL player band", band)
check("#1214 MIDSCROLL: no grey leaking through the transparent"
.. " player sprite (was ~6135 raw px before the replay)",
band.grey == 0, band.grey)
check("#1214 MIDSCROLL: player still wears the OBJ ramp",
c.obj >= REST_OBJ_MIN and c.obj <= REST_OBJ_MAX, c.obj)
end
end
local saveName = require("src.core.SaveData").saveFilename(version)
do
check("mon cycle back on the hold beat",
waitFor(function() return title.scrollPhase == "hold" end))
waitRenderedFrames(3)
pcall(function() love.filesystem.write(saveName, "return {}") end)
title:openMenu()
waitRenderedFrames(3)
local menu = game.stack:top()
local box = menu and menu.titleUiBox
U.log("menu titleUiBox:", box and table.concat(box, ",") or "nil")
check("main menu lists CONTINUE, so the box bottom is y 80",
box ~= nil and box[4] == 9,
box and tostring(box[4]) or "nil")
check("#1214 MENU: a box ending at y 80 does not suppress the player,"
.. " who starts at y 80",
redrawCount(6) == 4, "#" .. #P.uiSpriteRedraws())
local shot = SHOT_DIR .. "/bug1214_3_ogred_menu.png"
if U.shot(game, shot) then
local c = census(shot)
local band = census(shot, BAND[1], BAND[2], BAND[3], BAND[4])
report("MENU screen", c)
report("MENU player band", band)
check("#1214 MENU: OBJ ramp count unchanged from rest",
restObj ~= nil and math.abs(c.obj - restObj) <= MENU_OBJ_SLACK,
string.format("%d vs rest %d", c.obj, restObj or -1))
check("#1214 MENU: no DMG grey in the player band",
band.grey == 0, band.grey)
end
game.stack:pop()
waitRenderedFrames(2)
end
do
local ContinueInfo
for i = 1, 40 do
local name, val = debug.getupvalue(TitleState.openMenu, i)
if not name then break end
if name == "ContinueInfo" then ContinueInfo = val break end
end
check("ContinueInfo class reachable", ContinueInfo ~= nil)
local info
if ContinueInfo then
local ok, st = pcall(ContinueInfo.new, title,
{ player = { name = "RED" }, playTime = 0, pokedex = { owned = {} } })
check("ContinueInfo.new", ok, not ok and tostring(st) or "")
if ok then info = st end
end
if info then
check("CONTINUE window box is (4,7)-(19,16)",
info.titleUiBox and info.titleUiBox[1] == 4
and info.titleUiBox[2] == 7 and info.titleUiBox[3] == 19
and info.titleUiBox[4] == 16)
game.stack:push(info)
waitRenderedFrames(3)
check("#1214 CONTINUE: every player draw overlapping the window is"
.. " suppressed", redrawCount(6) == 0,
"#" .. #P.uiSpriteRedraws())
local shot = SHOT_DIR .. "/bug1214_4_ogred_continue.png"
if U.shot(game, shot) then
local c = census(shot, 33, 57, 159, 135)
report("CONTINUE box interior", c)
check("#1214 CONTINUE: no OBJ ramp bleeding over the window",
c.obj == 0, c.obj)
local row = census(shot, 33, 103, 159, 113)
report("CONTINUE POKeDEX row", row)
check("#1214 CONTINUE: the POKeDEX row is legible ink, not sprite",
row.ink > 30 and row.obj == 0,
string.format("ink=%d obj=%d", row.ink, row.obj))
end
game.stack:pop()
waitRenderedFrames(2)
end
end
pcall(function() love.filesystem.remove(saveName) end)
do
for _, mode in ipairs({ "gbc", "og", "og_inv", "gbc_inv", "classic" }) do
P.applyOptions({ colors = mode })
waitRenderedFrames(2)
check(mode .. " records no UI sprite redraws", redrawCount(4) == 0)
end
P.applyOptions({ colors = "gbc" })
check("mon cycle back on the hold beat under gbc",
waitFor(function() return title.scrollPhase == "hold" end))
waitRenderedFrames(3)
local shot = SHOT_DIR .. "/bug1214_5_gbc.png"
if U.shot(game, shot) then
local c = census(shot)
report("GBC", c)
check("#1214 GBC: the SGB title never wears the boot-ROM OBJ ramp",
c.obj == 0, c.obj)
end
end
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
waitRenderedFrames(3)
if fails > 0 then
U.log("################################################################")
U.log("#1214 FAILED: " .. fails .. " check(s) above, shots in " .. SHOT_DIR)
U.log("################################################################")
else
U.log("#1214 PASS: all checks, shots in " .. SHOT_DIR)
end
U.log("On screen now: the OG RED title in COLORS = OG. The player and the")
U.log("pokeball in his hand must be the boot ROM's OBJ ramp (green on Red,")
U.log("pink on Blue) while the logo, ribbon, cycling mon and copyright line")
U.log("stay on the BG ramp. Before #1214 he was BG dark red, and the first")
U.log("attempt at the fix left grey rectangles around him as mons slid past.")
U.log("START opens the menu, whose box ends one pixel above his head.")
while true do
coroutine.yield()
end
end
@@ -49,6 +49,16 @@ check(position("onHostPause();") < position("super.onPause();"),
check(position("onHostDestroy();") < position("super.onDestroy();"),
"destroy hook runs before SDL destruction")
check(source:find("DisplayManager.DisplayListener", 1, true)
and source:find("registerDisplayListener", 1, true)
and source:find("unregisterDisplayListener", 1, true),
"secondary displays are monitored while the activity is active")
check(position("if (secondaryEnabled) registerSecondaryDisplayListener();") <
position("setupSecondaryDisplay();"),
"secondary display monitoring starts before initial discovery")
check(source:find("!monitor.hasDisplay(display.getDisplayId())", 1, true),
"a disconnected active display is rebound without replacing a live one")
check(not source:lower():find("openxr", 1, true),
"generic Android activity must not require OpenXR")
check(not source:find("QuestActivity", 1, true) and
@@ -0,0 +1,102 @@
-- engine/battle/misc.asm:37 FormatMovesString .printDashLoop
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BattleState = require("src.battle.BattleState")
local Font = require("src.render.Font")
local realDraw, realDrawCode, realDrawBox = Font.draw, Font.drawCode, Font.drawBox
local drawn
local function stubFont()
drawn = {}
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text = text, x = x, y = y } end
Font.drawCode = function() end
Font.drawBox = function() end
end
local function unstubFont()
Font.draw, Font.drawCode, Font.drawBox = realDraw, realDrawCode, realDrawBox
end
-- a mon with fewer than four moves: the remaining rows must be dashes, not
-- simply absent (ipairs used to stop at the last known move).
do
stubFont()
local screen = setmetatable({
phase = "moveSelect",
player = { curMoves = { { id = "TACKLE", pp = 35 } } },
data = { moves = { TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" } } },
moveIndex = 1, frame = 0,
}, { __index = BattleState })
local ok, err = pcall(function() screen:drawTextArea() end)
T.check(ok, "moveSelect draws without error (" .. tostring(err) .. ")")
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
end
T.eq(#rows, 4, "all four move rows are drawn, even the unused ones")
T.eq(rows[1], "TACKLE", "the one real move prints its name")
T.eq(rows[2], "-", "an empty slot is a dash")
T.eq(rows[3], "-", "so is the next one")
T.eq(rows[4], "-", "and the last one")
unstubFont()
end
-- a full four-move mon: no dashes anywhere.
do
stubFont()
local screen = setmetatable({
phase = "moveSelect",
player = { curMoves = {
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
{ id = "TACKLE", pp = 35 }, { id = "GROWL", pp = 40 },
} },
data = { moves = {
TACKLE = { name = "TACKLE", pp = 35, type = "NORMAL" },
GROWL = { name = "GROWL", pp = 40, type = "NORMAL" },
} },
moveIndex = 1, frame = 0,
}, { __index = BattleState })
screen:drawTextArea()
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 48 and d.y >= 104 and d.y <= 128 then rows[#rows + 1] = d.text end
end
T.eq(#rows, 4, "still exactly four rows")
for i, want in ipairs({ "TACKLE", "GROWL", "TACKLE", "GROWL" }) do
T.eq(rows[i], want, "row " .. i .. " keeps its own move name")
end
unstubFont()
end
-- the Mimic menu shares FormatMovesString on the cart, so it gets the same
-- dash treatment.
do
stubFont()
local screen = setmetatable({
phase = "mimicSelect",
mimicMoves = { { id = "TACKLE" }, { id = "GROWL" } },
data = { moves = {
TACKLE = { name = "TACKLE" }, GROWL = { name = "GROWL" },
} },
mimicIndex = 1, frame = 0,
}, { __index = BattleState })
local ok = pcall(function() screen:drawTextArea() end)
T.check(ok, "mimicSelect draws without error")
local rows = {}
for _, d in ipairs(drawn) do
if d.x == 16 and d.y >= 64 and d.y <= 88 then rows[#rows + 1] = d.text end
end
T.eq(rows[1], "TACKLE", "mimic row 1 is the enemy's first move")
T.eq(rows[2], "GROWL", "mimic row 2 is its second")
T.eq(rows[3], "-", "an enemy with fewer than four moves dashes out the rest")
T.eq(rows[4], "-", "including the last row")
unstubFont()
end
T.finish("battle move slot dashes bug 1343")
@@ -0,0 +1,80 @@
-- #1338: after the TOWN MAP, Daisy has to swap from the sitting object to
-- the walking one -- PalletTownDaisyScript, gated on both
-- EVENT_GOT_TOWN_MAP and EVENT_ENTERED_BLUES_HOUSE.
-- scripts/BluesHouse.asm:12-16; scripts/PalletTown.asm:133-144
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Story = assert(loadfile("data/scripts/story.lua"))()
local Story2 = assert(loadfile("data/scripts/story2.lua"))()
T.check(type(Story.BLUES_HOUSE.onEnter) == "function",
"M.BLUES_HOUSE.onEnter exists")
T.check(type(Story2.PALLET_TOWN.onEnter) == "function",
"M.PALLET_TOWN.onEnter exists")
-- Entering Blue's House alone must set EVENT_ENTERED_BLUES_HOUSE and touch
-- nothing else: BluesHouseDefaultScript is a plain SetEvent, no swap here.
do
local game = { save = { flags = {} } }
Story.BLUES_HOUSE.onEnter(game, {})
T.check(game.save.flags.EVENT_ENTERED_BLUES_HOUSE == true,
"onEnter sets EVENT_ENTERED_BLUES_HOUSE")
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
"and does not itself start the swap")
end
-- PALLET_TOWN's onEnter is the swap: both events must be set, and it must
-- write the toggle even though BLUES_HOUSE is not the live map (the fix's
-- own precondition, verified against src/script/Commands.lua's toggleObject
-- writing save.objectToggles before its live-NPC early return).
do
local game = {
save = {
flags = { EVENT_GOT_TOWN_MAP = true, EVENT_ENTERED_BLUES_HOUSE = true },
},
}
local ow = { map = { id = "PALLET_TOWN" } }
Story2.PALLET_TOWN.onEnter(game, ow)
T.check(game.save.flags.EVENT_DAISY_WALKING == true,
"both prerequisites set: EVENT_DAISY_WALKING fires")
local toggles = game.save.objectToggles and game.save.objectToggles.BLUES_HOUSE
T.check(toggles ~= nil, "the swap reaches BLUES_HOUSE's toggle table")
T.eq(toggles and toggles.BLUESHOUSE_DAISY1, false,
"the sitting Daisy (DAISY1) is hidden")
T.eq(toggles and toggles.BLUESHOUSE_DAISY2, true,
"the walking Daisy (DAISY2) is shown")
end
-- Only having the map, without ever entering the house, must not swap her:
-- EVENT_ENTERED_BLUES_HOUSE is a real gate, not a formality.
do
local game = {
save = { flags = { EVENT_GOT_TOWN_MAP = true } },
}
Story2.PALLET_TOWN.onEnter(game, { map = { id = "PALLET_TOWN" } })
T.check(game.save.flags.EVENT_DAISY_WALKING == nil,
"without EVENT_ENTERED_BLUES_HOUSE the swap does not fire")
end
-- Re-entering Pallet Town after she has already swapped must not re-run
-- the toggle writes (EVENT_DAISY_WALKING itself is the guard).
do
local game = {
save = {
flags = {
EVENT_GOT_TOWN_MAP = true,
EVENT_ENTERED_BLUES_HOUSE = true,
EVENT_DAISY_WALKING = true,
},
objectToggles = {},
},
}
local ow = { map = { id = "PALLET_TOWN" } }
Story2.PALLET_TOWN.onEnter(game, ow)
T.check(next(game.save.objectToggles) == nil,
"already-walking Daisy: onEnter writes no toggle a second time")
end
T.finish("blues_house_daisy_walking_bug1338")
+56
View File
@@ -0,0 +1,56 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local clock, quit = 1, false
local sent, queue, draws = {}, {}, 0
local peer = {
send = function(_, data) sent[#sent + 1] = data end,
disconnect_now = function() end,
}
local host = {
connect = function() return peer end,
service = function()
if #queue == 0 then return nil end
return table.remove(queue, 1)
end,
}
local rendered = {
setFilter = function() end, replacePixels = function() end,
release = function() end,
}
love = {
timer = { getTime = function() return clock end },
data = { decompress = function(_, _, value) return value end },
image = { newImageData = function(w, h, format, raw)
assert(w == 1 and h == 1 and format == "rgba8" and raw == "rgba")
return {}
end },
graphics = {
newImage = function() return rendered end,
getDimensions = function() return 100, 100 end,
clear = function() end, setColor = function() end,
draw = function() draws = draws + 1 end,
},
event = { quit = function() quit = true end },
}
package.preload.enet = function()
return { host_create = function() return host end }
end
require("src.render.DesktopCompanion").install({ port = 50000, token = "token" })
queue[#queue + 1] = { type = "connect" }
love.update()
assert(sent[#sent] == "Htoken", "companion authenticates after connecting")
queue[#queue + 1] = {
type = "receive", data = "Ftoken\n1,1,0,auto\nrgba",
}
love.update()
love.draw()
assert(draws == 1, "companion draws a received frame")
love.mousepressed(50, 50, 1)
love.mousereleased(50, 50, 1)
assert(sent[#sent - 1] == "Itoken\ndown,0,0"
and sent[#sent] == "Itoken\nup,0,0", "mouse input maps back to source pixels")
queue[#queue + 1] = { type = "receive", data = "Qtoken" }
love.update()
assert(quit, "parent can close the companion")
print("desktop companion: ok")
+57
View File
@@ -0,0 +1,57 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local clock = 1
love = {
timer = { getTime = function() return clock end },
data = {
hash = function() return "digest" end,
encode = function() return "0123456789abcdef0123456789abcdef" end,
compress = function(_, _, value) return value end,
},
}
local sent, spawned, queue = {}, nil, {}
local peer = {
send = function(_, data, channel, flag)
sent[#sent + 1] = { data = data, channel = channel, flag = flag }
end,
disconnect_now = function() end,
}
local host = {
service = function()
if #queue == 0 then return nil end
return table.remove(queue, 1)
end,
destroy = function() end,
}
package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
package.loaded["src.core.HostShell"] = {
spawnSelfDetached = function(args) spawned = args return true end,
}
package.preload.enet = function()
return { host_create = function() return host end }
end
local Screen = require("src.render.SecondScreen")
assert(Screen.usable(), "the shared facade selects the desktop backend")
Screen.setEnabled(true)
assert(spawned and spawned[1]:match("^%-%-display%-companion=%d+,[%w]+$"),
"enabling launches one companion of this app")
local token = spawned[1]:match(",([%w]+)$")
queue[#queue + 1] = { type = "receive", peer = peer, data = "H" .. token }
assert(Screen.detected(), "a token-authenticated companion becomes detected")
local pixels = { getString = function() return "rgba" end }
assert(Screen.push(pixels, 1, 1, 0x102030, "auto"),
"a connected companion accepts a frame")
assert(sent[#sent].data:find("^F" .. token .. "\n1,1,1056816,auto\nrgba"),
"frame metadata and pixels stay in one loopback packet")
queue[#queue + 1] = {
type = "receive", peer = peer, data = "I" .. token .. "\ndown,3,4",
}
assert(Screen.pollTouch() == "down,3,4", "companion input returns to the mod")
Screen.setEnabled(false)
assert(sent[#sent].data == "Q" .. token, "disabling closes the companion")
print("desktop second screen: ok")
@@ -0,0 +1,49 @@
-- engine/battle/experience.asm:69
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BattleState = require("src.battle.BattleState")
local function newSave()
return { player = { id = 12345, name = "RED" } }
end
do
local save = newSave()
local homegrown = {}
BattleState.stampOT(save, homegrown)
T.eq(homegrown.otId, 12345, "a home-grown mon is still stamped with the player id")
T.eq(homegrown.ot, "RED", "and the player's own name")
end
-- The bug: a mon that arrived traded (traded = true) but whose OT id was
-- never recorded (a legacy peer, a link mon with no otId in its packet) used
-- to get save.player.id written into otId on the very first load, which then
-- reads identically to a mon the player caught -- awardExp's OT-id compare
-- (BattleState.lua ~4009) permanently loses the 1.5x boost.
do
local save = newSave()
local tradedNoId = { traded = true }
BattleState.stampOT(save, tradedNoId)
T.eq(tradedNoId.otId, nil,
"a traded mon with no OT id is left unstamped, not silently adopted")
T.eq(tradedNoId.ot, "RED",
"the OT NAME fill still happens (cosmetic, not the boost gate)")
-- a second stampOT pass (a second save/load cycle) must not adopt it either
BattleState.stampOT(save, tradedNoId)
T.eq(tradedNoId.otId, nil, "repeated reloads do not eventually stamp it")
end
-- A mon with its own foreign OT id (the ordinary traded-in case) is untouched
-- either way; this is the arm the regression never broke.
do
local save = newSave()
local tradedWithId = { traded = true, otId = 777 }
BattleState.stampOT(save, tradedWithId)
T.eq(tradedWithId.otId, 777, "a recorded foreign OT id is never overwritten")
end
T.finish("exp traded ot survives reload bug 1265")
+155
View File
@@ -0,0 +1,155 @@
-- engine/battle/effect_commands.asm:1553-1614 BattleCommand_CheckHit
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
FIGHTING = { id = "FIGHTING", index = 1, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SWIFT = { id = "SWIFT", name = "SWIFT", power = 60, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_ALWAYS_HIT" },
VITAL_THROW = { id = "VITAL_THROW", name = "VITAL THROW", power = 70,
type = "FIGHTING", accuracy = 100, pp = 10,
effect = "EFFECT_ALWAYS_HIT" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = {
id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = {
BRIGHTPOWDER = { id = "BRIGHTPOWDER", name = "BRIGHTPOWDER",
heldEffect = "HELD_BRIGHTPOWDER", heldParameter = 51 },
},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function alwaysHighRoll(n)
return 99 % math.max(1, n or 1)
end
local function newBattle()
local player = Mon.new(DATA, "MACHOP", 15, { dvs = perfect })
player.moves = {
{ id = "TACKLE", pp = 35, maxPp = 35 },
{ id = "SWIFT", pp = 20, maxPp = 20 },
{ id = "VITAL_THROW", pp = 10, maxPp = 10 },
}
local wild = Mon.new(DATA, "MACHOP", 15, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = alwaysHighRoll }), player, wild
end
local function moveEvent(events)
for _, event in ipairs(events or {}) do
if event.kind == "move" then return event end
end
return nil
end
local function findText(events, text)
for _, event in ipairs(events or {}) do
if event.kind == "message" and event.text == text then return true end
end
return false
end
local function use(battle, player, wild, moveId)
wild.hp = wild.maxHp
battle:useMove(player, wild, moveId)
local events = battle:takeEvents()
return (moveEvent(events) or {}).missed == true, events
end
do
local battle, player, wild = newBattle()
battle.stages.enemy.evasion = Effects.MAX_STAGE
T.check(use(battle, player, wild, "TACKLE"),
"at +6 evasion a 100-accuracy move misses on a high roll")
T.check(not use(battle, player, wild, "SWIFT"),
"SWIFT ignores the evasion stage")
T.check(wild.hp < wild.maxHp, "and the hit lands damage")
T.check(not use(battle, player, wild, "VITAL_THROW"),
"VITAL THROW carries the same effect")
end
do
local battle, player, wild = newBattle()
battle.stages.player.accuracy = -Effects.MAX_STAGE
T.check(use(battle, player, wild, "TACKLE"),
"at -6 accuracy a 100-accuracy move misses on a high roll")
T.check(not use(battle, player, wild, "SWIFT"),
"SWIFT ignores the accuracy stage")
end
do
local battle, player, wild = newBattle()
wild.item = "BRIGHTPOWDER"
T.check(use(battle, player, wild, "TACKLE"),
"BRIGHTPOWDER alone is enough to miss on a 99 roll")
T.check(not use(battle, player, wild, "SWIFT"),
"the `ret z` sits ahead of .BrightPowder too")
end
do
local battle, player, wild = newBattle()
battle:volatile(wild).vanished = true
battle:volatile(wild).chargeMove = "FLY"
local missed, events = use(battle, player, wild, "SWIFT")
T.check(missed, "a FLY target still dodges SWIFT")
T.check(findText(events, "MACHOP's attack missed!"), "with CheckHit's .Miss")
T.eq(wild.hp, wild.maxHp, "and takes nothing")
end
do
local battle, player, wild = newBattle()
battle:volatile(wild).vanished = true
battle:volatile(wild).chargeMove = "DIG"
T.check(use(battle, player, wild, "SWIFT"), "a DIG target dodges it too")
end
do
local battle, player, wild = newBattle()
battle:volatile(wild).protect = true
local missed, events = use(battle, player, wild, "SWIFT")
T.check(missed, "PROTECT still turns SWIFT aside")
T.check(findText(events, "MACHOP protected itself!"), "with .Protect's line")
T.eq(wild.hp, wild.maxHp, "and takes nothing")
end
do
local battle, player, wild = newBattle()
T.check(not use(battle, player, wild, "SWIFT"),
"with no stage moved at all SWIFT still hits")
T.check(not use(battle, player, wild, "TACKLE"),
"and a plain move only fails the 99 roll once a stage has moved")
end
T.finish("gen2 always-hit bug 1272")
@@ -0,0 +1,82 @@
-- data/moves/animations.asm:379
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local UI = require("src.ui.gen2.BattleState")
local function newSelf(opts)
opts = opts or {}
return setmetatable({
anim = opts.anim,
ballThrow = opts.ballThrow,
picHidden = { player = false, enemy = false },
pendingAfterAnim = nil,
afterSendOut = nil,
}, { __index = UI })
end
-- pushCaught itself must never touch picHidden: only the animation's own
-- steps (stepAnim) are allowed to latch the enemy pic box.
do
local self1 = setmetatable({
battle = {}, tutorial = true, save = nil,
queue = {}, picHidden = { player = false, enemy = false },
}, { __index = UI })
self1:pushCaught({ species = "RATTATA" }, "POKE_BALL")
T.eq(self1.picHidden.enemy, false,
"pushCaught alone does not hide the enemy pic")
T.eq(self1.battle.outcome, "caught", "pushCaught still marks the battle caught")
end
-- stepAnim, natural end (anim:step() returns false): a caught ball throw
-- latches, everything else does not.
do
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, true, "caught ball throw latches at the natural end")
T.eq(s.anim, nil, "the finished runner is cleared")
end
do
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, false, "a break-free throw does not latch")
end
do
local otherAnim = { animId = "ANIM_HYDRO_PUMP",
step = function() return false end, keepSprites = false }
local s = newSelf({ anim = otherAnim, ballThrow = { caught = true } })
s:stepAnim(nil)
T.eq(s.picHidden.enemy, false, "an unrelated animation never latches")
end
-- stepAnim, cut short with B: the property the latch exists for -- a caught
-- mon must not reappear even if the player skips past "Gotcha!".
do
local caughtAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return true end, keepSprites = false }
local s = newSelf({ anim = caughtAnim, ballThrow = { caught = true } })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, true, "a B-skipped catch still latches")
T.eq(s.anim, nil, "B cuts the runner short")
end
do
local breakFreeAnim = { animId = "ANIM_THROW_POKE_BALL",
step = function() return true end, keepSprites = false }
local s = newSelf({ anim = breakFreeAnim, ballThrow = { caught = false } })
local input = { wasPressed = function(_, key) return key == "b" end }
s:stepAnim(input)
T.eq(s.picHidden.enemy, false, "a B-skipped break-free does not latch")
end
T.finish("gen2 ball throw pic latch bug 1232")
@@ -0,0 +1,58 @@
-- engine/battle_anims/anim_commands.asm:755 BattleAnimCmd_BattlerGFX_1Row
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local function findLoaded(runner, gfx)
for _, entry in ipairs(runner.loaded) do
if entry.gfx == gfx then return entry end
end
return nil
end
do
local runner = AnimRunner.new({})
runner:start(nil)
runner:loadBattlerGfx(1)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.check(head and feet, "both pseudo-sheets registered")
T.eq(head.battler, "enemy",
"GFX_PLAYERHEAD's tiles are the ENEMY's feet row")
T.eq(head.tiles, 7, "seven tiles, the enemy pic's width")
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the asm's fixed base")
T.eq(feet.battler, "player",
"GFX_ENEMYFEET's tiles are the PLAYER's head row")
T.eq(feet.tiles, 6, "six tiles, the backpic's width")
T.eq(feet.tile, (0x80 - 6) - 49, "at the asm's fixed base")
T.eq(head.rows, 1, "one row each")
T.eq(feet.rows, 1, "on both sheets")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
runner:loadBattlerGfx(2)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
T.eq(head.battler, "enemy", "2ROW keeps the same crossing")
T.eq(head.tiles, 14, "two enemy rows")
T.eq(feet.battler, "player", "on both sides")
T.eq(feet.tiles, 12, "two player rows")
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "2ROW base")
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
end
do
local runner = AnimRunner.new({})
runner:start(nil)
AnimRunner.COMMANDS.battlergfx_1row(runner)
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
T.eq(head and head.battler, "enemy", "the script command routes the same way")
end
T.finish("gen2 battler gfx row attribution bug 1231")
@@ -0,0 +1,68 @@
-- engine/battle_anims/bg_effects.asm:406-471 BattleBGEffect_BattlerObj_1Row
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local BgEffects = require("src.battle.gen2.BgEffects")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
do
local bg = BgEffects.new(nil, { battleTurn = 0 })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
bg:playFrame()
local spawns = bg:takeSpawns()
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
"player attacking: the enemy's feet row becomes an OBJ")
T.eq(spawns[1] and spawns[1].x, 16 * 8 + 4, "at the asm's fixed x")
T.eq(bg.liftedRows.enemy, nil, "the tilemap row is intact on frame one")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
"an intact tilemap with no scroll skips the bake canvas")
bg:playFrame()
local lifted = bg.liftedRows.enemy
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
"frame two ClearBoxes row 6 of the enemy box (hlcoord 12, 6)")
T.eq(bg.hidden.enemy, false, "the rest of the pic stays on the BG")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
"a lifted row keeps the panel on the bake canvas even with scx 0 and no"
.. " lcdc pointer, so drawPic's 160x144 scissor stays in canvas space")
for _ = 1, 4 do bg:playFrame() end
T.eq(bg:activeCount(), 0, ".five ends the effect")
lifted = bg.liftedRows.enemy
T.check(lifted and lifted[1] == 6 and lifted[2] == 1,
".five never restores the row")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), true,
"and the wait frames after .five stay baked as well")
bg:queue("BATTLE_BG_EFFECT_SHOW_MON", 0, 0, 0)
bg:playFrame()
T.eq(bg.liftedRows.enemy, nil, "SHOW_MON's box redraw puts the row back")
T.eq(BattleAnimView.needsCanvas({ bg = bg }), false,
"after which the plain no-canvas path returns")
end
do
local bg = BgEffects.new(nil, { battleTurn = 1 })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_2ROW", 0, 0, 0)
bg:playFrame()
local spawns = bg:takeSpawns()
T.eq(spawns[1] and spawns[1].object, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
"enemy attacking: the player's head rows become an OBJ")
T.eq(spawns[1] and spawns[1].x, 6 * 8, "at the asm's fixed x")
bg:playFrame()
local lifted = bg.liftedRows.player
T.check(lifted and lifted[1] == 0 and lifted[2] == 2,
"rows 0-1 of the player box (hlcoord 2, 6, two rows)")
T.eq(bg.liftedRows.enemy, nil, "the attacker keeps its own rows")
end
do
local bg = BgEffects.new(nil, { battleTurn = 0, flying = { enemy = true } })
bg:queue("BATTLE_BG_EFFECT_BATTLEROBJ_1ROW", 0, 0, 0)
bg:playFrame()
T.eq(#bg:takeSpawns(), 0, "a flying target spawns nothing")
T.eq(bg:activeCount(), 0, "and the effect ends at once")
T.eq(bg.liftedRows.enemy, nil, "with no row lifted")
end
T.finish("gen2 battler row lift bug 1231")
@@ -0,0 +1,94 @@
-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
GROUND = { id = "GROUND", index = 1, category = "physical" },
FLYING = { id = "FLYING", index = 2, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND",
accuracy = 100, pp = 10, effect = "EFFECT_FLY" },
FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING",
accuracy = 95, pp = 15, effect = "EFFECT_FLY" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function highRoll(n) return (n or 1) - 1 end
local function newBattle(moveId)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = { { id = moveId, pp = 20, maxPp = 20 } }
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = highRoll }), player, wild
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
do
local battle, player, wild = newBattle("DIG")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev = moveEvent(battle.events)
T.check(ev ~= nil, "the charge turn queues a move event")
T.eq(ev and ev.animParam, 1,
"DIG's charge (burrow) turn carries animParam 1, the take-cover script arm")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev2 = moveEvent(battle.events)
T.check(ev2 ~= nil, "the strike turn also queues a move event")
T.eq(ev2 and ev2.animParam, nil,
"DIG's strike turn leaves animParam nil, the hit script arm")
end
do
local battle, player, wild = newBattle("FLY")
battle.events = {}
battle:useMove(player, wild, "FLY")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1")
end
-- a plain hit-and-run move never sets a parameter at all
do
local battle, player, wild = newBattle("DIG")
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
battle.events = {}
battle:useMove(player, wild, "TACKLE")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam")
end
T.finish("gen2 charge move anim param bug 1293")
+83
View File
@@ -0,0 +1,83 @@
-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask
-- the bag about the real COIN_CASE item id, not SILVER_WING.
-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Vm = require("src.script.gen2.Vm")
local Specials = require("src.script.gen2.Specials")
local Events = require("src.world.gen2.Events")
local COIN_CASE = 0x36
local SILVER_WING = 0x47
-- 1) the id the handler actually queries the bag with
local seenId
local vm = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 100 end,
hasItem = function(id) seenId = id return true end,
gameCornerGame = function(_, done) done() end,
},
})
vm.showTextFn = function() end
vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end)
coroutine.resume(vm.co)
T.eq(seenId, COIN_CASE,
"SlotMachine's CheckItem call carries the real COIN_CASE id ($36)")
T.check(seenId ~= SILVER_WING,
"and specifically not SILVER_WING ($47), the pre-fix value")
-- 2) a bag holding ONLY the real coin case (not the silver wing) must be
-- enough to open both machines: this is what would still fail if the id
-- above were merely logged and not actually used to gate the machine.
local bag = { [COIN_CASE] = true }
local slotsOpened, flipOpened
local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) slotsOpened = kind done() end,
},
})
vm2.showTextFn = function() end
vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end)
coroutine.resume(vm2.co)
T.eq(slotsOpened, "slots",
"a bag with the real COIN CASE and coins opens the slot machine")
local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) flipOpened = kind done() end,
},
})
vm3.showTextFn = function() end
vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end)
coroutine.resume(vm3.co)
T.eq(flipOpened, "cardflip",
"and the same bag opens card flip too")
-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still
-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it
-- is the yield the coroutine parks on, not a call, so opening never runs
-- behind it).
local wrongBag = { [SILVER_WING] = true }
local opened = false
local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return wrongBag[id] == true end,
gameCornerGame = function() opened = true end,
},
})
vm4.showTextFn = function() end
vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end)
local _, refusal = coroutine.resume(vm4.co)
T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.",
"holding only SILVER_WING gets the real _NoCoinCaseText refusal")
T.check(not opened, "and the machine never opens behind it")
T.finish("gen2_coin_case_bug1251")
@@ -0,0 +1,166 @@
-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold),
-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll)
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local UI = require("src.ui.gen2.BattleState")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
-- a controllable roll queue; falls back to a high roll (never fails an AI
-- check) once drained
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ---------------------------------------------------------------- gap 2:
-- the AI's 25% "miss" on a support move, and who is exempt from the roll.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails)
battle:useMove(wild, player, "GROWL")
T.check(findText(battle.events, "But it failed!"),
"enemy GROWL fails 25% of the time with the specific line")
T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true,
"an AI-failed move is marked missed (feeds the 40 frame hold)")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 200 } -- AI roll passes (>=64): lands
battle:useMove(wild, player, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the same move lands when the AI roll passes")
end
do
local battle, player, wild = newBattle(
{ { id = "GROWL", pp = 40, maxPp = 40 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
rolls = { 0, 10 } -- if the player rolled too, 10 would fail it
battle:useMove(player, wild, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the player's own GROWL is exempt from the AI roll")
end
-- --------------------------------------------------------------- gap 1:
-- the reported symptom -- the "used X!" line must hold before a failure.
do
local input = { wasPressed = function() return false end }
local ui = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = {
{ kind = "move", side = "enemy", move = "GROWL",
text = "Enemy MACHOP used GROWL!", missed = true },
{ kind = "message", text = "But it failed!" },
},
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
}, { __index = UI })
ui:advanceQueue()
T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first")
T.eq(ui.messageDelay, 40,
"a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)")
T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself")
local frames = 0
for _ = 1, 100 do
if ui.message == "But it failed!" then break end
ui:update(1 / 60)
frames = frames + 1
end
T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line")
T.eq(frames, 41, "the used line held for exactly the 40 delay frames")
T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B")
end
do
local input = { wasPressed = function() return false end }
local ui2 = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = { { kind = "move", side = "player", move = "TACKLE",
text = "MACHOP used TACKLE!" } },
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
animForMove = function() return false end,
}, { __index = UI })
ui2:advanceQueue()
T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all")
end
T.finish("gen2 enemy move fail text bug 1296")
@@ -0,0 +1,92 @@
-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the
-- group's OWN chance byte before the rod's cumulative list even runs
-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte
-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by
-- rod). A cache built before the extractor carried the byte has no
-- `chance` field on the group row at all and must keep fishing unconditionally.
-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
MAGIKARP = {
name = "MAGIKARP", types = { "WATER", "WATER" },
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
specialAttack = 15, specialDefense = 20 },
levelMoves = {},
},
},
}
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
local function fakeMap(waterCell)
return {
id = "TEST_MAP",
def = { fishGroup = "FISHGROUP_POND" },
cellCollision = function(_, x, y)
return (x == waterCell[1] and y == waterCell[2])
and COLL_WATER or COLL_FLOOR
end,
}
end
-- Rod lists that always hand back a species once a bite happens, so only the
-- group gate (or its absence) decides the outcome below.
local function fishGroups(chance)
return {
FISHGROUP_POND = {
chance = chance,
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
},
}
end
local function fakeWorld(chance)
local game = { data = DATA, save = { party = {} } }
local world = World.new(game)
world.map = fakeMap({ 5, 4 })
world.maps = { TEST_MAP = world.map.def }
world.encounters = { fishGroups = fishGroups(chance) }
world.player = { cellX = 5, cellY = 5, facing = "up" }
return world
end
-- ---- chance 0: the group byte fails Random every time, always a nibble ---
-- engine/events/fish.asm:24-30
do
local world = fakeWorld(0)
for rod = 1, 3 do
local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
eq(outcome, "nibble",
"chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
end
end
-- ---- chance 256: the group byte always passes, every rod finds the mon ---
do
local world = fakeWorld(256)
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
local outcome, wild = world:rollFishing(rod)
eq(outcome, "battle", "chance 256 always bites on " .. rod)
check(wild and wild.species == "MAGIKARP",
"and the rod's own list still resolves a species")
end
end
-- ---- no chance field at all: an old cache keeps fishing unconditionally --
do
local world = fakeWorld(nil)
local outcome = world:rollFishing("OLD_ROD")
eq(outcome, "battle",
"a cache with no group chance byte is not gated at all")
end
T.finish("gen2 fishing bite gate bug1368")
@@ -0,0 +1,115 @@
-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette
-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283
-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list
-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still
-- roll its night table after dark (#1389, Gastly unobtainable), and a
-- PALETTE_NITE cave must still roll its morning/day table at noon.
-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
RATTATA = {
name = "RATTATA", types = { "NORMAL", "NORMAL" },
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
levelMoves = {},
},
GASTLY = {
name = "GASTLY", types = { "GHOST", "POISON" },
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
specialAttack = 100, specialDefense = 35 },
levelMoves = {},
},
},
}
local function fullList(species)
local slots = {}
for i = 1, 7 do slots[i] = { species = species, level = 10 } end
return slots
end
-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common
-- and worthless, night is the whole reason the room exists. A second,
-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon):
-- the rates are flipped so the two fixtures cannot agree by accident.
local ENCOUNTERS = {
grass = {
SPROUT_TOWER_2F = {
rates = { MORN = 0, DAY = 0, NITE = 256 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
ILEX_FOREST = {
rates = { MORN = 256, DAY = 256, NITE = 0 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
},
}
local COLL_FLOOR = 0x00
local function fakeWorld(mapId, tod, daytime)
local game = { data = DATA,
save = { party = { { species = "RATTATA", level = 5 } } } }
local world = World.new(game)
world.map = {
id = mapId,
def = { environment = "DUNGEON" },
cellCollision = function() return COLL_FLOOR end,
}
world.maps = { [mapId] = world.map.def }
world.encounters = ENCOUNTERS
world.player = { cellX = 5, cellY = 5, facing = "down" }
-- World:applyPalettes writes both fields every load; a PALETTE_DAY tower
-- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the
-- clock (src/world/gen2/World.lua:8271-8326).
world.tod = tod
world.daytime = daytime
local battled
world.startBattle = function(_, opts)
battled = opts.wild and opts.wild.species
return true
end
return world, function() return battled end
end
-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY --
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY")
check(world:tryWildEncounter(), "the tower rolls at night despite the pin")
eq(battled(), "GASTLY",
"the night list wins because the lookup is the clock, not the pin")
end
-- ---- the same tower at actual daytime: the clock and the pin now agree ---
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY")
check(not world:tryWildEncounter(),
"DAY's rate is zero, so a daytime step in the tower rolls nothing")
eq(battled(), nil, "and nothing battled")
end
-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY --
do
local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE")
check(world:tryWildEncounter(),
"a pinned-night map still rolls its day table at the clock's noon")
eq(battled(), "RATTATA",
"the day list wins because the lookup ignores the palette pin")
end
T.finish("gen2 grass encounter tod bug1389")
+54
View File
@@ -0,0 +1,54 @@
-- Gen 2 map bakes take dpiscale 1 so map pixels stay square LCD pixels
-- (#208 #1301, constants/hardware.inc:932; see src/render/PixelCanvas.lua).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local g = love.graphics
local seen = {}
local realNewCanvas = g.newCanvas
g.newCanvas = function(w, h, settings)
seen[#seen + 1] = { w = w, h = h, dpiscale = settings and settings.dpiscale }
local c = realNewCanvas(w, h)
c.renderTo = function(_, fn) fn() end
c.setFilter = function() end
return c
end
local World = require("src.world.gen2.World")
local MapPreview = require("src.world.gen2.MapPreview")
local atlas = {
getDimensions = function() return 128, 128 end,
setFilter = function() end,
}
local tileset = { blocks = { [1] = {} }, tilesPerRow = 16 }
local map = { def = { tileset = "TILESET_JOHTO" }, width = 20, height = 18,
blocks = {}, borderBlock = 0 }
local world = setmetatable({
atlasCache = {},
atlasFor = function() return atlas, tileset end,
}, { __index = World })
World.bakeMapImage(world, map, nil, nil)
local bakeCount = #seen
T.check(bakeCount >= 1, "the map bake allocated a canvas")
World.scrollStrip(world, map.def, tileset, 3, { h = 1, v = 0 })
T.check(#seen > bakeCount, "the scroll strip allocated a canvas")
local stripCount = #seen
local origAtlasFor = MapPreview.atlasFor
MapPreview.atlasFor = function() return atlas, tileset end
MapPreview.bake({ tilesets = {}, atlasCache = {}, mapImages = {} }, map, "DAY")
MapPreview.atlasFor = origAtlasFor
T.check(#seen > stripCount, "the save-editor bake allocated a canvas")
for i, c in ipairs(seen) do
T.eq(c.dpiscale, 1,
("canvas %d (%dx%d) is allocated at dpiscale 1"):format(i, c.w, c.h))
end
g.newCanvas = realNewCanvas
T.finish("gen2 map bake dpi")
+268
View File
@@ -0,0 +1,268 @@
-- engine/battle/move_effects/pay_day.asm:13, engine/battle/core.asm:8014-8042
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local BattleState = require("src.ui.gen2.BattleState")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")
local Prize = require("src.battle.gen2.Prize")
local LEVEL = 15
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
PAY_DAY = { id = "PAY_DAY", name = "PAY DAY", power = 40, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_PAY_DAY" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MEOWTH = {
id = "MEOWTH", index = 52, name = "MEOWTH",
baseStats = { hp = 40, attack = 45, defense = 35, speed = 90,
specialAttack = 40, specialDefense = 40 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 69,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "PAY_DAY" } }, evolutions = {},
},
RATTATA = {
id = "RATTATA", index = 19, name = "RATTATA",
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = {},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function highRoll(n) return 99 % math.max(1, n or 1) end
local function newSave()
return { player = { name = "GOLD", money = 1000, id = 4242 },
mom = { savedMoney = 500 }, party = {} }
end
local function newBattle(save)
local player = Mon.new(DATA, "MEOWTH", LEVEL, { dvs = perfect })
player.moves = {
{ id = "PAY_DAY", pp = 20, maxPp = 20 },
{ id = "TACKLE", pp = 35, maxPp = 35 },
}
local wild = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
wild.maxHp = 999
wild.hp = 999
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = highRoll, save = save })
return battle, player, wild
end
local function eventWithText(events, text)
for _, event in ipairs(events or {}) do
if event.text == text then return event end
end
return nil
end
local function findText(events, text)
return eventWithText(events, text) ~= nil
end
do
local battle, player, wild = newBattle(newSave())
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, 2 * LEVEL, "one connected hit is 2 x the user's level")
T.check(findText(battle:takeEvents(), "Coins scattered\neverywhere!"),
"CoinsScatteredText rides the hit")
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, 4 * LEVEL, "a second hit adds the same again")
battle:useMove(player, wild, "TACKLE")
T.eq(battle.payDay, 4 * LEVEL, "another move adds nothing")
end
do
local battle, player, wild = newBattle(newSave())
battle.stages.enemy.evasion = Effects.MAX_STAGE
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, nil, "a miss accumulates nothing")
end
do
local battle, player, wild = newBattle(newSave())
battle.amuletCoin = true
battle:useMove(player, wild, "PAY_DAY")
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, 4 * LEVEL,
"the Amulet Coin does not double the running counter")
end
do
local battle, player, wild = newBattle(newSave())
local enemy = battle.enemy
enemy.level = 40
battle:useMove(enemy, player, "PAY_DAY")
T.eq(battle.payDay, 80, "an enemy's PAY DAY pays the player, at ITS level")
T.check(wild == battle.enemy, "the enemy is the wild mon")
end
do
local save = newSave()
T.eq(Prize.payDay(save, 30, false), 30, "the payout returns what it paid")
T.eq(save.player.money, 1030, "and writes the wallet directly")
T.eq(save.mom.savedMoney, 500, "with no Mom split")
end
do
local save = newSave()
T.eq(Prize.payDay(save, 30, true), 60, "the Amulet Coin doubles the total")
T.eq(save.player.money, 1060, "once, at payout")
end
do
local save = newSave()
T.eq(Prize.payDay(save, 0, true), nil, "an empty counter pays nothing")
T.eq(Prize.payDay(save, nil, false), nil, "and neither does an unset one")
T.eq(save.player.money, 1000, "the wallet is untouched")
T.eq(Prize.payDay(nil, 30, false), nil, "a save-less battle pays nothing")
end
do
local save = newSave()
save.player.money = Prize.MAX_MONEY - 10
Prize.payDay(save, 500, true)
T.eq(save.player.money, Prize.MAX_MONEY, "AddBattleMoneyToAccount clamps")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
wild.hp = 0
T.check(battle:resolveFaints(), "the enemy faint ends the battle")
T.eq(battle.outcome, "win", "on the win arm")
T.eq(save.player.money, 1000 + 2 * LEVEL, "which is where CheckPayDay runs")
T.eq(battle.payDay, nil, "and the counter is cleared")
local paid = eventWithText(battle:takeEvents(), "GOLD picked up ¥30!")
T.check(paid, "BattleText_PlayerPickedUpPayDayMoney")
T.eq(paid and paid.kind, "money", "on the same event kind the prize uses")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle.amuletCoin = true
battle:useMove(player, wild, "PAY_DAY")
wild.hp = 0
battle:resolveFaints()
T.eq(save.player.money, 1000 + 4 * LEVEL,
"the Amulet Coin doubles once on the way out")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
player.hp = 0
T.check(battle:resolveFaints(), "a whiteout ends the battle")
T.eq(battle.outcome, "lose", "on the lose arm")
T.eq(save.player.money, 1000, "which pays nothing")
T.eq(battle.payDay, 2 * LEVEL, "the counter is dropped, not banked")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
battle:endBattle("run")
T.eq(save.player.money, 1000, "running away pays nothing")
end
local function newScreen(save, battle, opts)
opts = opts or {}
local pushed = {}
local screen = setmetatable({
save = save, battle = battle, picHidden = {},
contest = opts.contest, tutorial = opts.tutorial,
contestCaught = false,
push = function(self, event) pushed[#pushed + 1] = event end,
name = function(_, mon) return mon.species end,
hasPokedex = function() return false end,
currentBox = function() return 1 end,
contestCatch = function(self) self.contestCaught = true end,
}, { __index = BattleState })
return screen, pushed
end
local function caughtEnemy(battle)
local enemy = battle.enemy
enemy.hp = 1
return enemy
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
local screen, pushed = newScreen(save, battle)
screen:pushCaught(caughtEnemy(battle), "POKE_BALL")
T.eq(save.player.money, 1000 + 2 * LEVEL, "a capture pays out")
T.eq(battle.payDay, nil, "and clears the counter")
T.check(findText(pushed, "GOLD picked up ¥30!"), "with the payout line")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
local screen = newScreen(save, battle, { contest = true })
screen:pushCaught(caughtEnemy(battle), "PARK_BALL")
T.check(screen.contestCaught, "the contest arm still holds the mon")
T.eq(save.player.money, 1000 + 2 * LEVEL, "and a contest catch pays too")
T.eq(battle.payDay, nil, "clearing the counter with it")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
local screen = newScreen(save, battle, { tutorial = true })
screen:pushCaught(caughtEnemy(battle), "POKE_BALL")
T.eq(save.player.money, 1000, "the catching tutorial pays nothing")
T.eq(battle.payDay, 2 * LEVEL, "and leaves the counter alone")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
wild.hp = 0
battle:resolveFaints()
local screen = newScreen(save, battle, { contest = true })
screen:pushPayDay()
T.eq(save.player.money, 1000 + 2 * LEVEL,
"a contest KO that already paid does not pay a second time")
end
T.finish("gen2 pay day bug 1212")
@@ -0,0 +1,88 @@
-- Gold #DEX AREA page drew no nest markers or landmark name because
-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of
-- the gen2Landmarks table Nests already resolves through (#1267).
-- engine/pokegear/pokegear.asm:2427
-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.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 PokedexMenu = require("src.ui.gen2.PokedexMenu")
local Nests = require("src.core.gen2.Nests")
-- one Johto landmark (index 5), one species that nests there
local data = {
gen2Encounters = {
grass = {
ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } },
},
},
gen2Maps = {
ROUTE_30 = { landmark = 5 },
},
gen2Landmarks = {
landmarks = {
LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" },
},
},
}
-- sanity: Nests.landmark itself resolves the index (never broken, per the
-- verifier) so a failure below is isolated to drawArea's own lookup
eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30",
"Nests.landmark resolves index 5 to the ROUTE 30 record")
-- capture what drawArea actually paints, without needing a real tile sheet
-- or font: fill/blank/current/monName are stubbed on the instance, which
-- Lua resolves before the PokedexMenu metatable's own methods.
local function newSelf()
local texts = {}
local rects = {}
local self = setmetatable({
game = { save = {} },
data = data,
mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet
areaRegion = "johto",
areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase
current = function() return { species = "RATTATA" } end,
monName = function() return "RATTATA" end,
fill = function() end,
blank = function() end,
text = function(_, str, tx, ty)
texts[#texts + 1] = { str = str, tx = tx, ty = ty }
end,
}, { __index = PokedexMenu })
return self, texts, rects
end
local realRect = love.graphics.rectangle
local self, texts, rects
do
self, texts, rects = newSelf()
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
end
self:drawArea()
love.graphics.rectangle = realRect
end
local function hasRect(x, y)
for _, r in ipairs(rects) do
if r.x == x and r.y == y then return true end
end
return false
end
check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2")
local function hasText(str)
for _, t in ipairs(texts) do
if t.str == str then return true end
end
return false
end
check(hasText("ROUTE 30"), "the landmark name is printed on row 16")
T.finish("gen2 pokedex area landmark bug 1267")
@@ -0,0 +1,93 @@
-- GetMapMusic (pokegold home/map.asm:2550), #1385
-- luajit tests/engine/gen2_rocket_map_music_bug1385.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
function Source:getDuration() return 1 end
local made = {}
love.audio = {
newSource = function(file, mode)
made[file] = setmetatable({ file = file, mode = mode }, Source)
return made[file]
end,
}
local World = require("src.world.gen2.World")
local Music = require("src.core.Music")
-- constants/music_constants.asm:100,:109
local MUSIC_MAHOGANY_MART = 100
local RADIO_TOWER_SENTINEL = 0x80 + 61
local order = {}
order[61 + 1] = "Music_GoldenrodCity"
local audio = {
musicOrder = order,
songs = {
Music_RocketHideout = { file = "rocket_hideout.wav" },
Music_CherrygroveCity = { file = "cherrygrove.wav" },
Music_RocketTheme = { file = "rocket_theme.wav" },
Music_GoldenrodCity = { file = "goldenrod.wav" },
},
}
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, true, false),
"Music_RocketHideout",
"MAHOGANY_MART_1F with rockets in the mart plays the hideout theme")
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, false, false),
"Music_CherrygroveCity",
"MAHOGANY_MART_1F after the hideout is cleared plays Cherrygrove")
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, true),
"Music_RocketTheme",
"RADIO_TOWER floors during the takeover play the rocket theme")
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, false),
"Music_GoldenrodCity",
"RADIO_TOWER floors otherwise fall back to the low bits of the byte")
eq(World.mapMusicLabel(audio, 72, true, true), nil,
"a plain song id stays with the mapSongs table")
eq(World.mapMusicLabel(audio, nil, true, true), nil,
"a missing music byte resolves to nothing")
local data = { audio = {
songs = {
Music_RocketHideout = { file = "rocket_hideout.wav" },
Music_Victory = { file = "victory.wav" },
},
mapSongs = {},
} }
local function playing()
for file, src in pairs(made) do
if src.playing then return file end
end
return "(silence)"
end
Music.stop()
Music.playMap(data, "MAHOGANY_MART_1F", false, false, nil,
"Music_RocketHideout")
eq(playing(), "rocket_hideout.wav",
"the resolved song overrides the empty mapSongs table")
Music.play(data, "Music_Victory", nil, { reason = "battle" })
eq(playing(), "victory.wav", "the battle result theme takes over")
Music.restoreMap(data)
eq(playing(), "rocket_hideout.wav",
"restoreMap replays the resolved song, ending the victory loop")
T.finish("gen2_rocket_map_music_bug1385")
+171
View File
@@ -0,0 +1,171 @@
-- engine/battle/move_effects/safeguard.asm:1, engine/battle/effect_commands.asm:6325
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
FIRE = { id = "FIRE", index = 2, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SAFEGUARD = { id = "SAFEGUARD", name = "SAFEGUARD", power = 0,
type = "NORMAL", accuracy = 100, pp = 25, effect = "EFFECT_SAFEGUARD" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
SACRED_FIRE = { id = "SACRED_FIRE", name = "SACRED FIRE", power = 100,
type = "FIRE", accuracy = 95, pp = 5, effect = "EFFECT_SACRED_FIRE",
effectChance = 50 },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ------------------------------------------------------------- 1388a
-- Safeguard sets the USER's own side, not the target's.
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
T.eq(battle.screens.player.safeguard, 5, "Safeguard sets the CASTER's side for 5 turns")
T.check((battle.screens.enemy.safeguard or 0) == 0,
"and never touches the opposing side")
T.check(findText(battle.events, "covered by a veil"), "the veil line is emitted")
end
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
battle.events = {}
rolls = { 200 } -- force the enemy's AI roll not to fail on its own
battle:useMove(wild, player, "THUNDER_WAVE")
T.check(findText(battle.events, "protected by SAFEGUARD"),
"an incoming status move from the OTHER side is blocked, loudly")
T.eq(player.status, nil, "and the paralysis never lands")
local ev = moveEvent(battle.events)
T.eq(ev and ev.missed, true, "the blocked move is marked missed")
end
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
T.check(findText(battle.events, "But it failed!"),
"using it again while it is already up simply fails")
end
do
-- the player's OWN status move against a safeguarded enemy is blocked too:
-- the effect reads whichever side is being TARGETED, not just "the enemy".
local battle, player, wild = newBattle(
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.screens.enemy.safeguard = 5
battle.events = {}
battle:useMove(player, wild, "THUNDER_WAVE")
T.check(findText(battle.events, "protected by SAFEGUARD"),
"the player's status move is blocked by the enemy's own safeguard")
T.eq(wild.status, nil, "the enemy stays unstatused under its own screen")
end
do
local battle = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.screens.player.safeguard = 1
battle.events = {}
battle:tickScreens()
T.check(findText(battle.events, "SAFEGUARD faded"), "it fades after its five turns")
T.eq(battle.screens.player.safeguard, nil, "and clears off the side entirely")
end
-- ------------------------------------------------------------- 1388b
-- Sacred Fire's burn was never implemented at all.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
battle.events = {}
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } -- everything rolls low: hit, no
-- crit, and the 50% secondary effect chance all pass
battle:useMove(wild, player, "SACRED_FIRE")
T.eq(player.status, "burn", "Sacred Fire can now burn its target")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
battle.screens.player.safeguard = 5
battle.events = {}
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
battle:useMove(wild, player, "SACRED_FIRE")
T.eq(player.status, nil, "Safeguard blocks the burn a damaging move carries")
T.check(not findText(battle.events, "protected by SAFEGUARD"),
"the secondary block is silent (SafeCheckSafeguard, not CheckSafeguard)")
local ev = moveEvent(battle.events)
T.check(ev and not ev.missed,
"the hit itself still lands and is not marked missed")
end
T.finish("gen2 safeguard bug 1388")
@@ -0,0 +1,89 @@
-- engine/battle_anims/anim_commands.asm:603 BattleAnimCmd_BGP
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local BgEffects = require("src.battle.gen2.BgEffects")
local GbcPalette = require("src.render.GbcPalette")
-- data/moves/animations.asm:4509 BattleAnim_ShadowBall
local SHADOW_BALL = {
{ "2gfx", "BATTLE_ANIM_GFX_EGG", "BATTLE_ANIM_GFX_SMOKE" },
{ "bgp", 0x1b },
{ "sound", 6 * 4 + 2, 0 },
{ "obj", "BATTLE_ANIM_OBJ_SHADOW_BALL", 64, 92, 0x2 },
{ "wait", 32 },
}
do
local runner = AnimRunner.new({
data = { scripts = { SHADOW_BALL = SHADOW_BALL } },
})
runner:start("SHADOW_BALL")
T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL, "identity ramp before the script")
T.check(runner:step(), "the script is still running after frame one")
T.eq(runner.bg.bgp, 0x1b,
"anim_bgp $1b lands in wBGP: the inverted ramp the view must apply")
runner.bg:reset()
T.eq(runner.bg.bgp, BgEffects.NORMAL_PAL,
"BattleAnim_RevertPals puts the identity back")
end
do
T.eq(GbcPalette.BGP_IDENTITY, 0xe4, "dc 3, 2, 1, 0")
local colors = { "c0", "c1", "c2", "c3" }
local out = GbcPalette.remap(colors, 0x1b)
T.eq(out[1], "c3", "$1b is dc 0, 1, 2, 3: colour 0 shows shade 3")
T.eq(out[2], "c2", "colour 1 shows shade 2")
T.eq(out[3], "c1", "colour 2 shows shade 1")
T.eq(out[4], "c0", "colour 3 shows shade 0")
T.check(GbcPalette.remap(colors, 0xe4) == colors,
"the identity byte returns the palette untouched")
end
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
do
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
local palettes = {
pokemon = {
[6] = { normal = { { 200, 100, 50 }, { 80, 40, 20 } } },
[25] = { normal = { { 230, 200, 40 }, { 150, 90, 20 } } },
},
hpBar = {
green = { { 100, 220, 100 }, { 30, 160, 30 } },
yellow = { { 230, 220, 90 }, { 180, 150, 20 } },
red = { { 230, 100, 90 }, { 180, 30, 20 } },
},
expBar = { { 120, 140, 230 }, { 40, 60, 160 } },
}
local view = BattleAnimView.new({}, palettes)
local battle = { player = { species = 6 }, enemy = { species = 25 } }
local list = view:panelPalettes(battle)
T.eq(#list, 7, "shades + two mons + three hp bars + exp bar")
local src, dst, count, ambiguous = GbcPalette.remapTable(list, 0x1b)
T.check(count > 0 and count <= GbcPalette.REMAP_MAX,
"the table fits the shader array")
T.eq(ambiguous, 0, "no colour maps two ways")
local function mapped(from)
for i = 1, count do
if src[i][1] == from[1] and src[i][2] == from[2]
and src[i][3] == from[3] then
return dst[i]
end
end
end
local black = mapped({ 255, 255, 255 })
T.check(black and black[1] == 0 and black[2] == 0 and black[3] == 0,
"white inverts to black")
local white = mapped({ 0, 0, 0 })
T.check(white and white[1] == 255 and white[2] == 255 and white[3] == 255,
"black inverts to white")
local mid = mapped({ 200, 100, 50 })
T.check(mid and mid[1] == 80 and mid[2] == 40 and mid[3] == 20,
"the mon's colour 1 shows its colour 2")
end
T.finish("gen2 shadow ball bgp bug 1269")
@@ -0,0 +1,86 @@
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
--
-- gen2_shadow_ball_bgp_bug1269.lua proves the runner lands bg.bgp and that
-- panelPalettes/remapTable would invert correctly; it never calls
-- BattleAnimView:present, which is where #1269 actually lived (the byte
-- was landed but nothing read it). This suite drives present() itself and
-- watches the shader binding around the backdrop draw.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
-- A remap shader that never touches the GPU: present() only needs
-- love.graphics.newShader to succeed so GbcPalette.remapShader() is
-- non-nil, which is the gate `present` checks before it will bake+remap.
local sentShader = { calls = {} }
function sentShader:send(name, ...) self.calls[#self.calls + 1] = name end
love.graphics.newShader = function() return sentShader end
-- The stub's stand-in Quad has no setViewport/getViewport, which blitRow
-- (the scanline blit present() drives 144 times a frame) needs; the real
-- love.graphics.Quad has both.
love.graphics.newQuad = function(x, y, w, h)
local q = { x = x, y = y, w = w, h = h }
function q:setViewport(x2, y2, w2, h2) self.x, self.y, self.w, self.h = x2, y2, w2, h2 end
function q:getViewport() return self.x, self.y, self.w, self.h end
return q
end
local T = require("tests.harness")
local GbcPalette = require("src.render.GbcPalette")
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
local shaderDuringFill = "unset"
local realRectangle = love.graphics.rectangle
love.graphics.rectangle = function(...)
if shaderDuringFill == "unset" then
shaderDuringFill = love.graphics.getShader()
end
return realRectangle(...)
end
local view = BattleAnimView.new({}, nil)
-- data/moves/animations.asm:4509 BattleAnim_ShadowBall's anim_bgp $1b, with
-- no scroll and no rBGP window queued, is exactly the frame that used to
-- fall through the old `needsCanvas`-only early-out untouched.
local runner = {
bg = {
bgp = 0x1b,
lcdc = nil,
scx = 0,
scy = 0,
lyStart = 0,
lyEnd = 0,
lyBackup = {},
},
}
T.check(love.graphics.getShader() == nil, "no shader bound before present")
view:present(runner, function() end, nil)
T.check(shaderDuringFill == sentShader,
"the panel backdrop is drawn through the remap shader, not plainly")
T.check(love.graphics.getShader() == nil,
"the shader is unbound again once present returns, so drawObjects is unaffected")
local sawRemapSend = false
for _, name in ipairs(sentShader.calls) do
if name == "remapSrc" then sawRemapSend = true end
end
T.check(sawRemapSend, "GbcPalette.useRemap actually sent a remap table, not just bound the shader")
-- The identity byte must take the plain path: no bake, no shader, ever.
shaderDuringFill = "unset"
local identityRunner = {
bg = { bgp = GbcPalette.BGP_IDENTITY, lcdc = nil, scx = 0, scy = 0,
lyStart = 0, lyEnd = 0, lyBackup = {} },
}
local plainDrawCalled = false
view:present(identityRunner, function() plainDrawCalled = true end, nil)
T.check(plainDrawCalled, "identity rBGP takes the plain drawBg() path")
T.check(shaderDuringFill == "unset",
"identity rBGP never touches the remap shader")
T.finish("gen2 shadow ball bgp view bug 1269")
@@ -0,0 +1,90 @@
-- #1228: `givepoke` with the 3-argument (untrained) form must run
-- GiveANickname_YesNo, the same as a wild catch does.
-- engine/pokemon/move_mon.asm:1632-1645, 1753-1757, 1787
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Vm = require("src.script.gen2.Vm")
local Events = require("src.world.gen2.Events")
-- Elm's `givepoke CYNDAQUIL, 5, BERRY` shape: no trainer operand, so
-- Vm.lua's arg4 fallback reads it as 0 (untrained).
local scripts = {
generation = 2,
["s:give"] = {
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 },
{ op = "end" },
},
}
local mon = { species = 155, level = 5 }
local renamed
local vm = Vm.new(scripts, {}, Events.new(), {
givePoke = function() return mon end,
yesorno = function(onChoose) onChoose(true) end,
showText = function(_, onDone) onDone() end,
specials = {
monName = function(species) return species == 155 and "CYNDAQUIL" or "?" end,
renameMon = function(m, done, opts)
renamed = { mon = m, blank = opts and opts.blank }
done("SPARKY")
end,
},
})
T.check(vm:start("s:give"), "starter script starts")
for _ = 1, 10 do vm:update() end
T.check(not vm:running(), "script finished")
T.eq(mon.nickname, "SPARKY",
"givepoke with trainer=0 runs the nickname prompt and stores the answer")
T.check(renamed ~= nil and renamed.mon == mon,
"renameMon opened on the mon givepoke just handed over")
T.check(renamed.blank == true,
"the keyboard opens blank, same as InitNickname on a fresh catch")
-- The trade-gift form (trainer ~= 0, e.g. GiftSpearowName's 8-byte
-- Route35GoldenrodGate.asm:30 shape) must never open the keyboard.
local mon2 = { species = 155, level = 5 }
local renamed2 = false
local scripts2 = {
generation = 2,
["s:give2"] = {
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 1 },
{ op = "end" },
},
}
local vm2 = Vm.new(scripts2, {}, Events.new(), {
givePoke = function() return mon2 end,
yesorno = function() error("a trainer-labelled gift must never prompt") end,
showText = function(_, onDone) onDone() end,
specials = { renameMon = function() renamed2 = true end },
})
T.check(vm2:start("s:give2"), "trainer-gift script starts")
for _ = 1, 10 do vm2:update() end
T.check(mon2.nickname == nil, "trainer arm leaves the nickname untouched")
T.check(not renamed2, "and never opens the keyboard")
-- A NO answer, and an all-spaces keyboard entry, both leave the species
-- name standing (_InitString's blank test, home/string.asm:6-30).
local mon3 = { species = 155, level = 5 }
local scripts3 = {
generation = 2,
["s:give3"] = {
{ op = "givepoke", species = 155, level = 5, item = 0, trainer = 0 },
{ op = "end" },
},
}
local vm3 = Vm.new(scripts3, {}, Events.new(), {
givePoke = function() return mon3 end,
yesorno = function(onChoose) onChoose(false) end,
showText = function(_, onDone) onDone() end,
specials = {
renameMon = function() error("NO must not open the keyboard") end,
},
})
T.check(vm3:start("s:give3"), "NO-answer script starts")
for _ = 1, 10 do vm3:update() end
T.check(mon3.nickname == nil, "answering NO leaves the nickname unset")
T.finish("gen2_starter_nickname_bug1228")
@@ -0,0 +1,147 @@
-- engine/battle_anims/anim_commands.asm:905-960 GetSubstitutePic
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local Assets = require("src.render.Assets")
local BattleState = require("src.ui.gen2.BattleState")
local MONSTER = { getDimensions = function() return 16, 96 end }
Assets.image = function(path)
if path == "assets/generated/sprites/monster.png" then return MONSTER end
return nil
end
local drawn
love.graphics.draw = function(image, a, b, c)
if type(a) == "table" then
drawn[#drawn + 1] = { image = image, quad = a, x = b, y = c }
else
drawn[#drawn + 1] = { image = image, x = a, y = b }
end
end
do
local runner = { env = { battleTurn = 0 }, picOverride = {} }
AnimRunner.COMMANDS.raisesub(runner)
T.eq(runner.picOverride.player, "substitute",
"anim_raisesub puts the doll up on the acting side")
AnimRunner.COMMANDS.dropsub(runner)
T.eq(runner.picOverride.player, false,
"anim_dropsub writes false, not nil")
T.check(runner.picOverride.player ~= nil,
"so LowerSub reads as an override and not as `no animation running`")
end
do
local runner = { env = { battleTurn = 1 }, picOverride = {} }
AnimRunner.COMMANDS.raisesub(runner)
T.eq(runner.picOverride.enemy, "substitute", "and the enemy side too")
T.eq(runner.picOverride.player, nil, "without touching the other one")
end
local function newAnim(overrides)
return {
bg = { hidden = {}, picSize = {}, slide = {}, monShade = {} },
picOverride = overrides or {},
}
end
local function newScreen(anim)
local image = { getDimensions = function() return 56, 56 end }
local back = { getDimensions = function() return 48, 48 end }
return setmetatable({
picHidden = {}, anim = anim,
pic = function(_, _, isBack) return isBack and back or image end,
picScale = function() return 1 end,
faintSink = function() return 0 end,
}, { __index = BattleState }), image, back
end
do
local screen = newScreen(newAnim({ enemy = "substitute" }))
T.eq(screen:animPicState("enemy").pic, "substitute",
"animPicState surfaces the runner's override")
end
do
local screen, image = newScreen(newAnim({ enemy = "substitute" }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = {} }, false)
T.eq(#drawn, 1, "one blit")
T.eq(drawn[1].image, MONSTER, "the doll, not the frontpic")
T.check(drawn[1].image ~= image, "the mon's own pic is not drawn")
T.eq(drawn[1].x, 112, "enemy doll x, GetSubstitutePic's cols 2-3")
T.eq(drawn[1].y, 40, "enemy doll y, rows 5-6 of the 7-tall box")
local quad = drawn[1].quad or {}
T.eq(quad.x, 0, "cut from the facing-DOWN frame")
T.eq(quad.y, 0, "at the top of monster.png")
T.eq(quad.w, 16, "16 wide")
T.eq(quad.h, 16, "16 tall")
end
do
local screen = newScreen(newAnim({ player = "substitute" }))
drawn = {}
screen:drawPic({ species = "TYPHLOSION", volatile = {} }, true)
T.eq(#drawn, 1, "one blit")
T.eq(drawn[1].x, 32, "player doll x, cols 2-3 of the 6-wide box")
T.eq(drawn[1].y, 80, "player doll y, rows 4-5 of the 6-tall box")
T.eq((drawn[1].quad or {}).y, 16, "cut from the facing-UP frame")
end
do
local anim = newAnim({ enemy = "substitute" })
anim.bg.slide.enemy = 8
local screen = newScreen(anim)
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = {} }, false)
T.eq(drawn[1].x, 120, "REMOVE_MON / ENTER_MON still slide the doll")
end
do
local screen, image = newScreen(nil)
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, MONSTER, "with no animation running the volatile wins")
T.check(drawn[1].image ~= image, "and the frontpic stays down")
end
do
local screen, image = newScreen(nil)
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = {} }, false)
T.eq(drawn[1].image, image, "no substitute, no doll")
T.eq(drawn[1].x, 96, "the frontpic sits in its own box")
T.eq(drawn[1].y, 0, "at hlcoord 12, 0")
end
do
local screen, image = newScreen(newAnim({ enemy = false }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, image,
"a dropsub override beats the still-standing substitute volatile")
T.eq(drawn[1].x, 96, "and the mon is drawn in its own box")
end
do
local screen = newScreen(newAnim({ enemy = nil }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, MONSTER,
"an animation that never touched the pic leaves the doll up")
end
do
local screen, image = newScreen(newAnim({ enemy = "transform" }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, image,
"and any other override is not the doll either")
end
T.finish("gen2 substitute doll bug 1271")
@@ -0,0 +1,215 @@
-- data/types/type_matchups.asm:112-116, engine/battle/effect_commands.asm:1305-1313
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Game2 = require("src.core.Game2")
local Damage = require("src.battle.gen2.Damage")
local TYPES = {
"NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BIRD", "BUG",
"GHOST", "STEEL", "CURSE_TYPE", "FIRE", "WATER", "GRASS", "ELECTRIC",
"PSYCHIC_TYPE", "ICE", "DRAGON", "DARK",
}
local DEFAULT_ROWS = [[
NORMAL ROCK 5
NORMAL STEEL 5
FIRE FIRE 5
FIRE WATER 5
FIRE GRASS 20
FIRE ICE 20
FIRE BUG 20
FIRE ROCK 5
FIRE DRAGON 5
FIRE STEEL 20
WATER FIRE 20
WATER WATER 5
WATER GRASS 5
WATER GROUND 20
WATER ROCK 20
WATER DRAGON 5
ELECTRIC WATER 20
ELECTRIC ELECTRIC 5
ELECTRIC GRASS 5
ELECTRIC GROUND 0
ELECTRIC FLYING 20
ELECTRIC DRAGON 5
GRASS FIRE 5
GRASS WATER 20
GRASS GRASS 5
GRASS POISON 5
GRASS GROUND 20
GRASS FLYING 5
GRASS BUG 5
GRASS ROCK 20
GRASS DRAGON 5
GRASS STEEL 5
ICE WATER 5
ICE GRASS 20
ICE ICE 5
ICE GROUND 20
ICE FLYING 20
ICE DRAGON 20
ICE STEEL 5
ICE FIRE 5
FIGHTING NORMAL 20
FIGHTING ICE 20
FIGHTING POISON 5
FIGHTING FLYING 5
FIGHTING PSYCHIC_TYPE 5
FIGHTING BUG 5
FIGHTING ROCK 20
FIGHTING DARK 20
FIGHTING STEEL 20
POISON GRASS 20
POISON POISON 5
POISON GROUND 5
POISON ROCK 5
POISON GHOST 5
POISON STEEL 0
GROUND FIRE 20
GROUND ELECTRIC 20
GROUND GRASS 5
GROUND POISON 20
GROUND FLYING 0
GROUND BUG 5
GROUND ROCK 20
GROUND STEEL 20
FLYING ELECTRIC 5
FLYING GRASS 20
FLYING FIGHTING 20
FLYING BUG 20
FLYING ROCK 5
FLYING STEEL 5
PSYCHIC_TYPE FIGHTING 20
PSYCHIC_TYPE POISON 20
PSYCHIC_TYPE PSYCHIC_TYPE 5
PSYCHIC_TYPE DARK 0
PSYCHIC_TYPE STEEL 5
BUG FIRE 5
BUG GRASS 20
BUG FIGHTING 5
BUG POISON 5
BUG FLYING 5
BUG PSYCHIC_TYPE 20
BUG GHOST 5
BUG DARK 20
BUG STEEL 5
ROCK FIRE 20
ROCK ICE 20
ROCK FIGHTING 5
ROCK GROUND 5
ROCK FLYING 20
ROCK BUG 20
ROCK STEEL 5
GHOST NORMAL 0
GHOST PSYCHIC_TYPE 20
GHOST DARK 5
GHOST STEEL 5
GHOST GHOST 20
DRAGON DRAGON 20
DRAGON STEEL 5
DARK FIGHTING 5
DARK PSYCHIC_TYPE 20
DARK GHOST 20
DARK DARK 5
DARK STEEL 5
STEEL FIRE 5
STEEL WATER 5
STEEL ELECTRIC 5
STEEL ICE 20
STEEL ROCK 20
STEEL STEEL 5
]]
local FORESIGHT_ROWS = [[
NORMAL GHOST 0
FIGHTING GHOST 0
]]
local function parse(text)
local rows = {}
for attacker, defender, multiplier in
text:gmatch("(%u[%u_]*)%s+(%u[%u_]*)%s+(%d+)") do
rows[#rows + 1] = { attacker = attacker, defender = defender,
multiplier = tonumber(multiplier) }
end
return rows
end
local function serialize(rows)
local parts = {}
for _, row in ipairs(rows) do
parts[#parts + 1] = ("{attacker=%q,defender=%q,multiplier=%d}")
:format(row.attacker, row.defender, row.multiplier)
end
return "{" .. table.concat(parts, ",") .. "}"
end
local DEFAULT = parse(DEFAULT_ROWS)
local FORESIGHT = parse(FORESIGHT_ROWS)
T.eq(#DEFAULT, 108, "the fixture carries the cart's 108 default rows")
T.eq(#FORESIGHT, 2, "and the two rows the `db -2` marker precedes")
love.filesystem.write("data/generated/type_chart.lua",
("return { generation = 2, matchups = %s, foresightMatchups = %s }")
:format(serialize(DEFAULT), serialize(FORESIGHT)))
local function bootChart()
local game = setmetatable({ data = {} }, Game2)
game.applyOptions = function() end
pcall(Game2.load, game)
return game.data.type_chart
end
local function resolve(matchups)
local out = {}
for _, attacker in ipairs(TYPES) do
for _, defender in ipairs(TYPES) do
out[attacker .. ">" .. defender] =
Damage.typeMultiplier(attacker, { defender }, matchups)
end
end
return out
end
local chart = bootChart()
T.check(chart and chart.matchups, "Gold's chart reaches self.data.type_chart")
local before = resolve(DEFAULT)
local after = resolve(chart.matchups)
T.eq(before["NORMAL>GHOST"], 10,
"unmerged, a NORMAL move is neutral against a GHOST")
T.eq(before["FIGHTING>GHOST"], 10,
"unmerged, a FIGHTING move is neutral against a GHOST")
T.eq(after["NORMAL>GHOST"], 0, "merged, NORMAL does nothing to GHOST")
T.eq(after["FIGHTING>GHOST"], 0, "merged, FIGHTING does nothing to GHOST")
T.eq(after["GHOST>NORMAL"], 0, "and GHOST still does nothing to NORMAL")
T.eq(#chart.matchups, #DEFAULT + #FORESIGHT,
"the merge appends exactly the foresight rows")
T.eq(#(chart.foresightMatchups or {}), #FORESIGHT,
"and leaves foresightMatchups in place for Fingerprint")
local moved = {}
for pair, value in pairs(after) do
if before[pair] ~= value then moved[#moved + 1] = pair end
end
table.sort(moved)
T.eq(#moved, 2, "exactly two of the 19x19 pairs move")
T.eq(moved[1], "FIGHTING>GHOST", "the first is FIGHTING against GHOST")
T.eq(moved[2], "NORMAL>GHOST", "the second is NORMAL against GHOST")
local twice = bootChart()
T.eq(#twice.matchups, #DEFAULT + #FORESIGHT,
"a second load appends the rows once, not twice")
T.eq(Damage.typeMultiplier("NORMAL", { "GHOST", "POISON" }, twice.matchups), 0,
"a dual GHOST/POISON target is still immune to NORMAL")
T.finish("gen2 type chart foresight merge bug 1268")
+81
View File
@@ -0,0 +1,81 @@
-- HostShell.httpPost must work with Lua/LuaJIT's one-way io.popen.
--
-- io.popen accepts "r" or "w", not "rw". POST needs both a request body
-- and a response status, so the body is staged in a temporary file and curl
-- is opened read-only for its response.
-- luajit tests/engine/host_shell_postlog.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local HostShell = require("src.core.HostShell")
local MARK = "\n__gen1recomp_http__"
local STAGE_DIR = "/tmp/gen1recomp-postlog-stage"
local URL = "https://logs.example.com/logs"
local BODY = "debug log body\n"
local realOpen = io.open
local realPopen = io.popen
local realGetenv = os.getenv
local realRemove = os.remove
local realHaveCurl = HostShell.haveCurl
local openedPath, openedMode, writtenBody
local popenCommand, popenMode, removedPath
HostShell.haveCurl = function() return true end
os.getenv = function(name)
if name == "TEMP" or name == "TMP" or name == "TMPDIR" then
return STAGE_DIR
end
return realGetenv(name)
end
os.remove = function(path)
removedPath = path
return true
end
io.open = function(path, mode)
openedPath, openedMode = path, mode
return {
write = function(_, value)
writtenBody = value
return true
end,
close = function() return true end,
}
end
io.popen = function(command, mode)
popenCommand, popenMode = command, mode
return {
read = function() return MARK .. "200" end,
close = function() return true end,
}
end
local ok, err = HostShell.httpPost(URL, BODY, "text/plain", "gen1recomp-mod/test", 10)
io.open = realOpen
io.popen = realPopen
os.getenv = realGetenv
os.remove = realRemove
HostShell.haveCurl = realHaveCurl
eq(ok, true, "a desktop POST succeeds through the read-only response pipe: " .. tostring(err))
check(type(openedPath) == "string" and openedPath:find(STAGE_DIR .. "/gen1recomp-post-", 1, true) == 1, "the request body is staged under the OS temp dir")
check(openedPath and openedPath:sub(-4) == ".tmp", "the staged body carries a .tmp name")
eq(openedMode, "wb", "the temporary request body is opened for binary writing")
eq(writtenBody, BODY, "the complete log body is staged")
eq(popenMode, "r", "curl is opened in the supported read-only mode")
check(popenCommand:find("--data-binary", 1, true) ~= nil,
"curl reads the staged body with --data-binary")
check(openedPath and popenCommand:find(openedPath, 1, true) ~= nil,
"curl receives the temporary body path")
check(popenCommand:find(BODY, 1, true) == nil,
"the log body is not placed directly in the command line")
eq(removedPath, openedPath, "the staged request body is removed")
T.finish("host shell postlog")
+32
View File
@@ -0,0 +1,32 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local osName, fused, command = "Windows", true, nil
love = {
system = { getOS = function() return osName end },
filesystem = {
getExecutablePath = function() return "C:\\Game\\gen1recomp.exe" end,
getSource = function() return "C:\\Source\\gen1recomp" end,
isFused = function() return fused end,
},
}
package.loaded["src.core.Platform"] = { canSpawnProcess = function() return true end }
local execute = os.execute
os.execute = function(value) command = value return 0 end
local HostShell = require("src.core.HostShell")
assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
assert(command:find('start "" /b ', 1, true)
and command:find('"C:\\Game\\gen1recomp.exe"', 1, true),
"Windows launches the fused app detached")
osName, fused = "Linux", false
assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
assert(command:find("'C:\\Source\\gen1recomp'", 1, true)
and command:sub(-1) == "&", "Linux source runs include the game folder")
osName, fused = "OS X", true
assert(HostShell.spawnSelfDetached({ "--display-companion=50000,token" }))
assert(not command:find("start", 1, true) and command:sub(-1) == "&",
"macOS uses the same detached POSIX path")
os.execute = execute
print("spawn self detached: ok")
+94
View File
@@ -0,0 +1,94 @@
-- PKMN LEAGUE (the post-E4 Hall of Fame viewer) never had a screen backing
-- it, so a PC row wired up to open it would have had nowhere to go (#1282).
-- src/ui/LeaguePC.lua is the missing viewer; it resolves through the
-- registry's builtin fallback with no id table edit needed, because every
-- unregistered id falls through to `require("src.ui." .. id)`.
-- engine/menus/league_pc.asm:1, constants/pokemon_data_constants.asm:65 (cap 50)
-- luajit tests/engine/league_pc_bug1282.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 Screens = require("src.ui.Screens")
local LeaguePC = require("src.ui.LeaguePC")
local function team(species, level)
return { { species = species, level = level, nickname = nil } }
end
local function newGame(teamCount)
local teams = {}
for i = 1, teamCount do teams[i] = team("RATTATA", i) end
local popped = false
local game = {
data = { pokemon = {}, text = {} },
save = { hallOfFame = teams },
stack = { pop = function() popped = true end },
}
return game, function() return popped end
end
-- the registry needs no LeaguePC id entry: an unregistered id falls
-- through resolve()'s builtin path straight to src/ui/LeaguePC.lua
local factory = Screens.get({ data = {} }, "LeaguePC")
check(factory == LeaguePC, "Screens.get(\"LeaguePC\") resolves to this module")
check(type(factory.new) == "function", "the resolved factory is constructible")
-- 60 recorded teams: HOF_TEAM_CAPACITY (50) means the oldest 10 are gone,
-- so the viewer opens on the OLDEST STILL-RECORDED team, index 11
do
local game = newGame(60)
local pc = LeaguePC.new(game)
eq(pc.teamIndex, 11, "60 teams over a cap of 50 starts at team 11 (60-50+1)")
eq(pc.monIndex, 1, "starts on the first mon of that team")
check(pc:currentMon() ~= nil, "a current mon is resolved")
end
-- A steps through every remaining team (49 more presses, 11 -> 60), then
-- one more A on the last team's only mon closes the whole viewer
do
local game, wasPopped = newGame(60)
local pc = LeaguePC.new(game)
local doneCalled = false
pc.onDone = function() doneCalled = true end
for _ = 1, 49 do
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
end
eq(pc.teamIndex, 60, "49 A-presses walk from team 11 to team 60")
check(not wasPopped(), "the viewer is still open on the last team")
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
check(wasPopped(), "one more A on the last team's last mon closes the viewer")
check(doneCalled, "onDone fires on close")
end
-- B always closes immediately, from any position
do
local game, wasPopped = newGame(3)
local pc = LeaguePC.new(game)
game.input = { wasPressed = function(_, b) return b == "b" end }
pc:update(0)
check(wasPopped(), "B closes the viewer")
end
-- an empty Hall of Fame (no wins recorded yet, or the extreme edge case of
-- a save with the row reachable but no completed run) must not crash: A on
-- a nil current mon closes cleanly instead of indexing into nothing
do
local game, wasPopped = newGame(0)
local pc = LeaguePC.new(game)
eq(pc.teamIndex, 1, "an empty roster clamps teamIndex to 1, not 0 or negative")
check(pc:currentMon() == nil, "there is no current mon")
local ok = pcall(function()
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
end)
check(ok, "A on an empty Hall of Fame does not raise")
check(wasPopped(), "...and closes the viewer instead")
end
T.finish("league pc bug 1282")
+81
View File
@@ -0,0 +1,81 @@
-- The Pewter museum ticket clerk's money box (#1335): the closure re-reads
-- the balance, so the thank-you box shows 50 less than the ask did, and
-- the decline boxes keep the box up too (scripts/Museum1F.asm:71-112).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local M = assert(loadfile("data/scripts/story2.lua"))()
local clerk = M.MUSEUM_1F.talk.TEXT_MUSEUM1F_SCIENTIST1
local pushed
local function mkGame(cash)
pushed = {}
return {
data = { text = {} },
save = { money = cash, flags = {} },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
-- YES with enough money: the prompt carries the money closure and reads
-- the pre-payment balance; the thank-you box re-reads the reduced one
local g = mkGame(3000)
local doneFired = false
clerk(g, nil, nil, function() doneFired = true end)
local ask = pushed[1]
T.check(ask.opts ~= nil and ask.opts.money ~= nil and ask.opts.choice ~= nil,
"the ask box carries both a money closure and the YES/NO choice")
T.eq(ask.opts.money(), 3000, "the ask box reads 3000 before paying")
ask.opts.choice(true)
local thanks = pushed[2]
T.check(tostring(thanks.text):find("Thank you", 1, true) ~= nil,
"YES opens the thank-you box")
T.check(thanks.opts ~= nil and thanks.opts.money ~= nil,
"the thank-you box carries a money closure")
T.eq(thanks.opts.money(), 2950,
"the thank-you box re-reads the balance, 50 less than the ask")
T.check(g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET, "the ticket flag is set")
thanks.onDone()
T.check(doneFired, "the done callback still chains through")
-- YES but short on cash: the decline box also carries the money opt now
-- (Part 1: scripts/Museum1F.asm keeps MONEY_BOX up on the whole exchange)
g = mkGame(20)
clerk(g, nil, nil, function() end)
pushed[1].opts.choice(true)
local broke = pushed[2]
T.check(tostring(broke.text):find("enough money", 1, true) ~= nil,
"short on cash opens the not-enough-money box")
T.check(broke.opts ~= nil and broke.opts.money ~= nil,
"the not-enough-money box carries the money opt")
T.eq(broke.opts.money(), 20, "and its closure reads the untouched balance")
T.eq(g.save.money, 20, "money is not spent on the broke path")
-- NO: the come-again box keeps the money opt too
g = mkGame(3000)
clerk(g, nil, nil, function() end)
pushed[1].opts.choice(false)
local decline = pushed[2]
T.check(tostring(decline.text):find("Come again", 1, true) ~= nil,
"declining opens the come-again box")
T.check(decline.opts ~= nil and decline.opts.money ~= nil,
"the come-again box carries the money opt")
T.eq(decline.opts.money(), 3000, "and its closure reads the unspent balance")
-- already ticketed: take-your-time only, no money box (a ticket holder
-- is already inside, past the rope)
g = mkGame(3000)
g.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = true
clerk(g, nil, nil, function() end)
T.check(tostring(pushed[1].text):find("Take your time", 1, true) ~= nil,
"an existing ticket holder gets the take-your-time line")
T.check(pushed[1].opts == nil, "and no money box on that branch")
T.finish("museum_money_box_bug1335")
@@ -0,0 +1,86 @@
-- The NEW NAME / preset box drew on top of a full letter grid because
-- NamingScreen stayed isOpaque while the preset Menu was up, so the stack's
-- visibleBase never fell through to the screen underneath (#1329).
-- engine/movie/oak_speech/oak_speech2.asm:1
-- luajit tests/engine/naming_screen_opacity_bug1329.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")
package.loaded["src.core.Sound"] = { play = function() end }
local StateStack = require("src.core.StateStack")
local NamingScreen = require("src.ui.NamingScreen")
local function newGame()
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local game = { data = {} }
game.stack = stack
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game, stack
end
local game, stack = newGame()
-- stand-in for OakSpeech: opaque, and the state a fixed background lives on
local backgroundDraws = 0
local background = { isOpaque = true, draw = function() backgroundDraws = backgroundDraws + 1 end }
stack:push(background)
local result = { name = nil }
local ns = NamingScreen.new(game,
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result.name = n end })
stack:push(ns) -- StateStack:push calls ns:enter(), which pushes the preset Menu
eq(ns.choosing, true, "the screen marks itself as choosing a preset")
eq(ns.isOpaque, false, "isOpaque is shadowed false while the preset box is up")
local menu = stack:top()
check(menu ~= nil and menu ~= ns, "the preset Menu is on top of the naming screen")
eq(#menu.items, 4, "NEW NAME plus the three presets")
eq(menu.items[1].label, "NEW NAME", "row 1 is NEW NAME")
eq(stack:visibleBase(), 1, "the background (index 1) is visible, not the naming screen")
backgroundDraws = 0
stack:draw()
eq(backgroundDraws, 1, "the background actually got a draw call this frame")
-- picking NEW NAME (row 1) restores the grid's normal opacity
menu.index = 1
menu.game.input.queue.a = true
menu:update(0)
menu.game.input.queue.a = false
check(stack:top() == ns, "the Menu popped itself, the naming screen is back on top")
eq(ns.choosing, nil, "choosing cleared")
eq(rawget(ns, "isOpaque"), nil, "the instance field is cleared, unshadowing the class default")
eq(ns.isOpaque, true, "isOpaque now reads true again, through the class default")
eq(stack:visibleBase(), 2, "the naming screen itself is now the opaque base")
-- a preset pick instead closes the whole naming flow with that name
local game2, stack2 = newGame()
local background2 = { isOpaque = true, draw = function() end }
stack2:push(background2)
local result2 = { name = nil }
local ns2 = NamingScreen.new(game2,
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result2.name = n end })
stack2:push(ns2)
local menu2 = stack2:top()
eq(menu2.items[4].label, "JACK", "row 4 is the third preset")
menu2.index = 4 -- "JACK"
menu2.game.input.queue.a = true
menu2:update(0)
eq(result2.name, "JACK", "selecting a preset pops the whole flow with that name")
eq(#stack2.states, 1, "only the background remains on the stack")
T.finish("naming screen opacity bug 1329")
+96
View File
@@ -0,0 +1,96 @@
-- The walking-NPC animation cadence, which the port ran at half the cart's
-- rate (#1303). UpdateSpriteInWalkingAnimation advances one animation frame
-- every 4 fixed steps regardless of how long the whole cell takes
-- (engine/overworld/movement.asm:301), so a 32-frame NPC cell must show the
-- same two-pulse cadence Player:pose already shows across its own 16.
-- luajit tests/engine/npc_walk_cadence_bug1303.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local NPC = require("src.world.NPC")
local DATA = {
sprites = {
SPRITE_TEST_NPC = { image = "fixture_npc.png", frames = 6, walker = true },
},
}
local function newNpc()
return NPC.new(DATA, "TEST_MAP", {
index = 1, x = 5, y = 5, sprite = "SPRITE_TEST_NPC", range = "ANY_DIR",
movement = "STAY",
})
end
-- ---- the pure cadence: two walk pulses across one 32-frame NPC step ------
do
local npc = newNpc()
npc.moving = true
local phases = {}
for clock = 0, 31 do
npc.animClock = clock
phases[clock] = npc:walkPhase()
end
local risingEdges = 0
for clock = 1, 31 do
if phases[clock] == 1 and phases[clock - 1] == 0 then
risingEdges = risingEdges + 1
end
end
eq(risingEdges, 2, "a 32-frame NPC cell shows two walk pulses, not one")
eq(phases[0], 0, "frame 0 stands")
eq(phases[4], 1, "frame 4 opens the first walk pulse")
eq(phases[11], 1, "frame 11 is still the first pulse")
eq(phases[12], 0, "frame 12 closes it back to standing")
eq(phases[20], 1, "frame 20 opens the second walk pulse")
eq(phases[27], 1, "frame 27 is still the second pulse")
eq(phases[28], 0, "frame 28 closes the cycle back to standing")
end
-- ---- the flip half-cycle: one flip per 16-frame half, not per whole cell -
do
local npc = newNpc()
npc.moving = true
npc.animClock = 0
local _, _, _, _, _, flip0 = npc:pose()
npc.animClock = 15
local _, _, _, _, _, flip15 = npc:pose()
npc.animClock = 16
local _, _, _, _, _, flip16 = npc:pose()
npc.animClock = 31
local _, _, _, _, _, flip31 = npc:pose()
check(not flip0, "the first half of the cell is unflipped")
check(not flip15, "still unflipped just before frame 16")
check(flip16, "frame 16 flips, matching Player:pose's own half-cycle")
check(flip31, "and stays flipped through the second half")
end
-- ---- standing keeps the externally-written flip (Pikachu idle contract) --
do
local npc = newNpc()
npc.moving = false
npc.stepFlip = true
local _, _, _, _, phase, flip = npc:pose()
eq(phase, 0, "a standing NPC has no walk phase")
check(flip, "and pose() reads stepFlip back exactly, not the moving formula")
end
-- ---- the wiring: NPC:update advances animClock alongside progress -------
do
local npc = newNpc()
npc.facing = "down"
npc.moving = true
npc.targetX, npc.targetY = npc.cellX, npc.cellY + 1
local map = {}
for i = 1, 16 do
npc:update(map, {})
eq(npc.animClock, i, "animClock ticks once per update, step " .. i)
end
check(npc.moving, "still mid-cell at 16 of the 32 ticks")
end
T.finish("npc walk cadence bug1303")
+1 -1
View File
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow")
T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows")
T.eq(nurse.frameOverride, 1, "image index $14: the nurse bows")
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
@@ -0,0 +1,83 @@
-- Oak's lab poster/e-mail hidden events (#1331, #1333): the extractor's
-- hidden_event whitelist drops both, so the posters and PC e-mail were
-- dead A presses until the flavor script's onInteract hook.
-- engine/events/hidden_events/oaks_lab_posters.asm:1
-- engine/events/hidden_events/oaks_lab_email.asm:1
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
package.loaded["src.render.TextBox"] = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local M = assert(loadfile("data/scripts/flavor/oaks_lab.lua"))()
local hook = M.OAKS_LAB.onInteract
T.check(type(hook) == "function", "OAKS_LAB.onInteract exists")
local pushed
local function mkGame(owned)
pushed = {}
return {
data = { text = {
_PushStartText = "PUSH",
_SaveOptionText = "SAVE",
_StrengthsAndWeaknessesText = "TYPES",
_OakLabEmailText = "EMAIL",
} },
save = { pokedex = { owned = owned } },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
end
local owUp = { player = { facing = "up" } }
local owDown = { player = { facing = "down" } }
-- left poster: unconditional, any facing (#1331)
local g = mkGame({})
T.eq(hook(g, owDown, 4, 0), true, "left poster consumes the press")
T.eq(#pushed, 1, "left poster pushes one box")
T.eq(pushed[1].text, "PUSH", "left poster prints PushStartText")
-- right poster, fewer than 2 species owned: SaveOptionText (#1331)
g = mkGame({ [1] = true })
T.eq(hook(g, owDown, 5, 0), true, "right poster consumes the press (<2 owned)")
T.eq(pushed[1].text, "SAVE", "right poster prints SaveOptionText below 2 owned")
-- right poster, 2+ species owned: StrengthsAndWeaknessesText (#1331)
g = mkGame({ [1] = true, [4] = true })
T.eq(hook(g, owDown, 5, 0), true, "right poster consumes the press (2+ owned)")
T.eq(pushed[1].text, "TYPES",
"right poster prints StrengthsAndWeaknessesText at 2+ owned")
-- right poster, exactly at the boundary (still <2, one species owned twice
-- under different keys does not double-count)
g = mkGame({ [1] = true })
T.eq(hook(g, owDown, 5, 0), true, "right poster boundary case consumes")
T.eq(pushed[1].text, "SAVE", "one owned species stays on the SAVE line")
-- e-mail tiles, facing up: OakLabEmailText (#1333)
g = mkGame({})
T.eq(hook(g, owUp, 0, 1), true, "e-mail tile x=0 consumes facing up")
T.eq(pushed[1].text, "EMAIL", "e-mail tile x=0 prints OakLabEmailText")
g = mkGame({})
T.eq(hook(g, owUp, 1, 1), true, "e-mail tile x=1 consumes facing up")
T.eq(pushed[1].text, "EMAIL", "e-mail tile x=1 prints OakLabEmailText")
-- e-mail tiles, any other facing: explicit FALSE so the press falls
-- through to tryBookshelf, and nothing is pushed (#1333)
g = mkGame({})
T.eq(hook(g, owDown, 0, 1), false, "e-mail tile x=0 returns false facing down")
T.eq(#pushed, 0, "wrong facing prints nothing")
g = mkGame({})
T.eq(hook(g, { player = { facing = "left" } }, 1, 1), false,
"e-mail tile x=1 returns false facing left")
T.eq(#pushed, 0, "wrong facing prints nothing")
-- unrelated cell: falls through, nothing printed
g = mkGame({})
T.eq(hook(g, owUp, 3, 3), false, "an unrelated cell returns false")
T.eq(#pushed, 0, "an unrelated cell prints nothing")
T.finish("oaks_lab_posters_email_bug1331_1333")
@@ -0,0 +1,69 @@
-- #1279: the rival must face DOWN at his own table cell before the taunt,
-- same as SetSpriteFacingDirectionAndDelay does before DisplayTextID -- not
-- just the player turning to face him.
-- scripts/OaksLab.asm:347-351 (Red/Blue); pokeyellow scripts/OaksLab.asm:311-315
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local function fakeOw(rival)
return {
npcByIndex = function(_, i) return i == 1 and rival or nil end,
map = {
inBounds = function() return true end,
isWalkableCell = function() return true end,
},
runner = { run = function(_, rows, opts) return rows, opts end },
}
end
-- capture the rows the runner was handed, since `run` is only a stub
local function captureRun(ow)
local captured
ow.runner.run = function(_, rows, opts) captured = rows return true end
return function() return captured end
end
local function baseGame()
return {
save = {
flags = {
EVENT_GOT_STARTER = true,
EVENT_BATTLED_RIVAL_IN_OAKS_LAB = false,
EVENT_CHOSE_BULBASAUR = true,
},
},
}
end
-- Red/Blue
do
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
local rival = { id = "rival" }
local ow = fakeOw(rival)
local getRows = captureRun(ow)
local ok = M.onStep(baseGame(), ow, 4, 6)
T.check(ok == true, "onStep claims the rival-challenge step")
local rows = getRows()
T.check(rows ~= nil, "the challenge rows reached the runner")
T.same(rows[1], { "face_object", 1, "down" },
"the FIRST row faces the rival down at his table (#1279)")
T.eq(rows[2][1], "face_player_dir", "the player-facing row still follows it")
T.eq(rows[2][2], "up", "and still turns the player up, unchanged")
end
-- Yellow
do
local M = assert(loadfile("data/scripts/oaks_lab_yellow.lua"))()
local rival = { id = "rival" }
local ow = fakeOw(rival)
local getRows = captureRun(ow)
local ok = M.onStep(baseGame(), ow, 4, 6)
T.check(ok == true, "yellow onStep claims the rival-challenge step")
local rows = getRows()
T.check(rows ~= nil, "the yellow challenge rows reached the runner")
T.same(rows[1], { "face_object", 1, "down" },
"yellow's first row faces the rival (object 1) down too (#1279)")
end
T.finish("oaks_lab_rival_faces_down_bug1279")
@@ -0,0 +1,68 @@
-- "This POKéMON is really energetic!" prints before the received-mon box
-- (#1334). starterBall's numeric jump targets moved down one row for the
-- new line, so this asserts the targets by ROW CONTENT, not by index, and
-- would catch a stale target the next time a row is inserted above them.
-- scripts/OaksLab.asm:919
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
local rows = M.talk.TEXT_OAKSLAB_CHARMANDER_POKE_BALL
T.check(type(rows) == "table", "starterBall rows loaded")
-- the energetic line lands between the ask and the sound/received pair
local askRow
for i, row in ipairs(rows) do
if row[1] == "ask" then askRow = i end
end
T.check(askRow ~= nil, "found the ask row")
T.eq(rows[askRow + 1][1], "jump_if_false", "the ask is followed by its jump")
T.same(rows[askRow + 2], { "show_text", "_OaksLabMonEnergeticText" },
"the energetic line is the row right after the NO jump")
T.eq(rows[askRow + 3][1], "text_sound",
"the sound cue stays on the RECEIVED line, not the energetic one")
T.eq(rows[askRow + 4][1], "show_text",
"and the received-mon text follows the sound cue")
T.eq(rows[askRow + 4][2], "_OaksLabReceivedMonText",
"specifically the received-mon text")
-- jump_if_true (row 2): the EVENT_GOT_STARTER short-circuit must land on
-- the leftover-ball beat, "face_object 5 down" -- asserted by content
local gotStarterJump
for i, row in ipairs(rows) do
if row[1] == "check_flag" and row[2] == "EVENT_GOT_STARTER" then
gotStarterJump = rows[i + 1]
break
end
end
T.check(gotStarterJump ~= nil and gotStarterJump[1] == "jump_if_true",
"found the EVENT_GOT_STARTER jump_if_true")
local target1 = gotStarterJump[2]
T.check(type(target1) == "number", "the target is a numeric row index")
T.same(rows[target1], { "face_object", 5, "down" },
"jump_if_true lands on the leftover-ball face_object row")
-- jump_if_false (row 4): the EVENT_FOLLOWED_OAK_INTO_LAB gate must land on
-- the pre-pick line, "show_text _OaksLabThoseArePokeBallsText"
local followedOakJump
for i, row in ipairs(rows) do
if row[1] == "check_flag" and row[2] == "EVENT_FOLLOWED_OAK_INTO_LAB" then
followedOakJump = rows[i + 1]
break
end
end
T.check(followedOakJump ~= nil and followedOakJump[1] == "jump_if_false",
"found the EVENT_FOLLOWED_OAK_INTO_LAB jump_if_false")
local target2 = followedOakJump[2]
T.check(type(target2) == "number", "the target is a numeric row index")
T.same(rows[target2], { "show_text", "_OaksLabThoseArePokeBallsText" },
"jump_if_false lands on the pre-pick ThoseArePokeBalls row")
-- the leftover-ball beat this jump lands on must never fall through into
-- the pre-pick line beneath it (#601 remnant); confirm a terminating jump
-- sits between them
T.eq(rows[target1 + 2][1], "jump",
"the leftover-ball beat ends on its own jump before the pre-pick row")
T.finish("oaks_lab_starter_energetic_bug1334")
+15 -7
View File
@@ -76,6 +76,8 @@ for _, name in ipairs({ "openOaksPC", "dexRating" }) do
("Game upvalue on %s"):format(name))
end
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub),
"TextBox upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
@@ -111,8 +113,14 @@ end
reset()
local done = false
fakeSelf:openPC(function() done = true end)
-- engine/menus/pc.asm:5
local pcOn = lastPush()
T.eq(pcOn.kind, "text", "openPC opens with the turned-on text")
T.check(tostring(pcOn.text):find("turned on", 1, true) ~= nil,
"the opening box is TurnedOnPC1Text")
pcOn.onDone()
local menu = lastPush()
T.eq(menu.kind, "menu", "openPC pushes the PC menu")
T.eq(menu.kind, "menu", "the PC menu follows the box")
local oak
for _, item in ipairs(menu.items) do
if item.label == "PROF.OAK's PC" then oak = item end
@@ -121,16 +129,16 @@ T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
oak.onSelect()
-- drop the menu's Turn_On_PC and the row's Enter_PC
plays = {}
T.eq(pushed[2].kind, "text", "selection opens the access text")
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
T.eq(pushed[3].kind, "text", "selection opens the access text")
T.check(tostring(pushed[3].text):find("Accessed", 1, true) ~= nil,
"first session box is the access text")
runChain()
T.check(done, "session completes")
T.check(pushed[3].opts ~= nil and pushed[3].opts.choice ~= nil,
T.check(pushed[4].opts ~= nil and pushed[4].opts.choice ~= nil,
"the rated question carries the YES/NO choice")
T.check(tostring(pushed[3].text):find("rated", 1, true) ~= nil,
T.check(tostring(pushed[4].text):find("rated", 1, true) ~= nil,
"second session box asks for the rating")
local ratingBox = pushed[4]
local ratingBox = pushed[5]
T.check(ratingBox.opts and ratingBox.opts.auto ~= nil
and ratingBox.opts.auto.wait ~= nil,
"rating box sounds the jingle then waits for a button")
@@ -142,7 +150,7 @@ T.eq(#plays, 0, "no jingle while the evaluation is printing")
ratingBox.opts.auto.sound()
T.eq(#plays, 1, "jingle fires once the rating text is printed")
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
T.check(tostring(pushed[5].text):find("Closed link", 1, true) ~= nil,
T.check(tostring(pushed[6].text):find("Closed link", 1, true) ~= nil,
"the closing link prints at the end of the session")
-- === declining the rating skips the evaluation but still closes the link
@@ -0,0 +1,73 @@
-- #1391: the Pewter museum guide's lockstep walk, exercised from all four
-- trigger cells around him, the way PewterGuys (engine/events/
-- pewter_guys.asm:1-49) builds the preamble and PewterCitySuperNerd1Shows
-- PlayerMuseumScript (scripts/PewterCity.asm:47-113) walks it out.
--
-- `museumEscort` had zero consumers and zero test coverage before this: a
-- future edit to the RLE tables or the preamble map could silently break
-- the walk and nothing would fail.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local M = assert(loadfile("data/scripts/flavor/pewter_city.lua"))()
local esc = M.PEWTER_CITY.museumEscort
T.check(type(esc) == "table", "museumEscort is exported")
T.check(type(esc.plan) == "function", "museumEscort.plan is exported")
T.check(type(esc.guySteps) == "table", "museumEscort.guySteps is exported")
-- RLEList_PewterMuseumGuy (engine/overworld/auto_movement.asm:199-204):
-- UP 6, LEFT 13, UP 3, LEFT 1 -- 23 steps by content, not just by count.
local wantGuySteps = {}
for _ = 1, 6 do wantGuySteps[#wantGuySteps + 1] = "up" end
for _ = 1, 13 do wantGuySteps[#wantGuySteps + 1] = "left" end
for _ = 1, 3 do wantGuySteps[#wantGuySteps + 1] = "up" end
wantGuySteps[#wantGuySteps + 1] = "left"
T.same(esc.guySteps, wantGuySteps,
"guySteps is exactly UP x6, LEFT x13, UP x3, LEFT x1")
local function apply(x, y, dirs)
for _, d in ipairs(dirs) do
if d == "up" then y = y - 1
elseif d == "down" then y = y + 1
elseif d == "left" then x = x - 1
elseif d == "right" then x = x + 1 end
end
return x, y
end
-- PewterMuseumGuyCoords (engine/events/pewter_guys.asm:58-75): the four
-- cells adjacent to the guy's spawn (27,17), each with its own preamble.
local triggers = { { 27, 18 }, { 27, 16 }, { 26, 17 }, { 28, 17 } }
for _, c in ipairs(triggers) do
local plan = esc.plan(c[1], c[2])
T.check(plan ~= nil,
("(%d,%d) is a real trigger cell and must produce a plan"):format(c[1], c[2]))
T.eq(#plan.steps, 23,
("(%d,%d): 23-step walk, same length regardless of approach side")
:format(c[1], c[2]))
T.eq(plan.guyHeadStart, 0,
("(%d,%d): no NO_INPUT head padding in the museum preamble")
:format(c[1], c[2]))
local px, py = apply(c[1], c[2], plan.steps)
T.check(px == 14 and py == 8,
("(%d,%d): player's walk ends at (14,8), by the museum door")
:format(c[1], c[2]))
local guySub = {}
for i = plan.guyHeadStart + 1, plan.guyHeadStart + #plan.steps do
guySub[#guySub + 1] = esc.guySteps[i]
end
local gx, gy = apply(27, 17, guySub)
T.check(gx == 13 and gy == 8,
("(%d,%d): guy's walk ends at (13,8), beside the player")
:format(c[1], c[2]))
end
-- Any cell that is not one of the four adjacent trigger cells must not
-- start the escort at all.
T.check(esc.plan(10, 10) == nil, "a non-adjacent cell returns no plan")
T.check(esc.plan(27, 17) == nil, "the guy's own cell is not a trigger either")
T.finish("pewter_museum_escort_bug1391")
@@ -0,0 +1,95 @@
-- The Pokedex entry page laid out its fields at the port's own invented
-- coordinates instead of the cart's, ran the whole description together on
-- one page with no trailing full stop, and never let A/B advance past page
-- one (#1341).
-- engine/menus/pokedex.asm:399, home/text.asm:245 (<PAGE>), :204 (<DEXEND>)
-- luajit tests/engine/pokedex_entry_layout_bug1341.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")
-- stub Font: DexEntryMenu draws through Font.draw/Font.drawCode only, and
-- the real Font needs loaded page images this suite has no reason to touch.
local calls = {}
package.loaded["src.render.Font"] = {
draw = function(text, x, y) calls[#calls + 1] = { text = text, x = x, y = y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { code = code, x = x, y = y } end,
}
local DexEntryMenu = require("src.ui.DexEntryMenu")
local function hasText(text, x, y)
for _, c in ipairs(calls) do
if c.text == text and c.x == x and c.y == y then return true end
end
return false
end
local function hasCode(code, x, y)
for _, c in ipairs(calls) do
if c.code == code and c.x == x and c.y == y then return true end
end
return false
end
local game = {
data = {
pokemon = {
BULBASAUR = {
id = "BULBASAUR",
name = "BULBASAUR",
dex = 1,
dexEntry = {
kind = "SEED POKEMON",
heightFt = 2, heightIn = 4, weight = 69,
text = "_BulbasaurDexEntry",
},
},
},
text = {
-- \f is the extractor's <PAGE> break; two pages, three lines each
_BulbasaurDexEntry = "A strange seed was\nplanted on its\nback at birth\f"
.. "The plant sprouts\nand grows with\nthis POKEMON",
},
constants = { dexDigits = 3 },
},
save = { pokedex = { owned = { BULBASAUR = true } } },
}
local ns = DexEntryMenu.new(game, "BULBASAUR")
eq(ns.pageCount, 2, "the entry has two <PAGE>-separated pages")
eq(ns.page, 1, "starts on page 1")
ns:draw()
check(hasText("BULBASAUR", 72, 16), "name at (72,16), not the port's old (72,8)")
check(hasText("SEED POKEMON", 72, 32), "kind at (72,32)")
check(hasText("No.001", 16, 64), "dex number under the pic, at (16,64)")
check(hasText("HT 2\226\128\178" .. "04\226\128\179", 72, 48), "HT at (72,48)")
check(hasText("WT 6.9lb", 72, 64), "WT at (72,64)")
check(hasText("A strange seed was", 8, 88), "page 1 line 1 at y=88 (row 11)")
check(hasText("planted on its", 8, 104), "page 1 line 2 at y=104")
check(hasText("back at birth", 8, 120), "page 1 line 3, unmodified: not the last page")
check(hasCode(0xEE, 144, 128), "the more-below arrow shows on a non-final page")
-- A on a non-final page turns it, it does not close the screen
local popped, doneCalled = false, false
game.stack = { pop = function() popped = true end }
game.input = { wasPressed = function(_, b) return b == "a" end }
ns.onDone = function() doneCalled = true end
ns:update(0)
eq(ns.page, 2, "A advances to page 2 instead of closing")
check(not popped, "the screen did not pop on a non-final page")
calls = {}
ns:draw()
check(hasText("this POKEMON.", 8, 120), "the final page's last line gets the trailing full stop")
check(not hasCode(0xEE, 144, 128), "no more-below arrow on the last page")
-- A on the final page closes the screen
ns:update(0)
check(popped, "A on the final page pops the screen")
check(doneCalled, "onDone fires once the last page closes")
T.finish("pokedex entry layout bug 1341")
+101
View File
@@ -0,0 +1,101 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local savedHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local Viewport = require("src.render.GameViewport")
local SafeArea = require("src.core.SafeArea")
local TouchControls = require("src.core.TouchControls")
Viewport.begin(1)
assert(not Viewport.active(), "vanilla frame must not allocate a viewport")
local w, h = Viewport.dimensions()
assert(w == 640 and h == 576, "vanilla dimensions must stay unchanged")
hooks:wrap("render.viewport", function(next, ctx)
local full = next(ctx)
assert(full.width == 640 and full.height == 576,
"viewport hook receives OS-independent window geometry")
return { x = 320, y = 12, width = 320, height = 288 }
end, 0, "fixture")
local presented
hooks:wrap("render.window", function(next, game, ctx)
presented = ctx
return next(game, ctx)
end, 0, "fixture")
Viewport.begin(2)
assert(Viewport.active(), "a reserved rectangle creates a game target")
w, h = Viewport.dimensions()
assert(w == 320 and h == 288, "game renders against reserved dimensions")
Viewport.target().getPixelDimensions = function() return 737, 664 end
local pw, ph = Viewport.pixelDimensions()
assert(pw == 737 and ph == 664,
"captured rendering uses the target's real high-DPI pixel dimensions")
local x, y, inside = Viewport.toLocal(400, 100)
assert(x == 80 and y == 88 and inside,
"window pointers expose viewport-local coordinates")
local _, _, outside = Viewport.toLocal(20, 20)
assert(not outside, "reserved companion space is outside the game viewport")
local _, _, localW, localH = SafeArea.rect()
local _, _, windowW, windowH = SafeArea.windowRect()
assert(localW == 320 and localH == 288,
"game chrome may still use viewport-local safe geometry")
assert(windowW == 640 and windowH == 576,
"OS chrome can retain the full-window safe geometry")
TouchControls:init()
local controls = TouchControls:layout()
assert(controls.dpad.cx < 160 and controls.a.cx > 480,
"touch controls stay laid out across the full OS window")
local function source(path)
local file = assert(io.open(path, "r"))
local text = file:read("*a")
file:close()
return text
end
for _, path in ipairs({ "src/core/Game.lua", "src/core/Game2.lua" }) do
local text = source(path)
local finish = assert(text:find("GameViewport.finish(self)", 1, true))
local controlsDraw = assert(text:find("TouchControls:draw()", finish, true))
assert(controlsDraw > finish,
path .. " draws touch controls after final window composition")
end
Viewport.setTarget()
assert(love.graphics.getCanvas() == Viewport.target(),
"game rendering is redirected into the viewport canvas")
Viewport.finish({})
assert(presented and presented.x == 320 and presented.y == 12
and presented.width == 320 and presented.height == 288
and presented.windowWidth == 640 and presented.windowHeight == 576
and presented.generation == 2,
"window composition receives game and host geometry")
assert(love.graphics.getCanvas() == nil,
"window composition restores the OS render target")
Viewport.reset()
assert(not Viewport.active(),
"viewport geometry cannot leak into the launcher after presentation")
hooks.chains["render.viewport"] = nil
hooks:wrap("render.viewport", function(next, ctx)
local full = next(ctx)
full.capture = true
return full
end, 0, "capture-fixture")
Viewport.begin(1)
assert(Viewport.active() and Viewport.dimensions() == 640,
"a full-window capture allocates a final composition target")
presented = nil
Viewport.setTarget()
Viewport.finish({})
assert(presented and presented.width == 640 and presented.height == 576,
"a full-window capture reaches final window composition")
Viewport.reset()
Runtime.hooks = savedHooks
print("render viewport: ok")
+174
View File
@@ -0,0 +1,174 @@
-- HandlePoisonBurnLeechSeed runs once per side per round; a mod that
-- reads battle.ruleset.residualAfterMove twice (executeAction:3487 and
-- endOfTurn:2657) could otherwise fire both arms. #1181's b.residualDone
-- latch (residualFor ~2612, endOfTurn ~2670, cleared ~2685) closes that.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle(opts)
opts = opts or {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_A", 28) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
if opts.playerFaster then
battle.player.curStats.speed = 200
battle.enemy.curStats.speed = 1
else
battle.enemy.curStats.speed = 200
battle.player.curStats.speed = 1
end
return battle
end
local function drain(battle)
local rows = {}
for _ = 1, 800 do
local item = table.remove(battle.queue, 1)
if not item then return rows end
if item.text then rows[#rows + 1] = { text = item.text } end
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the turn queue never drained")
end
local function countTicks(rows)
local player, enemy = 0, 0
for _, r in ipairs(rows) do
if r.text and r.text:find("hurt by poison", 1, true) then
if r.text:find("Enemy", 1, true) then enemy = enemy + 1
else player = player + 1 end
end
end
return player, enemy
end
local function assertCleared(b, label)
T.check(b.player.residualDone == nil, label .. ": player residualDone cleared")
T.check(b.enemy.residualDone == nil, label .. ": enemy residualDone cleared")
end
do
local b = newBattle({ playerFaster = true })
b.player.mon.status = "PSN"
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "faster attacker: poison ticks exactly once")
T.eq(et, 0, "faster attacker: no enemy tick")
assertCleared(b, "faster attacker")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "slower attacker: poison ticks exactly once")
T.eq(et, 0, "slower attacker: no enemy tick")
assertCleared(b, "slower attacker")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b:itemUsed({})
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "item round: poison ticks exactly once")
T.eq(et, 0, "item round: no enemy tick")
assertCleared(b, "item round")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.catchAttempt = function() return false, 0 end
local ballId
for id in pairs(Data.items or {}) do
if id:find("BALL") then ballId = id break end
end
if not ballId then
Data.items = Data.items or {}
Data.items.POKE_BALL = { name = "POKE BALL" }
ballId = "POKE_BALL"
end
b:throwBall(ballId)
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "ball round: poison ticks exactly once")
T.eq(et, 0, "ball round: no enemy tick")
assertCleared(b, "ball round")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.runRoll = function() return false end
b:tryRun()
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "failed run: poison ticks exactly once")
T.eq(et, 0, "failed run: no enemy tick")
assertCleared(b, "failed run")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b:resolveSwitch(b.game.save.party[2])
local pt, et = countTicks(drain(b))
T.eq(pt, 0, "switch round: a fresh battler ticks zero")
T.eq(et, 0, "switch round: still no enemy tick")
assertCleared(b, "switch round")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.enemy.mon.status = "PSN"
local php0, ehp0 = b.player.mon.hp, b.enemy.mon.hp
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local rows = drain(b)
local pt, et = countTicks(rows)
T.eq(pt, 1, "both sides poisoned: player side ticks once")
T.eq(et, 1, "both sides poisoned: enemy side ticks once too")
T.check(b.player.mon.hp < php0, "both sides poisoned: player actually lost HP")
T.check(b.enemy.mon.hp < ehp0, "both sides poisoned: enemy actually lost HP")
assertCleared(b, "both sides poisoned")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.player.mon.hp = 999
b.player.mon.stats.hp = 999
local lastLoss
for round = 1, 5 do
local hp0 = b.player.mon.hp
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local pt, et = countTicks(drain(b))
T.eq(pt, 1, ("round %d of 5: poison ticks exactly once"):format(round))
T.eq(et, 0, ("round %d of 5: no enemy tick"):format(round))
assertCleared(b, ("round %d of 5"):format(round))
local loss = hp0 - b.player.mon.hp
T.check(loss > 0, ("round %d of 5: HP actually dropped"):format(round))
if lastLoss then
T.eq(loss, lastLoss, ("round %d of 5: same tick size as the round before"):format(round))
end
lastLoss = loss
end
end
T.finish("residual latch (#1181)")
@@ -0,0 +1,68 @@
-- The wall TOWN MAP prints TownMapText before the map screen opens (#1330).
-- engine/events/hidden_events/town_map.asm:1
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local screenPushes = {}
package.loaded["src.ui.Screens"] = {
push = function(_, id, opts)
screenPushes[#screenPushes + 1] = { id = id, opts = opts }
end,
}
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local fakeGame = {
data = { text = { _TownMapText = "A TOWN MAP." } },
stack = { push = function(_, box) pushed = box end },
}
T.check(setUpvalue(OW.tryBookshelf, "TextBox", textBoxStub),
"TextBox upvalue on tryBookshelf")
T.check(setUpvalue(OW.tryBookshelf, "Game", fakeGame),
"Game upvalue on tryBookshelf")
local map = {
def = { tileset = "HOUSE" },
inBounds = function() return true end,
cellTile = function() return 0x3D end,
}
local fakeSelf = setmetatable({ player = { facing = "up" }, map = map },
{ __index = OW })
pushed, screenPushes = nil, {}
local result = fakeSelf:tryBookshelf(5, 2)
T.eq(result, true, "tryBookshelf consumes the wall TOWN MAP tile")
T.check(pushed ~= nil, "a text box was pushed")
T.eq(pushed.text, "A TOWN MAP.", "the box carries TownMapText")
T.eq(#screenPushes, 0, "the TownMap screen has not opened yet")
T.check(type(pushed.onDone) == "function", "the box has an onDone")
pushed.onDone()
T.eq(#screenPushes, 1, "onDone is what opens the screen")
T.eq(screenPushes[1].id, "TownMap", "the pushed screen id is TownMap")
-- a screen id a mod removed must not crash the interaction
pushed, screenPushes = nil, {}
package.loaded["src.ui.Screens"].push = function() error("no such screen") end
fakeSelf:tryBookshelf(5, 2)
local ok = pcall(pushed.onDone)
T.check(ok, "Screens.push stays wrapped in pcall")
T.finish("town_map_bookshelf_bug1330")
@@ -0,0 +1,86 @@
-- The TOWN MAP drew a blinking black square for the player instead of their
-- walk sprite, because the marker was a placeholder rectangle instead of the
-- OAM sprite the cart draws (#1344).
-- engine/items/town_map.asm:347
-- luajit tests/engine/town_map_player_sprite_bug1344.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 TownMap = require("src.ui.TownMap")
local function newGame()
return {
data = {
field = {
townMap = { PALLET_TOWN = { x = 1, y = 2, name = "PALLET TOWN" } },
playerSprites = { walk = "SPRITE_RED" },
-- no townMap.background: forces the stale-asset fallback draw path,
-- which is the one #1344's repro screenshot came from
},
sprites = { SPRITE_RED = { image = "assets/generated/sprites/red_walk.png" } },
maps = {},
},
save = {},
overworld = { map = { id = "PALLET_TOWN" } },
}
end
local game = newGame()
local tm = TownMap.new(game, {})
eq(tm.mode, "grid", "one located entry puts the screen in grid mode")
check(tm.bg == nil, "no background.map means the stale-asset fallback draws")
check(tm.playerLoc ~= nil, "the player's map resolved to a location")
check(tm.playerSheet ~= nil,
"TownMap.new resolved the walk sheet (the fix this test guards)")
-- capture what :draw() actually paints, without a real screen
local draws, rects = {}, {}
local realDraw, realRect = love.graphics.draw, love.graphics.rectangle
love.graphics.draw = function(img, quadOrX, x, y)
draws[#draws + 1] = { img = img, quad = quadOrX, x = x, y = y }
end
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
end
tm.blink = 0 -- (0 < 20): the player marker is in its "on" blink phase
tm:draw()
love.graphics.draw = realDraw
love.graphics.rectangle = realRect
local function drewSprite()
for _, d in ipairs(draws) do
if d.img == tm.playerSheet and d.quad == tm.playerQuad then
return d
end
end
return nil
end
local spriteDraw = drewSprite()
check(spriteDraw ~= nil, "the player's walk sprite was drawn, not a placeholder")
if spriteDraw then
-- engine/items/town_map.asm:449 WriteTownMapSpriteOAM's -4,-3 carry quirk
eq(spriteDraw.x, tm.playerLoc.x * 8 - 4, "sprite x is markerXY - 4")
eq(spriteDraw.y, tm.playerLoc.y * 8 - 3, "sprite y is markerXY - 3")
end
local function blackDotAtPlayer()
for _, r in ipairs(rects) do
if r.mode == "fill" and r.w == 4 and r.h == 4
and r.x == tm.playerLoc.x * 8 + 2 and r.y == tm.playerLoc.y * 8 + 2 then
return true
end
end
return false
end
check(not blackDotAtPlayer(),
"the old 4x4 placeholder square is not drawn once a sprite is available")
T.finish("town map player sprite bug 1344")
+24
View File
@@ -257,6 +257,30 @@ do
eq(set[173], true, "the hidden item's flag is set by number")
end
-- engine/events/poisonstep.asm .PlayPoisonSFX: SFX_POISON then
-- LoadPoisonBGPals, four frames (#1362)
do
local World = require("src.world.gen2.World")
local ctx = { poisonBGFlash = World.poisonBGFlash,
playSfxNamed = function() end }
eq(CallAsm.run(ctx, "PlayPoisonSFX"), nil, "PlayPoisonSFX writes no wScriptVar")
eq(ctx.poisonFlash, 4, "and sets the flash to exactly four frames")
local hurt = { game = { save = { party = { { hp = 5, status = "psn" } },
poisonStepCount = 3 } },
poisonBGFlash = World.poisonBGFlash, playSfxNamed = function() end,
stepContext = function() return { linkMode = false } end }
World.countStep(hurt)
eq(hurt.poisonFlash, 4, "the hurt arm reaches it too")
local faint = { poisonBGFlash = World.poisonBGFlash, playSfxNamed = function() end }
World.poisonFaintScript(faint, { fainted = {}, whiteout = false })
eq(faint.poisonFlash, 4, "and so does the faint arm")
local ok = pcall(CallAsm.run, {}, "PlayPoisonSFX")
check(ok, "a ctx with no poisonBGFlash does not error")
end
-- engine/events/poisonstep.asm .CheckWhitedOut ends on
-- CheckPlayerPartyForFitMon, whose answer is 1 when something can still fight;
-- the `iffalse .whiteout` after it is reading "no fit mon".
+112
View File
@@ -270,6 +270,60 @@ do
"MAX_LEVEL refuses (cp MAX_LEVEL / jp nc, NoEffectMessage)")
end
-- ------------------------------------------------- ItemEffects: partyAction
do
for _, itemId in ipairs({ "HP_UP", "PROTEIN", "IRON", "CARBOS", "CALCIUM",
"PP_UP" }) do
check(ItemEffects.partyAction(itemId, DATA) ~= nil,
itemId .. " resolves a party action (#1249)")
end
eq(ItemEffects.partyAction("ZINC", DATA), nil,
"ZINC does not exist in Gen 1 or Gen 2")
end
-- ------------------------------------------------- ItemEffects: VITAMIN
do
local mon = fixtureMon(12)
local before = { hp = mon.hp, maxHp = mon.maxHp }
local result = ItemEffects.useOnMon("HP_UP", mon, DATA)
eq(result.used, true, "an HP UP under the ceiling is spent")
eq(mon.statExp.hp, 2560, "StatExpItemPointerOffsets' +10 high-byte add")
check(mon.maxHp > before.maxHp, "UpdateStatsAfterItem recomputes max HP")
eq(mon.stats.hp, mon.maxHp, "the recomputed hp stat lands on stats too")
eq(mon.hp, before.hp, "current HP is UNCHANGED by HP UP")
eq(result.text, "CYNDAQUIL's\nHEALTH rose.", "with the cart's stat line")
local ceiling = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
local refused = ItemEffects.useOnMon("HP_UP", ceiling, DATA)
eq(refused.used, false, "25600 stat exp refuses (cp 100 / jr nc)")
eq(ceiling.statExp.hp, 25600, "and leaves the word untouched")
eq(refused.text, ItemEffects.TEXT_NO_EFFECT, "with NoEffectMessage")
local edge = fixtureMon(12, { statExp = {
hp = 25599, attack = 0, defense = 0, speed = 0, special = 0 } })
local accepted = ItemEffects.useOnMon("HP_UP", edge, DATA)
eq(accepted.used, true, "25599 is still under the ceiling")
eq(edge.statExp.hp, 28159, "landing on 25599 + 2560")
local labels = {
HP_UP = { stat = "hp", label = "HEALTH" },
PROTEIN = { stat = "attack", label = "ATTACK" },
IRON = { stat = "defense", label = "DEFENSE" },
CARBOS = { stat = "speed", label = "SPEED" },
CALCIUM = { stat = "special", label = "SPECIAL" },
}
for itemId, row in pairs(labels) do
local target = fixtureMon(12)
local outcome = ItemEffects.useOnMon(itemId, target, DATA)
eq(target.statExp[row.stat], 2560, itemId .. " raises its own stat exp")
eq(outcome.text, ("CYNDAQUIL's\n%s rose."):format(row.label),
itemId .. " prints " .. row.label)
end
end
-- ------------------------------------------------- ItemEffects: PP family
do
@@ -305,6 +359,41 @@ do
"a party mon with every slot full refuses the ELIXER family")
end
-- ------------------------------------------------- ItemEffects: PP UP
do
local mon = fixtureMon(12)
for use = 1, 3 do
local result = ItemEffects.usePpItem("PP_UP", mon, 1, DATA)
eq(result.used, true, "PP UP use " .. use .. " of 3 is spent")
eq(mon.moves[1].ppUps, use, "PP_UP_ONE ticks the top two bits")
eq(mon.moves[1].maxPp, 35 + use * 7,
"min(floor(basePP / 5), 7) added per use")
eq(mon.moves[1].pp, 30 + use * 7, "current PP rises by the same amount")
end
local capped = ItemEffects.usePpItem("PP_UP", mon, 1, DATA)
eq(capped.used, false, "a 4th PP UP refuses (PP_UP_MASK's 3-use cap)")
eq(mon.moves[1].maxPp, 56, "and leaves max PP where it was")
eq(capped.text, "TACKLE's PP\nis maxed out.", "with PPIsMaxedOutText")
local sketch = fixtureMon(12)
sketch.moves[1] = { id = "SKETCH", pp = 1, maxPp = 1 }
local sketchResult = ItemEffects.usePpItem("PP_UP", sketch, 1, DATA)
eq(sketchResult.used, false, "SKETCH refuses a PP UP")
eq(sketchResult.text, "SKETCH's PP\nis maxed out.",
"with the same maxed-out line")
local modMon = fixtureMon(12)
modMon.moves[1] = { id = "MOD_MOVE", pp = 5 }
local unknown = ItemEffects.usePpItem("PP_UP", modMon, 1, DATA)
eq(unknown.used, false,
"an id absent from data.moves with no maxPp refuses (base PP unknown)")
eq(modMon.moves[1].ppUps, nil, "and is not consumed")
eq(modMon.moves[1].pp, 5, "with current PP untouched")
eq(unknown.text, "MOD_MOVE's PP\nis maxed out.",
"printing the same maxed-out line rather than PP 0")
end
-- ------------------------------------------------ Game2: the .Party wiring
-- The REAL flow: usePartyItem pushes the real Gen2PartyMenu, the pick runs
-- the effect, the consumption happens, and the message rides a real TextBox
@@ -360,6 +449,29 @@ do
check(box ~= nil and box.pages ~= nil, "and prints the no-effect line")
end
do
local mon = fixtureMon(12)
local host = newHost({ HP_UP = 1 }, { mon })
host:useFieldItem("HP_UP")
local party = host.stack:top()
check(party ~= nil and party.prompt ~= nil,
"USE pushes the party list (partyAction is no longer nil)")
drive(host, function() return host.stack:top() ~= party end)
eq(mon.statExp.hp, 2560, "the vitamin ran through the real menu")
eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent")
end
do
local mon = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
local host = newHost({ HP_UP = 2 }, { mon })
host:useFieldItem("HP_UP")
local party = host.stack:top()
drive(host, function() return host.stack:top() ~= party end)
eq(host.save.inventory.HP_UP, 2, "a refused HP UP costs nothing")
eq(mon.statExp.hp, 25600, "and leaves the stat exp word untouched")
end
do
-- ETHER: party pick, then the move list, then the restore.
local mon = fixtureMon(12)
+49 -5
View File
@@ -356,11 +356,9 @@ local optionsGame, optionsInput = newGame(Save.newGame())
local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- 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)
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows) and CANCEL.
check("twenty-one rows", #OptionsMenu.ROWS, 21)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
@@ -863,6 +861,52 @@ check("GBC leaves a palette alone",
1)[1], 1)
check("and has no present pass", GbcPalette.presentColors(), nil)
local gbcfxIndex
for i, row in ipairs(OptionsMenu.ROWS) do
if row.label == "GBC FX" then gbcfxIndex = i end
end
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
"VIDEO MODE")
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
"TOUCH PAD")
local videoRow = select(2, rowNamed("VIDEO MODE"))
check("VIDEO MODE is a row", videoRow ~= nil, true)
local videoBuilt
for _, row in ipairs(scrollOptions.rows) do
if row.key == "videoMode" then videoBuilt = row end
end
check("buildRows gives it id videoMode", videoBuilt and videoBuilt.id,
"videoMode")
check("default video mode is windowed", Save.DEFAULT_OPTIONS.videoMode,
"windowed")
scrollOptions.options.videoMode = "windowed"
scrollOptions:cycle(videoRow, 1)
check("right stores borderless, not the display string",
scrollOptions.options.videoMode, "borderless")
scrollOptions:cycle(videoRow, 1)
check("right again stores windowed",
scrollOptions.options.videoMode, "windowed")
scrollOptions:cycle(videoRow, -1)
check("left also toggles, stored as borderless",
scrollOptions.options.videoMode, "borderless")
scrollOptions:cycle(videoRow, -1)
check("left toggles back to windowed",
scrollOptions.options.videoMode, "windowed")
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
scrollOptions.options.videoMode = "borderless"
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
"FULL")
for _, mode in ipairs({ "windowed", "borderless" }) do
scrollOptions.options.videoMode = mode
local text = videoRow.text(scrollOptions.options)
check("value fits the 8-char column: " .. mode, #text <= 8, true)
end
-- ------------------------------------------------------------- name picker
--
-- NameMenuHeader's coordinates, which is the whole point of this screen: the
+116
View File
@@ -0,0 +1,116 @@
-- The post-battle grace period, which the port never re-armed after an
-- UNSCRIPTED wild battle (#1229): World:tryWildEncounter's own
-- self:startBattle({ wild = wild }) carries no onDone, so no VM resume ever
-- ran the script's own `reloadmapafterbattle` (which is where the cart's
-- SetUpFiveStepWildEncounterCooldown lives, engine/overworld/events.asm:1158-
-- 1162), and the counter sat at zero for the very next step. What is
-- asserted here is the real World:startBattle -> onDone chain, same as
-- tests/gen2_canlose_test.lua exercises for the loss arm.
--
-- GOLD_CACHE=".../gold" luajit tests/gen2_wild_cooldown_bug1229_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 wild cooldown")
local check, eq = S.check, S.eq
local World = require("src.world.gen2.World")
local Mon = require("src.battle.gen2.Mon")
local Screens = require("src.ui.Screens")
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local probe = io.open(cache .. "/data/generated/pokemon.lua", "r")
if not probe then
check(true, "gold cache absent (SKIP)")
S.finish()
return
end
probe:close()
local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end
local pokemon = loadLua("data/generated/pokemon.lua")
local moves = loadLua("data/generated/moves.lua")
-- The battle screen as a registry fake, same shape gen2_canlose_test uses:
-- the real World:startBattle pushes it and parks its onDone for the test.
-- Screens.get caches by id process-wide, so a suite dofile'd earlier in
-- tests/run_tests.lua can leave a stale "Gen2BattleState" behind.
Screens.invalidate()
local battleDone
local registry = {
Gen2BattleState = { new = function(_g, opts)
battleDone = opts.onDone
return { screenId = "Gen2BattleState" }
end },
}
local function makeStack()
local stack = { items = {} }
function stack:push(inst) self.items[#self.items + 1] = inst end
function stack:pop()
local top = self.items[#self.items]
self.items[#self.items] = nil
return top
end
return stack
end
local function makeWorld()
battleDone = nil
local game = {
data = { audio = {}, screens = registry, pokemon = pokemon, moves = moves },
-- No roamer state and no map at all: World:pushBattleTransition needs
-- self.map to push a wipe and finds none, so pushBattle() runs straight
-- away, exactly like a headless battle would. World:restoreMapMusic and
-- World:battleMusicContext both already guard a nil self.map.
save = { player = { name = "GOLD", money = 3000 }, party = {} },
stack = makeStack(),
}
local mon = Mon.new(game.data, "CYNDAQUIL", 5)
check(mon ~= nil, "the cache can build the player's starter")
game.save.party[1] = mon
local w = World.new(game)
return w, game
end
-- ---- the unscripted path itself: World:tryWildEncounter's own call -------
do
local w, game = makeWorld()
local wild = Mon.new(game.data, "MAGIKARP", 10)
check(wild ~= nil, "the cache can build the wild mon")
-- A grace period already partway spent, so a re-arm is the only way to
-- land back on 5.
w.wildCooldown = 2
check(w:startBattle({ wild = wild }),
"the unscripted wild path starts a battle")
check(battleDone ~= nil, "the battle screen is up")
battleDone("win")
eq(w.wildCooldown, 5,
"reloadmapafterbattle's SetUpFiveStepWildEncounterCooldown re-arms " ..
"the counter (events.asm:1158-1162), even with no script waiting")
end
-- ---- the counter itself, once re-armed: four blocked steps then a roll --
do
local w = makeWorld()
w.wildCooldown = 5
for step = 1, 4 do
check(w:wildCooldownStep(),
"step " .. step .. " of the grace period is still blocked")
end
check(not w:wildCooldownStep(), "the fifth step may roll")
end
-- ---- a battle NOTHING started re-arms nothing: only startBattle's onDone -
do
local w = makeWorld()
w.wildCooldown = 0
check(w.wildCooldown == 0, "a fresh world never re-arms on its own")
end
S.finish()
+289
View File
@@ -0,0 +1,289 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local S = require("tests.harness").suite("mod battle snapshot")
local check, eq = S.check, S.eq
local Gen1BattleState = require("src.battle.BattleState")
check(Gen1BattleState.isBattleState == true,
"Gen 1 battle states carry the discovery marker")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load({ type_chart = {
types = { NORMAL = { name = "NORMAL", category = "physical" } },
matchups = {},
} })
local Damage = require("src.battle.Damage")
local attacker, defender = { stages = {} }, { stages = {} }
eq(Damage.accuracyThreshold({ oneIn256Miss = true }, { accuracy = 100 },
attacker, defender), 255, "faithful accuracy keeps the 1-in-256 miss")
eq(Damage.accuracyThreshold({ oneIn256Miss = false }, { accuracy = 100 },
attacker, defender), 256, "clean accuracy exposes a certain hit")
local Catching = require("src.battle.Catching")
eq(Catching.chance("MASTER_BALL", { hp = 1, stats = { hp = 1 } },
{ catchRate = 1 }), 100, "Master Ball preview is certain")
check(Catching.chance("MOD_BALL", { hp = 1, stats = { hp = 1 } },
{ catchRate = 1 }, nil, { ballDef = { attempt = function() end } }) == nil,
"custom ball logic does not receive a guessed preview")
local mon = { species = "TESTMON", level = 5, hp = 18,
stats = { hp = 20 }, moves = {} }
local game = {
data = {
pokemon = { TESTMON = { name = "TESTMON", catchRate = 255 } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL", power = 35,
accuracy = 95, pp = 35 } },
items = { POTION = { name = "POTION" },
POKE_BALL = { name = "POKE BALL" } },
},
save = { party = { mon }, inventory = { POTION = 1, POKE_BALL = 1 } },
stack = { states = {} },
}
local battle = {
isBattleState = true, phase = "menu", queue = {},
ruleset = { oneIn256Miss = true },
player = { mon = mon, curTypes = { "NORMAL" }, stages = {},
curMoves = { { id = "TACKLE", pp = 35 } } },
enemy = { mon = { species = "TESTMON", level = 4, hp = 12,
stats = { hp = 12 }, moves = {} }, curTypes = { "NORMAL" }, stages = {} },
}
function battle:battleKind() return self.kind or "wild" end
function battle:effectRecord() return { accuracyChecked = true } end
function battle:visibleText() return { "Wild TESTMON appeared!" } end
function battle:menuLockedAction() return nil end
function battle:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moveSelect" end
return true
end
function battle:chooseMove(slot)
self.chosenMove = slot
self.phase = "messages"
return true
end
function battle:cancelMove() self.phase = "menu" return true end
function battle:chooseSafari(action)
self.chosenSafari, self.phase = action, "messages"
return true
end
function battle:chooseMimic(slot)
self.chosenMimic, self.phase = slot, "messages"
return true
end
function battle:catchChance(ball)
return require("src.battle.Catching").chance(ball, self.enemy.mon,
game.data.pokemon[self.enemy.mon.species])
end
game.stack.states = { battle }
local api = require("src.battle.BattleAPI").new(game)
local snapshot = api:snapshot()
check(snapshot and snapshot.kind == "wild" and snapshot.prompt == "menu",
"Gen 1 battle is exposed")
eq(snapshot.player.maxHp, 20, "Gen 1 max HP comes from battle stats")
eq(snapshot.moves[1].name, "TACKLE", "move records are copied")
eq(snapshot.message[1], "Wild TESTMON appeared!", "battle text is copied")
eq(#snapshot.items, 2, "medicine and balls are exposed")
check(type(snapshot.items[1].catchChance) == "number"
or type(snapshot.items[2].catchChance) == "number",
"stock catch chance is available")
snapshot.player.hp = 0
snapshot.moves[1].pp = 0
eq(mon.hp, 18, "changing a snapshot cannot change a Pokemon")
eq(battle.player.curMoves[1].pp, 35,
"changing a snapshot cannot change a move")
local same = api:snapshot()
eq(same.revision, snapshot.revision, "unchanged battle keeps its revision")
battle.enemy.mon.hp = 5
check(api:snapshot().revision > same.revision,
"observable battle changes advance the revision")
game.stack.states = {}
check(api:snapshot() == nil, "Gen 1 returns nil outside a battle")
game.stack.states = { battle }
local menu = api:snapshot()
local ok, err = api:submit({ id = 1, revision = menu.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gen 1 rejects a stale intent")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gen 1 rejects an unknown menu choice")
check(api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" }), "Gen 1 accepts a menu intent")
eq(battle.chosenMenu, "fight", "Gen 1 uses the semantic menu path")
ok, err = api:submit({ id = 1, revision = menu.revision,
kind = "menu", choice = "fight" })
check(not ok and err == "replayed intent", "Gen 1 rejects a replayed intent")
local moveMenu = api:snapshot()
ok, err = api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gen 1 rejects an invalid move slot")
check(api:submit({ id = 2, revision = moveMenu.revision,
kind = "move", slot = 1 }), "Gen 1 accepts a valid move")
eq(battle.chosenMove, 1, "Gen 1 uses the semantic move path")
battle.phase = "moveSelect"
local back = api:snapshot()
check(api:submit({ id = 3, revision = back.revision, kind = "back" }),
"Gen 1 accepts move-menu back")
eq(battle.phase, "menu", "Gen 1 back restores the command menu")
battle.kind, battle.safari = "safari", { balls = 30 }
local safari = api:snapshot()
check(api:submit({ id = 4, revision = safari.revision,
kind = "safari", action = "rock" }), "Gen 1 accepts a Safari action")
eq(battle.chosenSafari, "rock", "Gen 1 uses the semantic Safari path")
battle.kind, battle.safari = "wild", nil
battle.phase, battle.mimicMoves = "mimicSelect", { { slot = 1 } }
local mimic = api:snapshot()
check(api:submit({ id = 5, revision = mimic.revision,
kind = "mimic", index = 1 }), "Gen 1 accepts a Mimic choice")
eq(battle.chosenMimic, 1, "Gen 1 uses the semantic Mimic path")
local player2 = { species = "CHIKORITA", level = 5, hp = 20,
maxHp = 21, moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } }
local enemy2 = { species = "RATTATA", level = 3, hp = 12, maxHp = 12,
moves = {} }
local battle2 = { player = player2, enemy = enemy2, party = { player2 },
wild = true, turn = 0 }
function battle2:moveDisabled() return false end
local screen2 = { screenId = "Gen2BattleState", battle = battle2,
phase = "menu", menuIndex = 1, moveIndex = 1 }
function screen2:chooseMenu(choice)
self.chosenMenu = choice
if choice == "fight" then self.phase = "moves" end
return true
end
function screen2:chooseMove(slot)
self.chosenMove = slot
self.phase = "resolving"
return true
end
function screen2:cancelMove() self.phase = "menu" return true end
local game2 = {
data = {
pokemon = { CHIKORITA = { name = "CHIKORITA" },
RATTATA = { name = "RATTATA" } },
moves = { TACKLE = { name = "TACKLE", type = "NORMAL",
power = 35, accuracy = 95, pp = 35 } },
},
save = { party = { player2 } }, stack = { states = { screen2 } },
}
local api2 = require("src.battle.gen2.BattleAPI").new(game2)
local snapshot2 = api2:snapshot()
check(snapshot2 and snapshot2.kind == "wild" and snapshot2.prompt == "menu",
"Gold battle is discovered through its screen id")
eq(snapshot2.player.maxHp, 21, "Gold max HP uses the mon field")
eq(snapshot2.moves[1].name, "TACKLE", "Gold moves are copied")
snapshot2.player.hp = 0
snapshot2.moves[1].pp = 0
eq(player2.hp, 20, "changing a snapshot cannot change a Gold Pokemon")
eq(player2.moves[1].pp, 35,
"changing a snapshot cannot change a Gold move")
screen2.message = "A wild RATTATA appeared!"
screen2.phase = "resolving"
local message2 = api2:snapshot()
eq(message2.prompt, "advance", "Gold message state is exposed")
check(message2.revision > snapshot2.revision,
"Gold battle changes advance the revision")
game2.stack.states = {}
check(api2:snapshot() == nil, "Gold returns nil outside a battle")
game2.stack.states = { screen2 }
screen2.message = nil
screen2.phase = "menu"
local menu2 = api2:snapshot()
ok, err = api2:submit({ id = 1, revision = menu2.revision - 1,
kind = "menu", choice = "fight" })
check(not ok and err == "stale battle context",
"Gold rejects a stale intent")
ok, err = api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "missing" })
check(not ok and err == "unknown battle menu choice",
"Gold rejects an unknown menu choice")
check(api2:submit({ id = 1, revision = menu2.revision,
kind = "menu", choice = "fight" }), "Gold accepts a menu intent")
eq(screen2.chosenMenu, "fight", "Gold uses the semantic menu path")
local moveMenu2 = api2:snapshot()
ok, err = api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 9 })
check(not ok and err == "invalid move slot",
"Gold rejects an invalid move slot")
check(api2:submit({ id = 2, revision = moveMenu2.revision,
kind = "move", slot = 1 }), "Gold accepts a valid move")
eq(screen2.chosenMove, 1, "Gold uses the semantic move path")
screen2.phase = "moves"
local back2 = api2:snapshot()
check(api2:submit({ id = 3, revision = back2.revision, kind = "back" }),
"Gold accepts move-menu back")
eq(screen2.phase, "menu", "Gold back restores the command menu")
do
local Data = require("tests.modkit").fixtures.fresh()
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local pressed = {}
local game3 = { data = Data, save = save, input = {
wasPressed = function(_, key) return pressed[key] == true end,
isDown = function() return false end,
}, stack = { states = {} } }
function game3.stack:top() return self.states[#self.states] end
function game3.stack:push(state) self.states[#self.states + 1] = state end
local real = Gen1BattleState.newWild(game3, "FIXMON_B", 12)
real.phase, real.queue, real.introSlide = "menu", {}, nil
game3.stack.states = { real }
pressed.a = true
real:update(1 / 60)
pressed.a = nil
eq(real.phase, "moveSelect", "native Gen 1 FIGHT uses the semantic path")
pressed.b = true
real:update(1 / 60)
pressed.b = nil
eq(real.phase, "menu", "native Gen 1 move-menu back still works")
end
do
local state = setmetatable({ phase = "menu", safari = { balls = 30 },
menuIndex = 1 }, { __index = Gen1BattleState })
function state:safariAction(action) self.safariChoice = action end
local ok, err = state:chooseSafari("missing")
check(not ok and err == "invalid safari action",
"native Safari rejects an unknown action")
check(state:chooseSafari("rock"), "native Safari choice is accepted")
eq(state.menuIndex, 3, "native Safari cursor follows the semantic choice")
eq(state.safariChoice, "rock", "native Safari action uses the shared path")
state.phase = "mimicSelect"
state.mimicMoves = { { slot = 4 } }
state.mimicCtx = { user = {}, target = {}, moveInst = {} }
function state:applyMimic(_, _, _, slot) self.mimicSlot = slot end
ok, err = state:chooseMimic(2)
check(not ok and err == "invalid mimic slot",
"native Mimic rejects an unknown choice")
check(state:chooseMimic(1), "native Mimic choice is accepted")
eq(state.phase, "messages", "native Mimic choice resumes battle messages")
eq(state.mimicSlot, 4, "native Mimic choice copies the selected move slot")
end
local Loader = require("src.mods.Loader")
local fs = { read = function() end, getInfo = function() end,
getDirectoryItems = function() return {} end }
local mod = { path = "mods/snapshot_test", manifest = {
id = "snapshot_test", version = "1.0.0", permissionSet = {},
} }
local loader1 = Loader.new({ fs = fs, generation = 1 })
loader1.game = game
check(loader1:_api(mod).battle:snapshot().kind == "wild",
"mod.battle selects the Gen 1 facade")
local loader2 = Loader.new({ fs = fs, generation = 2 })
loader2.game = game2
check(loader2:_api(mod).battle:snapshot().kind == "wild",
"mod.battle selects the Gen 2 facade")
S.finish()
+21
View File
@@ -1106,6 +1106,27 @@ check(loader.modOptions.okmod.hardcore == false
and loader.modOptions.okmod.startMoney == 3000
and loader.modOptions.okmod.tag == "BLUE",
"RESET DEFAULTS restores every schema default")
-- optional conditions keep mode-specific rows compact and refresh in place
local conditionalSchema = {
{ key = "mode", label = "MODE", type = "choice", default = "one",
choices = { { "ONE", "one" }, { "TWO", "two" } } },
{ key = "oneOnly", label = "ONE ONLY", type = "toggle", default = false,
visible_if = { key = "mode", equals = "one" } },
{ key = "twoOnly", label = "TWO ONLY", type = "toggle", default = false,
visible_if = { key = "mode", equals = "two" } },
{ key = "notOne", label = "NOT ONE", type = "toggle", default = false,
visible_if = { key = "mode", not_equals = "one" } },
}
loader.modOptions.condmod = {}
ms.cursor = 1
ms.optionRows = ms:buildOptionRows({ id = "condmod" }, conditionalSchema)
check(#ms.optionRows == 3 and ms.optionRows[2].id == "oneOnly",
"visible_if uses the controlling row default")
ms.optionRows[1].step(mgame, 1)
check(#ms.optionRows == 4 and ms.optionRows[2].id == "twoOnly"
and ms.optionRows[3].id == "notOne" and ms.cursor == 1,
"editing a controller refreshes conditions without moving the cursor")
press(ms, "b")
check(ms.screen == "list", "B leaves the options screen")
+54 -2
View File
@@ -24,7 +24,11 @@ local redWorld = {
}
local redGame = {
data = { field = { outsideTilesets = { "OVERWORLD" } },
items = { OLD_ROD = { name = "OLD ROD" } } },
items = { OLD_ROD = { name = "OLD ROD" } },
maps = { PALLET_TOWN = { index = 0, tileset = "OVERWORLD" },
ROUTE_4 = { index = 11, tileset = "OVERWORLD" } },
pokemon = { CHANSEY = { name = "CHANSEY" },
PIKACHU = { name = "PIKACHU" } } },
save = { player = { name = "RED" }, party = {},
inventory = { BICYCLE = 1, OLD_ROD = 1 } },
stack = { states = { redWorld } },
@@ -34,11 +38,15 @@ function redGame.stack:top() return self.states[#self.states] end
local RedAPI = require("src.world.WorldAPI")
local red = RedAPI.new(redGame, "fixture")
local unavailable, reason = RedAPI.new({}, "fixture"):availableFieldActions()
T.eq(#unavailable, 0, "Red lists no actions without an overworld")
T.eq(reason, "no overworld", "Red reports a missing overworld")
local RedWorld = require("src.world.OverworldController")
T.check(type(RedWorld.useBicycle) == "function"
and type(RedWorld.useFishingRod) == "function"
and type(RedWorld.useFlashFieldMove) == "function"
and type(RedWorld.useStrengthFieldMove) == "function"
and type(RedWorld.useSoftboiledFieldMove) == "function"
and type(RedWorld.stopSurfing) == "function",
"Red keeps field-action execution in its world")
local actions = red:availableFieldActions()
@@ -59,7 +67,9 @@ T.check(not ok and err == "fishing rod unavailable",
T.eq(redWorld.rodUsed, used, "a rejected Red rod changes nothing")
redWorld.player.moving = true
T.eq(#red:availableFieldActions(), 0, "Red hides actions while moving")
actions, err = red:availableFieldActions()
T.eq(#actions, 0, "Red hides actions while moving")
T.eq(err, "world is busy", "Red distinguishes a busy world from no actions")
ok, err = red:useFieldAction("bicycle")
T.check(not ok and err == "world is busy",
"Red refuses a stale action while busy")
@@ -88,6 +98,40 @@ T.check(redWorld.cutUsed and redWorld.surfUsed and redWorld.strengthUsed
and redWorld.flashUsed and redWorld.teleportUsed,
"Red delegates every move to its overworld path")
local source = { species = "CHANSEY", level = 30, hp = 80,
stats = { hp = 100 }, moves = { { id = "SOFTBOILED" } } }
local target = { species = "PIKACHU", level = 20, hp = 10,
stats = { hp = 50 }, moves = {} }
redGame.save.party = { source, target }
redWorld.useSoftboiledFieldMove = function(self, user, recipient)
self.softboiled = { user, recipient }
return true
end
byId = {}
for _, action in ipairs(red:availableFieldActions()) do byId[action.id] = action end
T.check(byId.softboiled and byId.softboiled.sources[1].targets[1].slot == 2,
"Red lists only valid SOFTBOILED targets")
T.check(red:useFieldAction("softboiled", { sourceSlot = 1, targetSlot = 2 }),
"Red accepts a listed SOFTBOILED transfer")
T.check(redWorld.softboiled[1] == source and redWorld.softboiled[2] == target,
"Red delegates SOFTBOILED to its overworld path")
ok, err = red:useFieldAction("softboiled", { sourceSlot = 2, targetSlot = 1 })
T.check(not ok and err == "softboiled target unavailable",
"Red rejects an invalid SOFTBOILED source")
redGame.save.inventory.THUNDERBADGE = 1
redGame.save.visited = { PALLET_TOWN = true, ROUTE_4 = true }
redGame.data.field.flyOrder = { "PALLET_TOWN", "ROUTE_4" }
redGame.data.field.flyWarps = { PALLET_TOWN = true, ROUTE_4 = true }
redMoves.FLY = source
redWorld.flyTo = function(self, mapId) self.flewTo = mapId end
T.check(red:canFly(), "Red exposes FLY only in a valid outdoor context")
T.check(red:flyTo("PALLET_TOWN") and redWorld.flewTo == "PALLET_TOWN",
"Red validates and delegates a visited FLY destination")
ok, err = red:flyTo("ROUTE_4")
T.check(not ok and err == "destination unavailable",
"Red rejects a fly warp that is not a native town destination")
redSurf = "dismount"
redWorld.player.surfing = true
redWorld.stopSurfing = function(self) self.dismounted = true end
@@ -142,6 +186,9 @@ goldWorld.fieldContext = function(_, mon) return {
local GoldAPI = require("src.world.gen2.WorldAPI")
local gold = GoldAPI.new(goldGame, "fixture")
unavailable, reason = GoldAPI.new({}, "fixture"):availableFieldActions()
T.eq(#unavailable, 0, "Gold lists no actions without an overworld")
T.eq(reason, "no overworld", "Gold reports a missing overworld")
actions = gold:availableFieldActions()
byId = {}
for _, action in ipairs(actions) do byId[action.id] = action end
@@ -170,4 +217,9 @@ T.check(gold:useFieldAction("squirtbottle"),
T.eq(goldWorld.itemUsed, "SQUIRTBOTTLE",
"Gold delegates the SquirtBottle to its field-item path")
goldWorld.acceptsMenuInput = function() return false end
actions, err = gold:availableFieldActions()
T.eq(#actions, 0, "Gold hides actions while busy")
T.eq(err, "world is busy", "Gold distinguishes a busy world from no actions")
T.finish()
+253
View File
@@ -0,0 +1,253 @@
-- Regression coverage for #1345 (BattleState:sgbBattlePals bar bypassing
-- PaletteFX.pal), #1346 (SummaryMenu double-tinting the HP fill) and #1340
-- (animSpriteColors hardcoding the SGB anim model). engine/gfx/palettes.asm
-- SetPal_Battle, engine/pokemon/status_screen.asm:120-125,
-- engine/battle/animations.asm:551-578.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity hpcolor og palette")
local check, eq, same = S.check, S.eq, S.same
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local PaletteFX = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local BattleState = require("src.battle.BattleState")
local HudTiles = require("src.render.HudTiles")
local SummaryMenu = require("src.ui.SummaryMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
Sound.playCry = function() end
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
local function setup(version, mode)
GameVersion.set(version)
PaletteFX.setMode(mode)
end
local function colorEq(got, want, msg)
local ok = got and want and got[1] == want[1] and got[2] == want[2]
and got[3] == want[3]
return check(ok, ("%s (got %s, want %s)"):format(msg,
got and ("(%d,%d,%d)"):format(got[1], got[2], got[3]) or "nil",
want and ("(%d,%d,%d)"):format(want[1], want[2], want[3]) or "nil"))
end
local function bucket(r)
if r > 0.83 then return 1 elseif r > 0.5 then return 2
elseif r > 0.17 then return 3 else return 4 end
end
-- ------------------------------------------------------- #1345 bar() routes
-- through PaletteFX.pal instead of the raw SGB pack
local ZONE0 = {
{ "red", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "blue", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "yellow", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "red", "ogred", { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } },
{ "blue", "ogred", { 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 } },
{ "yellow", "ogred", { 255, 255, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 25, 25, 25 } },
}
local function fullHpBattle()
return {
data = Data,
player = { mon = { hp = 20, stats = { hp = 20 } } },
enemy = { mon = { hp = 20, stats = { hp = 20 } } },
}
end
for _, c in ipairs(ZONE0) do
local version, mode = c[1], c[2]
setup(version, mode)
local pals = BattleState.sgbBattlePals(fullHpBattle())
check(pals ~= nil, ("%s/%s sgbBattlePals returns four zones"):format(version, mode))
local zone0 = pals and pals[0]
colorEq(zone0 and zone0[1], c[3], ("%s/%s zone0 color0"):format(version, mode))
colorEq(zone0 and zone0[2], c[4], ("%s/%s zone0 color1 (GetHealthBarColor)"):format(version, mode))
colorEq(zone0 and zone0[3], c[5], ("%s/%s zone0 color2 (bar fill)"):format(version, mode))
colorEq(zone0 and zone0[4], c[6], ("%s/%s zone0 color3"):format(version, mode))
end
-- --------------------------------------------- #1345 placeholder branch lock
-- OG RED / OG BLUE: zones 2/3 must land on the version's boot-ROM BG
-- endpoints regardless of placeholder, never the raw SGB pack's paper/ink.
for _, version in ipairs({ "red", "blue" }) do
setup(version, "ogred")
local white = PaletteFX.ogBg()[1]
local black = PaletteFX.ogBg()[4]
for _, ph in ipairs({ false, true }) do
local fake = fullHpBattle()
fake.showPlayerBack, fake.showEnemyTrainer = ph, ph
local pals = BattleState.sgbBattlePals(fake)
for _, z in ipairs({ 2, 3 }) do
local zone = pals[z]
colorEq(zone[1], white, ("%s/ogred placeholder=%s zone%d color0 is boot-ROM white")
:format(version, tostring(ph), z))
colorEq(zone[4], black, ("%s/ogred placeholder=%s zone%d color3 is boot-ROM black")
:format(version, tostring(ph), z))
check(not (zone[1][1] == 255 and zone[1][2] == 239 and zone[1][3] == 255),
("%s/ogred placeholder=%s zone%d color0 is not the raw SGB paper")
:format(version, tostring(ph), z))
check(not (zone[4][1] == 25 and zone[4][2] == 16 and zone[4][3] == 16),
("%s/ogred placeholder=%s zone%d color3 is not the raw SGB ink")
:format(version, tostring(ph), z))
end
end
setup(version, "ogred")
local off = BattleState.sgbBattlePals(fullHpBattle())
local fakeOn = fullHpBattle()
fakeOn.showPlayerBack, fakeOn.showEnemyTrainer = true, true
local on = BattleState.sgbBattlePals(fakeOn)
for _, z in ipairs({ 2, 3 }) do
same(off[z], on[z], ("%s/ogred zone%d is byte-identical placeholder or not")
:format(version, z))
end
end
-- Yellow keeps the raw MEWMON pack in both gbc and ogred (usesYellowCgb
-- guards both call sites), so placeholder must be byte-identical across modes.
do
setup("yellow", "gbc")
local fakeGbc = fullHpBattle()
fakeGbc.showPlayerBack, fakeGbc.showEnemyTrainer = true, true
local gbcPals = BattleState.sgbBattlePals(fakeGbc)
setup("yellow", "ogred")
local fakeOgred = fullHpBattle()
fakeOgred.showPlayerBack, fakeOgred.showEnemyTrainer = true, true
local ogredPals = BattleState.sgbBattlePals(fakeOgred)
for _, z in ipairs({ 2, 3 }) do
same(ogredPals[z], gbcPals[z],
("yellow/ogred placeholder zone%d is byte-identical to yellow/gbc"):format(z))
end
end
-- ------------------------------------------------------- #1345 blackout lock
-- OG RED / OG BLUE blacked-out zones must be the boot-ROM white/black, not
-- the raw PAL_BLACK pack. Yellow keeps the raw pack in both modes.
for _, version in ipairs({ "red", "blue" }) do
setup(version, "ogred")
local white = PaletteFX.ogBg()[1]
local black = PaletteFX.ogBg()[4]
local fake = fullHpBattle()
fake.blackedOut = true
local pals = BattleState.sgbBattlePals(fake)
check(pals ~= nil, ("%s/ogred blackout still returns four zones"):format(version))
for z = 0, 3 do
colorEq(pals[z][1], white, ("%s/ogred blackout zone%d color0 is boot-ROM white"):format(version, z))
colorEq(pals[z][4], black, ("%s/ogred blackout zone%d color3 is boot-ROM black"):format(version, z))
check(not (pals[z][1][1] == 255 and pals[z][1][2] == 239 and pals[z][1][3] == 255),
("%s/ogred blackout zone%d color0 is not the raw PAL_BLACK paper"):format(version, z))
end
end
do
setup("yellow", "gbc")
local gbcOut = BattleState.sgbBattlePals({ data = Data, blackedOut = true,
player = { mon = {} }, enemy = { mon = {} } })
setup("yellow", "ogred")
local ogredOut = BattleState.sgbBattlePals({ data = Data, blackedOut = true,
player = { mon = {} }, enemy = { mon = {} } })
same(ogredOut[0], gbcOut[0], "yellow/ogred blackout is byte-identical to yellow/gbc")
end
-- ------------------------------------------------------- #1346 grayFill guard
-- SummaryMenu:draw must pass grayFill behind the same barZoned guard
-- PartyMenu already used, or the fill double-applies through the zone pass.
local SUMMARY = {
{ "blue", "ogred", { 0, 0, 0 }, { 0, 0, 255 } },
{ "red", "gbc", { 25, 16, 16 }, { 74, 165, 90 } },
{ "red", "redpp", { 0, 0, 0 }, { 0, 189, 0 } },
{ "yellow", "ogred", { 25, 25, 25 }, { 0, 255, 0 } },
{ "red", "ogred", { 148, 58, 58 }, { 148, 58, 58 } },
}
for _, c in ipairs(SUMMARY) do
local version, mode, wantOld, wantNew = c[1], c[2], c[3], c[4]
setup(version, mode)
local mon = Pokemon.new(Data, "BULBASAUR", 20)
mon.hp = mon.stats.hp
local game = { data = Data,
save = { options = {}, player = { id = 1, name = "RED" } } }
local menu = SummaryMenu.new(game, mon)
local realBar = HudTiles.drawHPBar
local realShader = PaletteFX.shader
local seenGrayFill
HudTiles.drawHPBar = function(_, _, _, _, _, grayFill)
seenGrayFill = grayFill and true or false
end
PaletteFX.shader = function() return { send = function() end } end
local ok, err = pcall(function() menu:draw() end)
HudTiles.drawHPBar, PaletteFX.shader = realBar, realShader
check(ok, ("%s/%s SummaryMenu:draw runs headless%s"):format(version, mode,
ok and "" or (": " .. tostring(err))))
check(seenGrayFill == true,
("%s/%s SummaryMenu:draw asks for a gray fill when a zone pass will run")
:format(version, mode))
local pal = PaletteFX.pal(Data, "GREENBAR")
check(pal ~= nil, ("%s/%s GREENBAR resolves"):format(version, mode))
local predicted
if seenGrayFill then
predicted = pal[bucket(85 / 255)]
else
local tintR = math.min(1, pal[3][1] / 170)
predicted = pal[bucket((85 / 255) * tintR)]
end
colorEq(predicted, wantNew,
("%s/%s summary HP fill matches the fixed color"):format(version, mode))
local oldPredicted = pal[bucket((85 / 255) * math.min(1, pal[3][1] / 170))]
colorEq(oldPredicted, wantOld,
("%s/%s summary HP fill's pre-fix formula reproduces the reported color")
:format(version, mode))
end
-- ---------------------------------------------------- #1340 animSpriteColors
local ANIM = {
{ "red", "ogred", { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 } },
{ "blue", "ogred", { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } },
{ "red", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "blue", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "yellow", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "yellow", "ogred", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
}
for _, c in ipairs(ANIM) do
local version, mode = c[1], c[2]
setup(version, mode)
local zone = PaletteFX.pack(Data).palettes.GREENBAR
local fake = { data = Data, zoneColorsAt = function() return zone end }
local s = { obp = "f0", x = 72, y = 80 }
local out = BattleState.animSpriteColors(fake, s, 64, 64)
check(out ~= nil, ("%s/%s animSpriteColors returns a triple"):format(version, mode))
local rounded = {}
for i = 1, 3 do
rounded[i] = out and {
math.floor(out[i][1] * 255 + 0.5),
math.floor(out[i][2] * 255 + 0.5),
math.floor(out[i][3] * 255 + 0.5),
} or nil
end
colorEq(rounded[1], c[3], ("%s/%s animSpriteColors f0 shade0"):format(version, mode))
colorEq(rounded[2], c[4], ("%s/%s animSpriteColors f0 shade1"):format(version, mode))
colorEq(rounded[3], c[5], ("%s/%s animSpriteColors f0 shade2"):format(version, mode))
end
GameVersion.set("red")
eq(PaletteFX.usesSpriteObp("ogred"), true, "OG RED uses the boot-ROM OBJ ramp for anims")
GameVersion.set("yellow")
eq(PaletteFX.usesSpriteObp("ogred"), false, "OG YELLOW keeps the SGB anim model")
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
S.finish()
+5 -13
View File
@@ -183,32 +183,24 @@ do
end
end
check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step")
-- 7 is missing because that is the block the player is stood on
check(dirsEqual(wake, { 8, 6, 5, 4, 3, 2, 1 }),
check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }),
"water closes in astern, stern column first")
-- EraseSSAnne leaves the player's own block alone ("south of the player
-- and won't be redrawn"), so he never stands on water on the way out
local pbx, pby = 7, 1
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
check(not (r[2] == pbx and r[3] == pby),
"the block under the player is never rewritten")
check(r[2] >= 1 and r[2] <= DOCK_HULL.x1,
"the slide stays inside the dock's water, off the pier column 0")
end
-- she has to end up gone: every hull block bar the player's is water by
-- the last edit that touches it
-- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship,
-- the gangway block under the player included (#1211)
local final = {}
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
final[r[2] .. "," .. r[3]] = r[4]
end
for bx = DOCK_HULL.x0, DOCK_HULL.x1 do
for by = DOCK_HULL.y0, DOCK_HULL.y1 do
if not (bx == pbx and by == pby) then
check(WATER[final[bx .. "," .. by]],
("hull block (%d,%d) ends as open water"):format(bx, by))
end
check(WATER[final[bx .. "," .. by]],
("hull block (%d,%d) ends as open water"):format(bx, by))
end
end
+4 -3
View File
@@ -178,8 +178,8 @@ do
end
check(sawSurf, "departure plays Music_Surfing")
check(ow._queued ~= nil, "departure queues the sail-away script")
-- #360: the surf override must NOT ride into Vermilion City, she has to
-- sail west block by block, and the block under the player stays dry
-- #360: the surf override must NOT ride into Vermilion City, and she
-- sails west block by block
local kept, horns, slid, underPlayer = false, 0, 0, false
for _, row in ipairs(ow._queued or {}) do
if row[1] == "play_music" and row[3] and row[3].keep then kept = true end
@@ -194,7 +194,8 @@ do
eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp")
eq(horns, 2, "the horn blows before and after she sails")
check(slid > 8, "she sails west block by block instead of vanishing")
eq(underPlayer, false, "the block under the player is never watered over")
-- scripts/VermilionDock.asm:182-203 (#1211)
eq(underPlayer, true, "the gangway block under the player is erased too")
end
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
+4 -1
View File
@@ -49,7 +49,10 @@ local dex = DexEntryMenu.new(game, "PIKACHU")
check(dex.spriteTrueColor == true,
"Pokedex keeps a Pokemon sprite's trueColor flag")
local dexRects = uiRects(function() dex:draw() end)
local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight())
-- engine/menus/pokedex.asm:503: the pic sits in the 7x7 window at (8,8)
local dw, dh = dex.sprite:getDimensions()
local dx = 8 + math.floor((8 - dw / 8) / 2) * 8
local dy = 8 + (7 - dh / 8) * 8
check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
and dexRects[1].w == dex.sprite:getWidth()
and dexRects[1].h == dex.sprite:getHeight(),
+1
View File
@@ -3559,6 +3559,7 @@ runSuites({
"tests/gen2_hof_continue_test.lua",
"tests/gen2_pokecenter_stairs_test.lua",
"tests/gen2_canlose_test.lua",
"tests/gen2_wild_cooldown_bug1229_test.lua",
"tests/gen2_pc_screens_test.lua",
"tests/gen2_badge_boosts_test.lua",
"tests/gen2_held_items_test.lua",