mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment
This commit is contained in:
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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".
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user