CLOSES #1181, CLOSES #1212, CLOSES #1214, CLOSES #1224, CLOSES #1230, CLOSES #1249, CLOSES #1271, CLOSES #1272, CLOSES #1273, CLOSES #1298, CLOSES #1305, CLOSES #1307, CLOSES #1318, CLOSES #1328, CLOSES #1330, CLOSES #1331, CLOSES #1333, CLOSES #1334, CLOSES #1335, CLOSES #1340, CLOSES #1345, CLOSES #1346, CLOSES #1360, CLOSES #1362

This commit is contained in:
bryanthaboi
2026-08-16 06:41:56 -04:00
parent 3ee50a27c5
commit 12fdfa1e88
119 changed files with 2555 additions and 42186 deletions
@@ -0,0 +1,323 @@
-- ..(engine/movie/title.asm ln 321 DrawPlayerCharacter, ball at ln 99)
return function(game)
local U = dofile("tests/drivers/util.lua")
local P = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local TitleState = require("src.ui.TitleState")
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local REST_OBJ_MIN, REST_OBJ_MAX = 340, 520
local MENU_OBJ_SLACK = 8
-- ..(engine/movie/title.asm ln 321): 40x56 at (82,80), plus a margin
local BAND = { 74, 74, 132, 144 }
local fails = 0
local function check(label, ok, detail)
U.log(ok and "PASS" or "FAIL", label, detail or "")
if not ok then fails = fails + 1 end
return ok
end
local rendered = 0
local hostDraw = love.draw
love.draw = function(...)
local r = hostDraw(...)
rendered = rendered + 1
return r
end
local function waitRenderedFrames(frames)
local target = rendered + (frames or 2)
for _ = 1, 2400 do
if rendered >= target then return true end
U.wait(1)
end
return false
end
local function waitFor(pred, limit)
for _ = 1, limit or 1800 do
if pred() then return true end
U.wait(1)
end
return false
end
local function redrawCount(frames)
local m = 0
for _ = 1, frames or 4 do
waitRenderedFrames(1)
local n = #P.uiSpriteRedraws()
if n > m then m = n end
end
return m
end
local objRamp
local function isObj(R, G, B)
for _, c in ipairs(objRamp) do
if math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12
and math.abs(B - c[3]) <= 12 then
return true
end
end
return false
end
local function letterbox(img)
local W, H = img:getWidth(), img:getHeight()
local x1, y1, x2, y2 = W, H, -1, -1
for y = 0, H - 1 do
for x = 0, W - 1 do
local r, g, b = img:getPixel(x, y)
if r > 0.02 or g > 0.02 or b > 0.02 then
if x < x1 then x1 = x end
if x > x2 then x2 = x end
if y < y1 then y1 = y end
if y > y2 then y2 = y end
end
end
end
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
return x1, y1, bh / 144
end
local s = math.min(W / 160, H / 144)
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
end
local function census(path, bx1, by1, bx2, by2)
local f = io.open(path, "rb")
if not f then return nil end
local bytes = f:read("*a")
f:close()
local ok, img = pcall(function()
return love.image.newImageData(
love.filesystem.newFileData(bytes, "shot.png"))
end)
if not ok or not img then return nil end
local W, H = img:getWidth(), img:getHeight()
local ox, oy, scale = letterbox(img)
local x1 = ox + math.floor((bx1 or 0) * scale)
local y1 = oy + math.floor((by1 or 0) * scale)
local x2 = ox + math.ceil((bx2 or 160) * scale) - 1
local y2 = oy + math.ceil((by2 or 144) * scale) - 1
local c = { grey = 0, obj = 0, ink = 0, scale = scale, w = W, h = H }
for y = math.max(0, y1), math.min(H - 1, y2) do
for x = math.max(0, x1), math.min(W - 1, x2) do
local r, g, b = img:getPixel(x, y)
local R = math.floor(r * 255 + 0.5)
local G = math.floor(g * 255 + 0.5)
local B = math.floor(b * 255 + 0.5)
if R == G and G == B and R > 60 and R < 240 then c.grey = c.grey + 1 end
if R < 30 and G < 30 and B < 30 then c.ink = c.ink + 1 end
if isObj(R, G, B) then c.obj = c.obj + 1 end
end
end
local d = scale * scale
c.rawGrey, c.rawObj, c.rawInk = c.grey, c.obj, c.ink
c.grey = math.floor(c.grey / d + 0.5)
c.obj = math.floor(c.obj / d + 0.5)
c.ink = math.floor(c.ink / d + 0.5)
return c
end
local function report(tag, c)
U.log(string.format(
"%s: obj=%d grey=%d ink=%d (raw obj=%d grey=%d at %dx%d, scale %.2f)",
tag, c.obj, c.grey, c.ink, c.rawObj, c.rawGrey, c.w, c.h, c.scale))
end
local version = GameVersion.get()
U.wait(5)
U.tap(game, "start")
local title
check("reached the title screen", waitFor(function()
title = game.stack:top()
return getmetatable(title) == TitleState
end, 300))
if getmetatable(title) ~= TitleState then
U.log("BUG #1214 DRIVER ABORTED: never reached TitleState")
love.event.quit(1)
return
end
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
check("COLORS is ogred", P.mode == "ogred", P.mode)
check("usesSpriteObp() true, so the OBJ ramp is baked",
P.usesSpriteObp() == true)
objRamp = { P.ogObj()[2], P.ogObj()[3] }
U.log("version:", tostring(version), "boot-ROM OBJ ramp:",
string.format("(%d,%d,%d)/(%d,%d,%d)",
objRamp[1][1], objRamp[1][2], objRamp[1][3],
objRamp[2][1], objRamp[2][2], objRamp[2][3]))
check("title reached the loop phase",
waitFor(function() return title.phase == "loop" end))
check("mon cycle reached the hold beat",
waitFor(function() return title.scrollPhase == "hold" end))
waitRenderedFrames(3)
local restObj
do
check("nothing is occluding the title", game.stack:top() == title
and title.titleUiBox == nil)
check("at rest the four player draws are recorded for replay",
redrawCount(6) == 4, "#" .. #P.uiSpriteRedraws())
local shot = SHOT_DIR .. "/bug1214_1_ogred_rest.png"
if U.shot(game, shot) then
local c = census(shot)
local band = census(shot, BAND[1], BAND[2], BAND[3], BAND[4])
report("REST screen", c)
report("REST player band", band)
restObj = c.obj
check("#1214 REST: player wears the boot-ROM OBJ ramp (expect ~427"
.. " canvas px, 12141 raw on a 1024x768 window)",
c.obj >= REST_OBJ_MIN and c.obj <= REST_OBJ_MAX, c.obj)
check("#1214 REST: no DMG grey in the player band (r==g==b, 60<r<240)",
band.grey == 0, band.grey)
end
end
do
check("caught the mon sliding through the player bbox",
waitFor(function()
return title.scrollPhase == "in" and title.monOffset
and title.monOffset >= 10 and title.monOffset <= 60
end))
local before = title.monOffset
local shot = SHOT_DIR .. "/bug1214_2_ogred_midscroll.png"
if U.shot(game, shot) then
local c = census(shot)
local band = census(shot, BAND[1], BAND[2], BAND[3], BAND[4])
U.log("mid-scroll monOffset:", tostring(before), "->",
tostring(title.monOffset))
report("MIDSCROLL screen", c)
report("MIDSCROLL player band", band)
check("#1214 MIDSCROLL: no grey leaking through the transparent"
.. " player sprite (was ~6135 raw px before the replay)",
band.grey == 0, band.grey)
check("#1214 MIDSCROLL: player still wears the OBJ ramp",
c.obj >= REST_OBJ_MIN and c.obj <= REST_OBJ_MAX, c.obj)
end
end
local saveName = require("src.core.SaveData").saveFilename(version)
do
check("mon cycle back on the hold beat",
waitFor(function() return title.scrollPhase == "hold" end))
waitRenderedFrames(3)
pcall(function() love.filesystem.write(saveName, "return {}") end)
title:openMenu()
waitRenderedFrames(3)
local menu = game.stack:top()
local box = menu and menu.titleUiBox
U.log("menu titleUiBox:", box and table.concat(box, ",") or "nil")
check("main menu lists CONTINUE, so the box bottom is y 80",
box ~= nil and box[4] == 9,
box and tostring(box[4]) or "nil")
check("#1214 MENU: a box ending at y 80 does not suppress the player,"
.. " who starts at y 80",
redrawCount(6) == 4, "#" .. #P.uiSpriteRedraws())
local shot = SHOT_DIR .. "/bug1214_3_ogred_menu.png"
if U.shot(game, shot) then
local c = census(shot)
local band = census(shot, BAND[1], BAND[2], BAND[3], BAND[4])
report("MENU screen", c)
report("MENU player band", band)
check("#1214 MENU: OBJ ramp count unchanged from rest",
restObj ~= nil and math.abs(c.obj - restObj) <= MENU_OBJ_SLACK,
string.format("%d vs rest %d", c.obj, restObj or -1))
check("#1214 MENU: no DMG grey in the player band",
band.grey == 0, band.grey)
end
game.stack:pop()
waitRenderedFrames(2)
end
do
local ContinueInfo
for i = 1, 40 do
local name, val = debug.getupvalue(TitleState.openMenu, i)
if not name then break end
if name == "ContinueInfo" then ContinueInfo = val break end
end
check("ContinueInfo class reachable", ContinueInfo ~= nil)
local info
if ContinueInfo then
local ok, st = pcall(ContinueInfo.new, title,
{ player = { name = "RED" }, playTime = 0, pokedex = { owned = {} } })
check("ContinueInfo.new", ok, not ok and tostring(st) or "")
if ok then info = st end
end
if info then
check("CONTINUE window box is (4,7)-(19,16)",
info.titleUiBox and info.titleUiBox[1] == 4
and info.titleUiBox[2] == 7 and info.titleUiBox[3] == 19
and info.titleUiBox[4] == 16)
game.stack:push(info)
waitRenderedFrames(3)
check("#1214 CONTINUE: every player draw overlapping the window is"
.. " suppressed", redrawCount(6) == 0,
"#" .. #P.uiSpriteRedraws())
local shot = SHOT_DIR .. "/bug1214_4_ogred_continue.png"
if U.shot(game, shot) then
local c = census(shot, 33, 57, 159, 135)
report("CONTINUE box interior", c)
check("#1214 CONTINUE: no OBJ ramp bleeding over the window",
c.obj == 0, c.obj)
local row = census(shot, 33, 103, 159, 113)
report("CONTINUE POKeDEX row", row)
check("#1214 CONTINUE: the POKeDEX row is legible ink, not sprite",
row.ink > 30 and row.obj == 0,
string.format("ink=%d obj=%d", row.ink, row.obj))
end
game.stack:pop()
waitRenderedFrames(2)
end
end
pcall(function() love.filesystem.remove(saveName) end)
do
for _, mode in ipairs({ "gbc", "og", "og_inv", "gbc_inv", "classic" }) do
P.applyOptions({ colors = mode })
waitRenderedFrames(2)
check(mode .. " records no UI sprite redraws", redrawCount(4) == 0)
end
P.applyOptions({ colors = "gbc" })
check("mon cycle back on the hold beat under gbc",
waitFor(function() return title.scrollPhase == "hold" end))
waitRenderedFrames(3)
local shot = SHOT_DIR .. "/bug1214_5_gbc.png"
if U.shot(game, shot) then
local c = census(shot)
report("GBC", c)
check("#1214 GBC: the SGB title never wears the boot-ROM OBJ ramp",
c.obj == 0, c.obj)
end
end
game.save.options.colors = "ogred"
P.applyOptions(game.save.options)
waitRenderedFrames(3)
if fails > 0 then
U.log("################################################################")
U.log("#1214 FAILED: " .. fails .. " check(s) above, shots in " .. SHOT_DIR)
U.log("################################################################")
else
U.log("#1214 PASS: all checks, shots in " .. SHOT_DIR)
end
U.log("On screen now: the OG RED title in COLORS = OG. The player and the")
U.log("pokeball in his hand must be the boot ROM's OBJ ramp (green on Red,")
U.log("pink on Blue) while the logo, ribbon, cycling mon and copyright line")
U.log("stay on the BG ramp. Before #1214 he was BG dark red, and the first")
U.log("attempt at the fix left grey rectangles around him as mons slid past.")
U.log("START opens the menu, whose box ends one pixel above his head.")
while true do
coroutine.yield()
end
end
+155
View File
@@ -0,0 +1,155 @@
-- engine/battle/effect_commands.asm:1553-1614 BattleCommand_CheckHit
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
FIGHTING = { id = "FIGHTING", index = 1, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SWIFT = { id = "SWIFT", name = "SWIFT", power = 60, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_ALWAYS_HIT" },
VITAL_THROW = { id = "VITAL_THROW", name = "VITAL THROW", power = 70,
type = "FIGHTING", accuracy = 100, pp = 10,
effect = "EFFECT_ALWAYS_HIT" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = {
id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = {
BRIGHTPOWDER = { id = "BRIGHTPOWDER", name = "BRIGHTPOWDER",
heldEffect = "HELD_BRIGHTPOWDER", heldParameter = 51 },
},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function alwaysHighRoll(n)
return 99 % math.max(1, n or 1)
end
local function newBattle()
local player = Mon.new(DATA, "MACHOP", 15, { dvs = perfect })
player.moves = {
{ id = "TACKLE", pp = 35, maxPp = 35 },
{ id = "SWIFT", pp = 20, maxPp = 20 },
{ id = "VITAL_THROW", pp = 10, maxPp = 10 },
}
local wild = Mon.new(DATA, "MACHOP", 15, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = alwaysHighRoll }), player, wild
end
local function moveEvent(events)
for _, event in ipairs(events or {}) do
if event.kind == "move" then return event end
end
return nil
end
local function findText(events, text)
for _, event in ipairs(events or {}) do
if event.kind == "message" and event.text == text then return true end
end
return false
end
local function use(battle, player, wild, moveId)
wild.hp = wild.maxHp
battle:useMove(player, wild, moveId)
local events = battle:takeEvents()
return (moveEvent(events) or {}).missed == true, events
end
do
local battle, player, wild = newBattle()
battle.stages.enemy.evasion = Effects.MAX_STAGE
T.check(use(battle, player, wild, "TACKLE"),
"at +6 evasion a 100-accuracy move misses on a high roll")
T.check(not use(battle, player, wild, "SWIFT"),
"SWIFT ignores the evasion stage")
T.check(wild.hp < wild.maxHp, "and the hit lands damage")
T.check(not use(battle, player, wild, "VITAL_THROW"),
"VITAL THROW carries the same effect")
end
do
local battle, player, wild = newBattle()
battle.stages.player.accuracy = -Effects.MAX_STAGE
T.check(use(battle, player, wild, "TACKLE"),
"at -6 accuracy a 100-accuracy move misses on a high roll")
T.check(not use(battle, player, wild, "SWIFT"),
"SWIFT ignores the accuracy stage")
end
do
local battle, player, wild = newBattle()
wild.item = "BRIGHTPOWDER"
T.check(use(battle, player, wild, "TACKLE"),
"BRIGHTPOWDER alone is enough to miss on a 99 roll")
T.check(not use(battle, player, wild, "SWIFT"),
"the `ret z` sits ahead of .BrightPowder too")
end
do
local battle, player, wild = newBattle()
battle:volatile(wild).vanished = true
battle:volatile(wild).chargeMove = "FLY"
local missed, events = use(battle, player, wild, "SWIFT")
T.check(missed, "a FLY target still dodges SWIFT")
T.check(findText(events, "MACHOP's attack missed!"), "with CheckHit's .Miss")
T.eq(wild.hp, wild.maxHp, "and takes nothing")
end
do
local battle, player, wild = newBattle()
battle:volatile(wild).vanished = true
battle:volatile(wild).chargeMove = "DIG"
T.check(use(battle, player, wild, "SWIFT"), "a DIG target dodges it too")
end
do
local battle, player, wild = newBattle()
battle:volatile(wild).protect = true
local missed, events = use(battle, player, wild, "SWIFT")
T.check(missed, "PROTECT still turns SWIFT aside")
T.check(findText(events, "MACHOP protected itself!"), "with .Protect's line")
T.eq(wild.hp, wild.maxHp, "and takes nothing")
end
do
local battle, player, wild = newBattle()
T.check(not use(battle, player, wild, "SWIFT"),
"with no stage moved at all SWIFT still hits")
T.check(not use(battle, player, wild, "TACKLE"),
"and a plain move only fails the 99 roll once a stage has moved")
end
T.finish("gen2 always-hit bug 1272")
+268
View File
@@ -0,0 +1,268 @@
-- engine/battle/move_effects/pay_day.asm:13, engine/battle/core.asm:8014-8042
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local BattleState = require("src.ui.gen2.BattleState")
local Effects = require("src.battle.gen2.Effects")
local Mon = require("src.battle.gen2.Mon")
local Prize = require("src.battle.gen2.Prize")
local LEVEL = 15
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
PAY_DAY = { id = "PAY_DAY", name = "PAY DAY", power = 40, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_PAY_DAY" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MEOWTH = {
id = "MEOWTH", index = 52, name = "MEOWTH",
baseStats = { hp = 40, attack = 45, defense = 35, speed = 90,
specialAttack = 40, specialDefense = 40 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 69,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "PAY_DAY" } }, evolutions = {},
},
RATTATA = {
id = "RATTATA", index = 19, name = "RATTATA",
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = TYPES, matchups = {} },
items = {},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function highRoll(n) return 99 % math.max(1, n or 1) end
local function newSave()
return { player = { name = "GOLD", money = 1000, id = 4242 },
mom = { savedMoney = 500 }, party = {} }
end
local function newBattle(save)
local player = Mon.new(DATA, "MEOWTH", LEVEL, { dvs = perfect })
player.moves = {
{ id = "PAY_DAY", pp = 20, maxPp = 20 },
{ id = "TACKLE", pp = 35, maxPp = 35 },
}
local wild = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
wild.maxHp = 999
wild.hp = 999
local battle = Battle.new({ data = DATA, party = { player }, wild = wild,
random = highRoll, save = save })
return battle, player, wild
end
local function eventWithText(events, text)
for _, event in ipairs(events or {}) do
if event.text == text then return event end
end
return nil
end
local function findText(events, text)
return eventWithText(events, text) ~= nil
end
do
local battle, player, wild = newBattle(newSave())
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, 2 * LEVEL, "one connected hit is 2 x the user's level")
T.check(findText(battle:takeEvents(), "Coins scattered\neverywhere!"),
"CoinsScatteredText rides the hit")
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, 4 * LEVEL, "a second hit adds the same again")
battle:useMove(player, wild, "TACKLE")
T.eq(battle.payDay, 4 * LEVEL, "another move adds nothing")
end
do
local battle, player, wild = newBattle(newSave())
battle.stages.enemy.evasion = Effects.MAX_STAGE
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, nil, "a miss accumulates nothing")
end
do
local battle, player, wild = newBattle(newSave())
battle.amuletCoin = true
battle:useMove(player, wild, "PAY_DAY")
battle:useMove(player, wild, "PAY_DAY")
T.eq(battle.payDay, 4 * LEVEL,
"the Amulet Coin does not double the running counter")
end
do
local battle, player, wild = newBattle(newSave())
local enemy = battle.enemy
enemy.level = 40
battle:useMove(enemy, player, "PAY_DAY")
T.eq(battle.payDay, 80, "an enemy's PAY DAY pays the player, at ITS level")
T.check(wild == battle.enemy, "the enemy is the wild mon")
end
do
local save = newSave()
T.eq(Prize.payDay(save, 30, false), 30, "the payout returns what it paid")
T.eq(save.player.money, 1030, "and writes the wallet directly")
T.eq(save.mom.savedMoney, 500, "with no Mom split")
end
do
local save = newSave()
T.eq(Prize.payDay(save, 30, true), 60, "the Amulet Coin doubles the total")
T.eq(save.player.money, 1060, "once, at payout")
end
do
local save = newSave()
T.eq(Prize.payDay(save, 0, true), nil, "an empty counter pays nothing")
T.eq(Prize.payDay(save, nil, false), nil, "and neither does an unset one")
T.eq(save.player.money, 1000, "the wallet is untouched")
T.eq(Prize.payDay(nil, 30, false), nil, "a save-less battle pays nothing")
end
do
local save = newSave()
save.player.money = Prize.MAX_MONEY - 10
Prize.payDay(save, 500, true)
T.eq(save.player.money, Prize.MAX_MONEY, "AddBattleMoneyToAccount clamps")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
wild.hp = 0
T.check(battle:resolveFaints(), "the enemy faint ends the battle")
T.eq(battle.outcome, "win", "on the win arm")
T.eq(save.player.money, 1000 + 2 * LEVEL, "which is where CheckPayDay runs")
T.eq(battle.payDay, nil, "and the counter is cleared")
local paid = eventWithText(battle:takeEvents(), "GOLD picked up ¥30!")
T.check(paid, "BattleText_PlayerPickedUpPayDayMoney")
T.eq(paid and paid.kind, "money", "on the same event kind the prize uses")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle.amuletCoin = true
battle:useMove(player, wild, "PAY_DAY")
wild.hp = 0
battle:resolveFaints()
T.eq(save.player.money, 1000 + 4 * LEVEL,
"the Amulet Coin doubles once on the way out")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
player.hp = 0
T.check(battle:resolveFaints(), "a whiteout ends the battle")
T.eq(battle.outcome, "lose", "on the lose arm")
T.eq(save.player.money, 1000, "which pays nothing")
T.eq(battle.payDay, 2 * LEVEL, "the counter is dropped, not banked")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
battle:endBattle("run")
T.eq(save.player.money, 1000, "running away pays nothing")
end
local function newScreen(save, battle, opts)
opts = opts or {}
local pushed = {}
local screen = setmetatable({
save = save, battle = battle, picHidden = {},
contest = opts.contest, tutorial = opts.tutorial,
contestCaught = false,
push = function(self, event) pushed[#pushed + 1] = event end,
name = function(_, mon) return mon.species end,
hasPokedex = function() return false end,
currentBox = function() return 1 end,
contestCatch = function(self) self.contestCaught = true end,
}, { __index = BattleState })
return screen, pushed
end
local function caughtEnemy(battle)
local enemy = battle.enemy
enemy.hp = 1
return enemy
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
local screen, pushed = newScreen(save, battle)
screen:pushCaught(caughtEnemy(battle), "POKE_BALL")
T.eq(save.player.money, 1000 + 2 * LEVEL, "a capture pays out")
T.eq(battle.payDay, nil, "and clears the counter")
T.check(findText(pushed, "GOLD picked up ¥30!"), "with the payout line")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
local screen = newScreen(save, battle, { contest = true })
screen:pushCaught(caughtEnemy(battle), "PARK_BALL")
T.check(screen.contestCaught, "the contest arm still holds the mon")
T.eq(save.player.money, 1000 + 2 * LEVEL, "and a contest catch pays too")
T.eq(battle.payDay, nil, "clearing the counter with it")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
local screen = newScreen(save, battle, { tutorial = true })
screen:pushCaught(caughtEnemy(battle), "POKE_BALL")
T.eq(save.player.money, 1000, "the catching tutorial pays nothing")
T.eq(battle.payDay, 2 * LEVEL, "and leaves the counter alone")
end
do
local save = newSave()
local battle, player, wild = newBattle(save)
battle:useMove(player, wild, "PAY_DAY")
wild.hp = 0
battle:resolveFaints()
local screen = newScreen(save, battle, { contest = true })
screen:pushPayDay()
T.eq(save.player.money, 1000 + 2 * LEVEL,
"a contest KO that already paid does not pay a second time")
end
T.finish("gen2 pay day bug 1212")
@@ -0,0 +1,147 @@
-- engine/battle_anims/anim_commands.asm:905-960 GetSubstitutePic
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local AnimRunner = require("src.battle.gen2.AnimRunner")
local Assets = require("src.render.Assets")
local BattleState = require("src.ui.gen2.BattleState")
local MONSTER = { getDimensions = function() return 16, 96 end }
Assets.image = function(path)
if path == "assets/generated/sprites/monster.png" then return MONSTER end
return nil
end
local drawn
love.graphics.draw = function(image, a, b, c)
if type(a) == "table" then
drawn[#drawn + 1] = { image = image, quad = a, x = b, y = c }
else
drawn[#drawn + 1] = { image = image, x = a, y = b }
end
end
do
local runner = { env = { battleTurn = 0 }, picOverride = {} }
AnimRunner.COMMANDS.raisesub(runner)
T.eq(runner.picOverride.player, "substitute",
"anim_raisesub puts the doll up on the acting side")
AnimRunner.COMMANDS.dropsub(runner)
T.eq(runner.picOverride.player, false,
"anim_dropsub writes false, not nil")
T.check(runner.picOverride.player ~= nil,
"so LowerSub reads as an override and not as `no animation running`")
end
do
local runner = { env = { battleTurn = 1 }, picOverride = {} }
AnimRunner.COMMANDS.raisesub(runner)
T.eq(runner.picOverride.enemy, "substitute", "and the enemy side too")
T.eq(runner.picOverride.player, nil, "without touching the other one")
end
local function newAnim(overrides)
return {
bg = { hidden = {}, picSize = {}, slide = {}, monShade = {} },
picOverride = overrides or {},
}
end
local function newScreen(anim)
local image = { getDimensions = function() return 56, 56 end }
local back = { getDimensions = function() return 48, 48 end }
return setmetatable({
picHidden = {}, anim = anim,
pic = function(_, _, isBack) return isBack and back or image end,
picScale = function() return 1 end,
faintSink = function() return 0 end,
}, { __index = BattleState }), image, back
end
do
local screen = newScreen(newAnim({ enemy = "substitute" }))
T.eq(screen:animPicState("enemy").pic, "substitute",
"animPicState surfaces the runner's override")
end
do
local screen, image = newScreen(newAnim({ enemy = "substitute" }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = {} }, false)
T.eq(#drawn, 1, "one blit")
T.eq(drawn[1].image, MONSTER, "the doll, not the frontpic")
T.check(drawn[1].image ~= image, "the mon's own pic is not drawn")
T.eq(drawn[1].x, 112, "enemy doll x, GetSubstitutePic's cols 2-3")
T.eq(drawn[1].y, 40, "enemy doll y, rows 5-6 of the 7-tall box")
local quad = drawn[1].quad or {}
T.eq(quad.x, 0, "cut from the facing-DOWN frame")
T.eq(quad.y, 0, "at the top of monster.png")
T.eq(quad.w, 16, "16 wide")
T.eq(quad.h, 16, "16 tall")
end
do
local screen = newScreen(newAnim({ player = "substitute" }))
drawn = {}
screen:drawPic({ species = "TYPHLOSION", volatile = {} }, true)
T.eq(#drawn, 1, "one blit")
T.eq(drawn[1].x, 32, "player doll x, cols 2-3 of the 6-wide box")
T.eq(drawn[1].y, 80, "player doll y, rows 4-5 of the 6-tall box")
T.eq((drawn[1].quad or {}).y, 16, "cut from the facing-UP frame")
end
do
local anim = newAnim({ enemy = "substitute" })
anim.bg.slide.enemy = 8
local screen = newScreen(anim)
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = {} }, false)
T.eq(drawn[1].x, 120, "REMOVE_MON / ENTER_MON still slide the doll")
end
do
local screen, image = newScreen(nil)
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, MONSTER, "with no animation running the volatile wins")
T.check(drawn[1].image ~= image, "and the frontpic stays down")
end
do
local screen, image = newScreen(nil)
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = {} }, false)
T.eq(drawn[1].image, image, "no substitute, no doll")
T.eq(drawn[1].x, 96, "the frontpic sits in its own box")
T.eq(drawn[1].y, 0, "at hlcoord 12, 0")
end
do
local screen, image = newScreen(newAnim({ enemy = false }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, image,
"a dropsub override beats the still-standing substitute volatile")
T.eq(drawn[1].x, 96, "and the mon is drawn in its own box")
end
do
local screen = newScreen(newAnim({ enemy = nil }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, MONSTER,
"an animation that never touched the pic leaves the doll up")
end
do
local screen, image = newScreen(newAnim({ enemy = "transform" }))
drawn = {}
screen:drawPic({ species = "GASTLY", volatile = { substitute = 22 } }, false)
T.eq(drawn[1].image, image,
"and any other override is not the doll either")
end
T.finish("gen2 substitute doll bug 1271")
@@ -0,0 +1,215 @@
-- data/types/type_matchups.asm:112-116, engine/battle/effect_commands.asm:1305-1313
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Game2 = require("src.core.Game2")
local Damage = require("src.battle.gen2.Damage")
local TYPES = {
"NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BIRD", "BUG",
"GHOST", "STEEL", "CURSE_TYPE", "FIRE", "WATER", "GRASS", "ELECTRIC",
"PSYCHIC_TYPE", "ICE", "DRAGON", "DARK",
}
local DEFAULT_ROWS = [[
NORMAL ROCK 5
NORMAL STEEL 5
FIRE FIRE 5
FIRE WATER 5
FIRE GRASS 20
FIRE ICE 20
FIRE BUG 20
FIRE ROCK 5
FIRE DRAGON 5
FIRE STEEL 20
WATER FIRE 20
WATER WATER 5
WATER GRASS 5
WATER GROUND 20
WATER ROCK 20
WATER DRAGON 5
ELECTRIC WATER 20
ELECTRIC ELECTRIC 5
ELECTRIC GRASS 5
ELECTRIC GROUND 0
ELECTRIC FLYING 20
ELECTRIC DRAGON 5
GRASS FIRE 5
GRASS WATER 20
GRASS GRASS 5
GRASS POISON 5
GRASS GROUND 20
GRASS FLYING 5
GRASS BUG 5
GRASS ROCK 20
GRASS DRAGON 5
GRASS STEEL 5
ICE WATER 5
ICE GRASS 20
ICE ICE 5
ICE GROUND 20
ICE FLYING 20
ICE DRAGON 20
ICE STEEL 5
ICE FIRE 5
FIGHTING NORMAL 20
FIGHTING ICE 20
FIGHTING POISON 5
FIGHTING FLYING 5
FIGHTING PSYCHIC_TYPE 5
FIGHTING BUG 5
FIGHTING ROCK 20
FIGHTING DARK 20
FIGHTING STEEL 20
POISON GRASS 20
POISON POISON 5
POISON GROUND 5
POISON ROCK 5
POISON GHOST 5
POISON STEEL 0
GROUND FIRE 20
GROUND ELECTRIC 20
GROUND GRASS 5
GROUND POISON 20
GROUND FLYING 0
GROUND BUG 5
GROUND ROCK 20
GROUND STEEL 20
FLYING ELECTRIC 5
FLYING GRASS 20
FLYING FIGHTING 20
FLYING BUG 20
FLYING ROCK 5
FLYING STEEL 5
PSYCHIC_TYPE FIGHTING 20
PSYCHIC_TYPE POISON 20
PSYCHIC_TYPE PSYCHIC_TYPE 5
PSYCHIC_TYPE DARK 0
PSYCHIC_TYPE STEEL 5
BUG FIRE 5
BUG GRASS 20
BUG FIGHTING 5
BUG POISON 5
BUG FLYING 5
BUG PSYCHIC_TYPE 20
BUG GHOST 5
BUG DARK 20
BUG STEEL 5
ROCK FIRE 20
ROCK ICE 20
ROCK FIGHTING 5
ROCK GROUND 5
ROCK FLYING 20
ROCK BUG 20
ROCK STEEL 5
GHOST NORMAL 0
GHOST PSYCHIC_TYPE 20
GHOST DARK 5
GHOST STEEL 5
GHOST GHOST 20
DRAGON DRAGON 20
DRAGON STEEL 5
DARK FIGHTING 5
DARK PSYCHIC_TYPE 20
DARK GHOST 20
DARK DARK 5
DARK STEEL 5
STEEL FIRE 5
STEEL WATER 5
STEEL ELECTRIC 5
STEEL ICE 20
STEEL ROCK 20
STEEL STEEL 5
]]
local FORESIGHT_ROWS = [[
NORMAL GHOST 0
FIGHTING GHOST 0
]]
local function parse(text)
local rows = {}
for attacker, defender, multiplier in
text:gmatch("(%u[%u_]*)%s+(%u[%u_]*)%s+(%d+)") do
rows[#rows + 1] = { attacker = attacker, defender = defender,
multiplier = tonumber(multiplier) }
end
return rows
end
local function serialize(rows)
local parts = {}
for _, row in ipairs(rows) do
parts[#parts + 1] = ("{attacker=%q,defender=%q,multiplier=%d}")
:format(row.attacker, row.defender, row.multiplier)
end
return "{" .. table.concat(parts, ",") .. "}"
end
local DEFAULT = parse(DEFAULT_ROWS)
local FORESIGHT = parse(FORESIGHT_ROWS)
T.eq(#DEFAULT, 108, "the fixture carries the cart's 108 default rows")
T.eq(#FORESIGHT, 2, "and the two rows the `db -2` marker precedes")
love.filesystem.write("data/generated/type_chart.lua",
("return { generation = 2, matchups = %s, foresightMatchups = %s }")
:format(serialize(DEFAULT), serialize(FORESIGHT)))
local function bootChart()
local game = setmetatable({ data = {} }, Game2)
game.applyOptions = function() end
pcall(Game2.load, game)
return game.data.type_chart
end
local function resolve(matchups)
local out = {}
for _, attacker in ipairs(TYPES) do
for _, defender in ipairs(TYPES) do
out[attacker .. ">" .. defender] =
Damage.typeMultiplier(attacker, { defender }, matchups)
end
end
return out
end
local chart = bootChart()
T.check(chart and chart.matchups, "Gold's chart reaches self.data.type_chart")
local before = resolve(DEFAULT)
local after = resolve(chart.matchups)
T.eq(before["NORMAL>GHOST"], 10,
"unmerged, a NORMAL move is neutral against a GHOST")
T.eq(before["FIGHTING>GHOST"], 10,
"unmerged, a FIGHTING move is neutral against a GHOST")
T.eq(after["NORMAL>GHOST"], 0, "merged, NORMAL does nothing to GHOST")
T.eq(after["FIGHTING>GHOST"], 0, "merged, FIGHTING does nothing to GHOST")
T.eq(after["GHOST>NORMAL"], 0, "and GHOST still does nothing to NORMAL")
T.eq(#chart.matchups, #DEFAULT + #FORESIGHT,
"the merge appends exactly the foresight rows")
T.eq(#(chart.foresightMatchups or {}), #FORESIGHT,
"and leaves foresightMatchups in place for Fingerprint")
local moved = {}
for pair, value in pairs(after) do
if before[pair] ~= value then moved[#moved + 1] = pair end
end
table.sort(moved)
T.eq(#moved, 2, "exactly two of the 19x19 pairs move")
T.eq(moved[1], "FIGHTING>GHOST", "the first is FIGHTING against GHOST")
T.eq(moved[2], "NORMAL>GHOST", "the second is NORMAL against GHOST")
local twice = bootChart()
T.eq(#twice.matchups, #DEFAULT + #FORESIGHT,
"a second load appends the rows once, not twice")
T.eq(Damage.typeMultiplier("NORMAL", { "GHOST", "POISON" }, twice.matchups), 0,
"a dual GHOST/POISON target is still immune to NORMAL")
T.finish("gen2 type chart foresight merge bug 1268")
+81
View File
@@ -0,0 +1,81 @@
-- 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,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,68 @@
-- "This POKéMON is really energetic!" prints before the received-mon box
-- (#1334). starterBall's numeric jump targets moved down one row for the
-- new line, so this asserts the targets by ROW CONTENT, not by index, and
-- would catch a stale target the next time a row is inserted above them.
-- scripts/OaksLab.asm:919
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
local rows = M.talk.TEXT_OAKSLAB_CHARMANDER_POKE_BALL
T.check(type(rows) == "table", "starterBall rows loaded")
-- the energetic line lands between the ask and the sound/received pair
local askRow
for i, row in ipairs(rows) do
if row[1] == "ask" then askRow = i end
end
T.check(askRow ~= nil, "found the ask row")
T.eq(rows[askRow + 1][1], "jump_if_false", "the ask is followed by its jump")
T.same(rows[askRow + 2], { "show_text", "_OaksLabMonEnergeticText" },
"the energetic line is the row right after the NO jump")
T.eq(rows[askRow + 3][1], "text_sound",
"the sound cue stays on the RECEIVED line, not the energetic one")
T.eq(rows[askRow + 4][1], "show_text",
"and the received-mon text follows the sound cue")
T.eq(rows[askRow + 4][2], "_OaksLabReceivedMonText",
"specifically the received-mon text")
-- jump_if_true (row 2): the EVENT_GOT_STARTER short-circuit must land on
-- the leftover-ball beat, "face_object 5 down" -- asserted by content
local gotStarterJump
for i, row in ipairs(rows) do
if row[1] == "check_flag" and row[2] == "EVENT_GOT_STARTER" then
gotStarterJump = rows[i + 1]
break
end
end
T.check(gotStarterJump ~= nil and gotStarterJump[1] == "jump_if_true",
"found the EVENT_GOT_STARTER jump_if_true")
local target1 = gotStarterJump[2]
T.check(type(target1) == "number", "the target is a numeric row index")
T.same(rows[target1], { "face_object", 5, "down" },
"jump_if_true lands on the leftover-ball face_object row")
-- jump_if_false (row 4): the EVENT_FOLLOWED_OAK_INTO_LAB gate must land on
-- the pre-pick line, "show_text _OaksLabThoseArePokeBallsText"
local followedOakJump
for i, row in ipairs(rows) do
if row[1] == "check_flag" and row[2] == "EVENT_FOLLOWED_OAK_INTO_LAB" then
followedOakJump = rows[i + 1]
break
end
end
T.check(followedOakJump ~= nil and followedOakJump[1] == "jump_if_false",
"found the EVENT_FOLLOWED_OAK_INTO_LAB jump_if_false")
local target2 = followedOakJump[2]
T.check(type(target2) == "number", "the target is a numeric row index")
T.same(rows[target2], { "show_text", "_OaksLabThoseArePokeBallsText" },
"jump_if_false lands on the pre-pick ThoseArePokeBalls row")
-- the leftover-ball beat this jump lands on must never fall through into
-- the pre-pick line beneath it (#601 remnant); confirm a terminating jump
-- sits between them
T.eq(rows[target1 + 2][1], "jump",
"the leftover-ball beat ends on its own jump before the pre-pick row")
T.finish("oaks_lab_starter_energetic_bug1334")
+15 -7
View File
@@ -76,6 +76,8 @@ for _, name in ipairs({ "openOaksPC", "dexRating" }) do
("Game upvalue on %s"):format(name))
end
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub),
"TextBox upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
@@ -111,8 +113,14 @@ end
reset()
local done = false
fakeSelf:openPC(function() done = true end)
-- engine/menus/pc.asm:5
local pcOn = lastPush()
T.eq(pcOn.kind, "text", "openPC opens with the turned-on text")
T.check(tostring(pcOn.text):find("turned on", 1, true) ~= nil,
"the opening box is TurnedOnPC1Text")
pcOn.onDone()
local menu = lastPush()
T.eq(menu.kind, "menu", "openPC pushes the PC menu")
T.eq(menu.kind, "menu", "the PC menu follows the box")
local oak
for _, item in ipairs(menu.items) do
if item.label == "PROF.OAK's PC" then oak = item end
@@ -121,16 +129,16 @@ T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had")
oak.onSelect()
-- drop the menu's Turn_On_PC and the row's Enter_PC
plays = {}
T.eq(pushed[2].kind, "text", "selection opens the access text")
T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil,
T.eq(pushed[3].kind, "text", "selection opens the access text")
T.check(tostring(pushed[3].text):find("Accessed", 1, true) ~= nil,
"first session box is the access text")
runChain()
T.check(done, "session completes")
T.check(pushed[3].opts ~= nil and pushed[3].opts.choice ~= nil,
T.check(pushed[4].opts ~= nil and pushed[4].opts.choice ~= nil,
"the rated question carries the YES/NO choice")
T.check(tostring(pushed[3].text):find("rated", 1, true) ~= nil,
T.check(tostring(pushed[4].text):find("rated", 1, true) ~= nil,
"second session box asks for the rating")
local ratingBox = pushed[4]
local ratingBox = pushed[5]
T.check(ratingBox.opts and ratingBox.opts.auto ~= nil
and ratingBox.opts.auto.wait ~= nil,
"rating box sounds the jingle then waits for a button")
@@ -142,7 +150,7 @@ T.eq(#plays, 0, "no jingle while the evaluation is printing")
ratingBox.opts.auto.sound()
T.eq(#plays, 1, "jingle fires once the rating text is printed")
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
T.check(tostring(pushed[5].text):find("Closed link", 1, true) ~= nil,
T.check(tostring(pushed[6].text):find("Closed link", 1, true) ~= nil,
"the closing link prints at the end of the session")
-- === declining the rating skips the evaluation but still closes the link
+174
View File
@@ -0,0 +1,174 @@
-- HandlePoisonBurnLeechSeed runs once per side per round; a mod that
-- reads battle.ruleset.residualAfterMove twice (executeAction:3487 and
-- endOfTurn:2657) could otherwise fire both arms. #1181's b.residualDone
-- latch (residualFor ~2612, endOfTurn ~2670, cleared ~2685) closes that.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
local Font = require("src.render.Font")
Font.load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle(opts)
opts = opts or {}
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_A", 28) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 30)
battle.rng = function() return 0 end
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
if opts.playerFaster then
battle.player.curStats.speed = 200
battle.enemy.curStats.speed = 1
else
battle.enemy.curStats.speed = 200
battle.player.curStats.speed = 1
end
return battle
end
local function drain(battle)
local rows = {}
for _ = 1, 800 do
local item = table.remove(battle.queue, 1)
if not item then return rows end
if item.text then rows[#rows + 1] = { text = item.text } end
if item.fn then
battle.nextInsert = 0
item.fn()
end
end
error("the turn queue never drained")
end
local function countTicks(rows)
local player, enemy = 0, 0
for _, r in ipairs(rows) do
if r.text and r.text:find("hurt by poison", 1, true) then
if r.text:find("Enemy", 1, true) then enemy = enemy + 1
else player = player + 1 end
end
end
return player, enemy
end
local function assertCleared(b, label)
T.check(b.player.residualDone == nil, label .. ": player residualDone cleared")
T.check(b.enemy.residualDone == nil, label .. ": enemy residualDone cleared")
end
do
local b = newBattle({ playerFaster = true })
b.player.mon.status = "PSN"
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "faster attacker: poison ticks exactly once")
T.eq(et, 0, "faster attacker: no enemy tick")
assertCleared(b, "faster attacker")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "slower attacker: poison ticks exactly once")
T.eq(et, 0, "slower attacker: no enemy tick")
assertCleared(b, "slower attacker")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b:itemUsed({})
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "item round: poison ticks exactly once")
T.eq(et, 0, "item round: no enemy tick")
assertCleared(b, "item round")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.catchAttempt = function() return false, 0 end
local ballId
for id in pairs(Data.items or {}) do
if id:find("BALL") then ballId = id break end
end
if not ballId then
Data.items = Data.items or {}
Data.items.POKE_BALL = { name = "POKE BALL" }
ballId = "POKE_BALL"
end
b:throwBall(ballId)
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "ball round: poison ticks exactly once")
T.eq(et, 0, "ball round: no enemy tick")
assertCleared(b, "ball round")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.runRoll = function() return false end
b:tryRun()
local pt, et = countTicks(drain(b))
T.eq(pt, 1, "failed run: poison ticks exactly once")
T.eq(et, 0, "failed run: no enemy tick")
assertCleared(b, "failed run")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b:resolveSwitch(b.game.save.party[2])
local pt, et = countTicks(drain(b))
T.eq(pt, 0, "switch round: a fresh battler ticks zero")
T.eq(et, 0, "switch round: still no enemy tick")
assertCleared(b, "switch round")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.enemy.mon.status = "PSN"
local php0, ehp0 = b.player.mon.hp, b.enemy.mon.hp
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local rows = drain(b)
local pt, et = countTicks(rows)
T.eq(pt, 1, "both sides poisoned: player side ticks once")
T.eq(et, 1, "both sides poisoned: enemy side ticks once too")
T.check(b.player.mon.hp < php0, "both sides poisoned: player actually lost HP")
T.check(b.enemy.mon.hp < ehp0, "both sides poisoned: enemy actually lost HP")
assertCleared(b, "both sides poisoned")
end
do
local b = newBattle({ playerFaster = false })
b.player.mon.status = "PSN"
b.player.mon.hp = 999
b.player.mon.stats.hp = 999
local lastLoss
for round = 1, 5 do
local hp0 = b.player.mon.hp
b:resolveTurn({ id = "FIX_TACKLE", pp = 35 })
local pt, et = countTicks(drain(b))
T.eq(pt, 1, ("round %d of 5: poison ticks exactly once"):format(round))
T.eq(et, 0, ("round %d of 5: no enemy tick"):format(round))
assertCleared(b, ("round %d of 5"):format(round))
local loss = hp0 - b.player.mon.hp
T.check(loss > 0, ("round %d of 5: HP actually dropped"):format(round))
if lastLoss then
T.eq(loss, lastLoss, ("round %d of 5: same tick size as the round before"):format(round))
end
lastLoss = loss
end
end
T.finish("residual latch (#1181)")
@@ -0,0 +1,68 @@
-- The wall TOWN MAP prints TownMapText before the map screen opens (#1330).
-- engine/events/hidden_events/town_map.asm:1
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local screenPushes = {}
package.loaded["src.ui.Screens"] = {
push = function(_, id, opts)
screenPushes[#screenPushes + 1] = { id = id, opts = opts }
end,
}
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed
local textBoxStub = {
new = function(_, text, onDone, opts)
return { text = text, onDone = onDone, opts = opts }
end,
}
local fakeGame = {
data = { text = { _TownMapText = "A TOWN MAP." } },
stack = { push = function(_, box) pushed = box end },
}
T.check(setUpvalue(OW.tryBookshelf, "TextBox", textBoxStub),
"TextBox upvalue on tryBookshelf")
T.check(setUpvalue(OW.tryBookshelf, "Game", fakeGame),
"Game upvalue on tryBookshelf")
local map = {
def = { tileset = "HOUSE" },
inBounds = function() return true end,
cellTile = function() return 0x3D end,
}
local fakeSelf = setmetatable({ player = { facing = "up" }, map = map },
{ __index = OW })
pushed, screenPushes = nil, {}
local result = fakeSelf:tryBookshelf(5, 2)
T.eq(result, true, "tryBookshelf consumes the wall TOWN MAP tile")
T.check(pushed ~= nil, "a text box was pushed")
T.eq(pushed.text, "A TOWN MAP.", "the box carries TownMapText")
T.eq(#screenPushes, 0, "the TownMap screen has not opened yet")
T.check(type(pushed.onDone) == "function", "the box has an onDone")
pushed.onDone()
T.eq(#screenPushes, 1, "onDone is what opens the screen")
T.eq(screenPushes[1].id, "TownMap", "the pushed screen id is TownMap")
-- a screen id a mod removed must not crash the interaction
pushed, screenPushes = nil, {}
package.loaded["src.ui.Screens"].push = function() error("no such screen") end
fakeSelf:tryBookshelf(5, 2)
local ok = pcall(pushed.onDone)
T.check(ok, "Screens.push stays wrapped in pcall")
T.finish("town_map_bookshelf_bug1330")
+24
View File
@@ -257,6 +257,30 @@ do
eq(set[173], true, "the hidden item's flag is set by number")
end
-- engine/events/poisonstep.asm .PlayPoisonSFX: SFX_POISON then
-- LoadPoisonBGPals, four frames (#1362)
do
local World = require("src.world.gen2.World")
local ctx = { poisonBGFlash = World.poisonBGFlash,
playSfxNamed = function() end }
eq(CallAsm.run(ctx, "PlayPoisonSFX"), nil, "PlayPoisonSFX writes no wScriptVar")
eq(ctx.poisonFlash, 4, "and sets the flash to exactly four frames")
local hurt = { game = { save = { party = { { hp = 5, status = "psn" } },
poisonStepCount = 3 } },
poisonBGFlash = World.poisonBGFlash, playSfxNamed = function() end,
stepContext = function() return { linkMode = false } end }
World.countStep(hurt)
eq(hurt.poisonFlash, 4, "the hurt arm reaches it too")
local faint = { poisonBGFlash = World.poisonBGFlash, playSfxNamed = function() end }
World.poisonFaintScript(faint, { fainted = {}, whiteout = false })
eq(faint.poisonFlash, 4, "and so does the faint arm")
local ok = pcall(CallAsm.run, {}, "PlayPoisonSFX")
check(ok, "a ctx with no poisonBGFlash does not error")
end
-- engine/events/poisonstep.asm .CheckWhitedOut ends on
-- CheckPlayerPartyForFitMon, whose answer is 1 when something can still fight;
-- the `iffalse .whiteout` after it is reading "no fit mon".
+112
View File
@@ -270,6 +270,60 @@ do
"MAX_LEVEL refuses (cp MAX_LEVEL / jp nc, NoEffectMessage)")
end
-- ------------------------------------------------- ItemEffects: partyAction
do
for _, itemId in ipairs({ "HP_UP", "PROTEIN", "IRON", "CARBOS", "CALCIUM",
"PP_UP" }) do
check(ItemEffects.partyAction(itemId, DATA) ~= nil,
itemId .. " resolves a party action (#1249)")
end
eq(ItemEffects.partyAction("ZINC", DATA), nil,
"ZINC does not exist in Gen 1 or Gen 2")
end
-- ------------------------------------------------- ItemEffects: VITAMIN
do
local mon = fixtureMon(12)
local before = { hp = mon.hp, maxHp = mon.maxHp }
local result = ItemEffects.useOnMon("HP_UP", mon, DATA)
eq(result.used, true, "an HP UP under the ceiling is spent")
eq(mon.statExp.hp, 2560, "StatExpItemPointerOffsets' +10 high-byte add")
check(mon.maxHp > before.maxHp, "UpdateStatsAfterItem recomputes max HP")
eq(mon.stats.hp, mon.maxHp, "the recomputed hp stat lands on stats too")
eq(mon.hp, before.hp, "current HP is UNCHANGED by HP UP")
eq(result.text, "CYNDAQUIL's\nHEALTH rose.", "with the cart's stat line")
local ceiling = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
local refused = ItemEffects.useOnMon("HP_UP", ceiling, DATA)
eq(refused.used, false, "25600 stat exp refuses (cp 100 / jr nc)")
eq(ceiling.statExp.hp, 25600, "and leaves the word untouched")
eq(refused.text, ItemEffects.TEXT_NO_EFFECT, "with NoEffectMessage")
local edge = fixtureMon(12, { statExp = {
hp = 25599, attack = 0, defense = 0, speed = 0, special = 0 } })
local accepted = ItemEffects.useOnMon("HP_UP", edge, DATA)
eq(accepted.used, true, "25599 is still under the ceiling")
eq(edge.statExp.hp, 28159, "landing on 25599 + 2560")
local labels = {
HP_UP = { stat = "hp", label = "HEALTH" },
PROTEIN = { stat = "attack", label = "ATTACK" },
IRON = { stat = "defense", label = "DEFENSE" },
CARBOS = { stat = "speed", label = "SPEED" },
CALCIUM = { stat = "special", label = "SPECIAL" },
}
for itemId, row in pairs(labels) do
local target = fixtureMon(12)
local outcome = ItemEffects.useOnMon(itemId, target, DATA)
eq(target.statExp[row.stat], 2560, itemId .. " raises its own stat exp")
eq(outcome.text, ("CYNDAQUIL's\n%s rose."):format(row.label),
itemId .. " prints " .. row.label)
end
end
-- ------------------------------------------------- ItemEffects: PP family
do
@@ -305,6 +359,41 @@ do
"a party mon with every slot full refuses the ELIXER family")
end
-- ------------------------------------------------- ItemEffects: PP UP
do
local mon = fixtureMon(12)
for use = 1, 3 do
local result = ItemEffects.usePpItem("PP_UP", mon, 1, DATA)
eq(result.used, true, "PP UP use " .. use .. " of 3 is spent")
eq(mon.moves[1].ppUps, use, "PP_UP_ONE ticks the top two bits")
eq(mon.moves[1].maxPp, 35 + use * 7,
"min(floor(basePP / 5), 7) added per use")
eq(mon.moves[1].pp, 30 + use * 7, "current PP rises by the same amount")
end
local capped = ItemEffects.usePpItem("PP_UP", mon, 1, DATA)
eq(capped.used, false, "a 4th PP UP refuses (PP_UP_MASK's 3-use cap)")
eq(mon.moves[1].maxPp, 56, "and leaves max PP where it was")
eq(capped.text, "TACKLE's PP\nis maxed out.", "with PPIsMaxedOutText")
local sketch = fixtureMon(12)
sketch.moves[1] = { id = "SKETCH", pp = 1, maxPp = 1 }
local sketchResult = ItemEffects.usePpItem("PP_UP", sketch, 1, DATA)
eq(sketchResult.used, false, "SKETCH refuses a PP UP")
eq(sketchResult.text, "SKETCH's PP\nis maxed out.",
"with the same maxed-out line")
local modMon = fixtureMon(12)
modMon.moves[1] = { id = "MOD_MOVE", pp = 5 }
local unknown = ItemEffects.usePpItem("PP_UP", modMon, 1, DATA)
eq(unknown.used, false,
"an id absent from data.moves with no maxPp refuses (base PP unknown)")
eq(modMon.moves[1].ppUps, nil, "and is not consumed")
eq(modMon.moves[1].pp, 5, "with current PP untouched")
eq(unknown.text, "MOD_MOVE's PP\nis maxed out.",
"printing the same maxed-out line rather than PP 0")
end
-- ------------------------------------------------ Game2: the .Party wiring
-- The REAL flow: usePartyItem pushes the real Gen2PartyMenu, the pick runs
-- the effect, the consumption happens, and the message rides a real TextBox
@@ -360,6 +449,29 @@ do
check(box ~= nil and box.pages ~= nil, "and prints the no-effect line")
end
do
local mon = fixtureMon(12)
local host = newHost({ HP_UP = 1 }, { mon })
host:useFieldItem("HP_UP")
local party = host.stack:top()
check(party ~= nil and party.prompt ~= nil,
"USE pushes the party list (partyAction is no longer nil)")
drive(host, function() return host.stack:top() ~= party end)
eq(mon.statExp.hp, 2560, "the vitamin ran through the real menu")
eq(host.save.inventory.HP_UP, nil, "and the HP UP was spent")
end
do
local mon = fixtureMon(12, { statExp = {
hp = 25600, attack = 0, defense = 0, speed = 0, special = 0 } })
local host = newHost({ HP_UP = 2 }, { mon })
host:useFieldItem("HP_UP")
local party = host.stack:top()
drive(host, function() return host.stack:top() ~= party end)
eq(host.save.inventory.HP_UP, 2, "a refused HP UP costs nothing")
eq(mon.statExp.hp, 25600, "and leaves the stat exp word untouched")
end
do
-- ETHER: party pick, then the move list, then the restore.
local mon = fixtureMon(12)
+49 -5
View File
@@ -356,11 +356,9 @@ local optionsGame, optionsInput = newGame(Save.newGame())
local options = OptionsMenu.new(optionsGame, {
options = Save.defaultOptions(),
})
-- The cart's seven value rows, then CONTROLS, the port's audio, speed and
-- display rows, the three touch rows and CANCEL -- which is what makes this
-- screen scroll. The touch three are gated to mobile by buildRows; ROWS
-- itself carries every descriptor.
check("twenty rows", #OptionsMenu.ROWS, 20)
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
-- video mode, the mobile-gated touch three (buildRows) and CANCEL.
check("twenty-one rows", #OptionsMenu.ROWS, 21)
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
@@ -863,6 +861,52 @@ check("GBC leaves a palette alone",
1)[1], 1)
check("and has no present pass", GbcPalette.presentColors(), nil)
local gbcfxIndex
for i, row in ipairs(OptionsMenu.ROWS) do
if row.label == "GBC FX" then gbcfxIndex = i end
end
check("VIDEO MODE follows GBC FX", OptionsMenu.ROWS[gbcfxIndex + 1].label,
"VIDEO MODE")
check("and TOUCH PAD follows it", OptionsMenu.ROWS[gbcfxIndex + 2].label,
"TOUCH PAD")
local videoRow = select(2, rowNamed("VIDEO MODE"))
check("VIDEO MODE is a row", videoRow ~= nil, true)
local videoBuilt
for _, row in ipairs(scrollOptions.rows) do
if row.key == "videoMode" then videoBuilt = row end
end
check("buildRows gives it id videoMode", videoBuilt and videoBuilt.id,
"videoMode")
check("default video mode is windowed", Save.DEFAULT_OPTIONS.videoMode,
"windowed")
scrollOptions.options.videoMode = "windowed"
scrollOptions:cycle(videoRow, 1)
check("right stores borderless, not the display string",
scrollOptions.options.videoMode, "borderless")
scrollOptions:cycle(videoRow, 1)
check("right again stores windowed",
scrollOptions.options.videoMode, "windowed")
scrollOptions:cycle(videoRow, -1)
check("left also toggles, stored as borderless",
scrollOptions.options.videoMode, "borderless")
scrollOptions:cycle(videoRow, -1)
check("left toggles back to windowed",
scrollOptions.options.videoMode, "windowed")
check("text reads WINDOWED", videoRow.text(scrollOptions.options), "WINDOWED")
scrollOptions.options.videoMode = "borderless"
check("text reads FULL, not BORDERLESS", videoRow.text(scrollOptions.options),
"FULL")
for _, mode in ipairs({ "windowed", "borderless" }) do
scrollOptions.options.videoMode = mode
local text = videoRow.text(scrollOptions.options)
check("value fits the 8-char column: " .. mode, #text <= 8, true)
end
-- ------------------------------------------------------------- name picker
--
-- NameMenuHeader's coordinates, which is the whole point of this screen: the
+253
View File
@@ -0,0 +1,253 @@
-- Regression coverage for #1345 (BattleState:sgbBattlePals bar bypassing
-- PaletteFX.pal), #1346 (SummaryMenu double-tinting the HP fill) and #1340
-- (animSpriteColors hardcoding the SGB anim model). engine/gfx/palettes.asm
-- SetPal_Battle, engine/pokemon/status_screen.asm:120-125,
-- engine/battle/animations.asm:551-578.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity hpcolor og palette")
local check, eq, same = S.check, S.eq, S.same
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
local Font = require("src.render.Font")
Font.load(Data)
local PaletteFX = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local BattleState = require("src.battle.BattleState")
local HudTiles = require("src.render.HudTiles")
local SummaryMenu = require("src.ui.SummaryMenu")
local Pokemon = require("src.pokemon.Pokemon")
local Sound = require("src.core.Sound")
Sound.playCry = function() end
local prevVersion, prevMode = GameVersion.get(), PaletteFX.mode
local function setup(version, mode)
GameVersion.set(version)
PaletteFX.setMode(mode)
end
local function colorEq(got, want, msg)
local ok = got and want and got[1] == want[1] and got[2] == want[2]
and got[3] == want[3]
return check(ok, ("%s (got %s, want %s)"):format(msg,
got and ("(%d,%d,%d)"):format(got[1], got[2], got[3]) or "nil",
want and ("(%d,%d,%d)"):format(want[1], want[2], want[3]) or "nil"))
end
local function bucket(r)
if r > 0.83 then return 1 elseif r > 0.5 then return 2
elseif r > 0.17 then return 3 else return 4 end
end
-- ------------------------------------------------------- #1345 bar() routes
-- through PaletteFX.pal instead of the raw SGB pack
local ZONE0 = {
{ "red", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "blue", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "yellow", "gbc", { 255, 239, 255 }, { 247, 214, 123 }, { 74, 165, 90 }, { 25, 16, 16 } },
{ "red", "ogred", { 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } },
{ "blue", "ogred", { 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 } },
{ "yellow", "ogred", { 255, 255, 255 }, { 255, 255, 0 }, { 0, 255, 0 }, { 25, 25, 25 } },
}
local function fullHpBattle()
return {
data = Data,
player = { mon = { hp = 20, stats = { hp = 20 } } },
enemy = { mon = { hp = 20, stats = { hp = 20 } } },
}
end
for _, c in ipairs(ZONE0) do
local version, mode = c[1], c[2]
setup(version, mode)
local pals = BattleState.sgbBattlePals(fullHpBattle())
check(pals ~= nil, ("%s/%s sgbBattlePals returns four zones"):format(version, mode))
local zone0 = pals and pals[0]
colorEq(zone0 and zone0[1], c[3], ("%s/%s zone0 color0"):format(version, mode))
colorEq(zone0 and zone0[2], c[4], ("%s/%s zone0 color1 (GetHealthBarColor)"):format(version, mode))
colorEq(zone0 and zone0[3], c[5], ("%s/%s zone0 color2 (bar fill)"):format(version, mode))
colorEq(zone0 and zone0[4], c[6], ("%s/%s zone0 color3"):format(version, mode))
end
-- --------------------------------------------- #1345 placeholder branch lock
-- OG RED / OG BLUE: zones 2/3 must land on the version's boot-ROM BG
-- endpoints regardless of placeholder, never the raw SGB pack's paper/ink.
for _, version in ipairs({ "red", "blue" }) do
setup(version, "ogred")
local white = PaletteFX.ogBg()[1]
local black = PaletteFX.ogBg()[4]
for _, ph in ipairs({ false, true }) do
local fake = fullHpBattle()
fake.showPlayerBack, fake.showEnemyTrainer = ph, ph
local pals = BattleState.sgbBattlePals(fake)
for _, z in ipairs({ 2, 3 }) do
local zone = pals[z]
colorEq(zone[1], white, ("%s/ogred placeholder=%s zone%d color0 is boot-ROM white")
:format(version, tostring(ph), z))
colorEq(zone[4], black, ("%s/ogred placeholder=%s zone%d color3 is boot-ROM black")
:format(version, tostring(ph), z))
check(not (zone[1][1] == 255 and zone[1][2] == 239 and zone[1][3] == 255),
("%s/ogred placeholder=%s zone%d color0 is not the raw SGB paper")
:format(version, tostring(ph), z))
check(not (zone[4][1] == 25 and zone[4][2] == 16 and zone[4][3] == 16),
("%s/ogred placeholder=%s zone%d color3 is not the raw SGB ink")
:format(version, tostring(ph), z))
end
end
setup(version, "ogred")
local off = BattleState.sgbBattlePals(fullHpBattle())
local fakeOn = fullHpBattle()
fakeOn.showPlayerBack, fakeOn.showEnemyTrainer = true, true
local on = BattleState.sgbBattlePals(fakeOn)
for _, z in ipairs({ 2, 3 }) do
same(off[z], on[z], ("%s/ogred zone%d is byte-identical placeholder or not")
:format(version, z))
end
end
-- Yellow keeps the raw MEWMON pack in both gbc and ogred (usesYellowCgb
-- guards both call sites), so placeholder must be byte-identical across modes.
do
setup("yellow", "gbc")
local fakeGbc = fullHpBattle()
fakeGbc.showPlayerBack, fakeGbc.showEnemyTrainer = true, true
local gbcPals = BattleState.sgbBattlePals(fakeGbc)
setup("yellow", "ogred")
local fakeOgred = fullHpBattle()
fakeOgred.showPlayerBack, fakeOgred.showEnemyTrainer = true, true
local ogredPals = BattleState.sgbBattlePals(fakeOgred)
for _, z in ipairs({ 2, 3 }) do
same(ogredPals[z], gbcPals[z],
("yellow/ogred placeholder zone%d is byte-identical to yellow/gbc"):format(z))
end
end
-- ------------------------------------------------------- #1345 blackout lock
-- OG RED / OG BLUE blacked-out zones must be the boot-ROM white/black, not
-- the raw PAL_BLACK pack. Yellow keeps the raw pack in both modes.
for _, version in ipairs({ "red", "blue" }) do
setup(version, "ogred")
local white = PaletteFX.ogBg()[1]
local black = PaletteFX.ogBg()[4]
local fake = fullHpBattle()
fake.blackedOut = true
local pals = BattleState.sgbBattlePals(fake)
check(pals ~= nil, ("%s/ogred blackout still returns four zones"):format(version))
for z = 0, 3 do
colorEq(pals[z][1], white, ("%s/ogred blackout zone%d color0 is boot-ROM white"):format(version, z))
colorEq(pals[z][4], black, ("%s/ogred blackout zone%d color3 is boot-ROM black"):format(version, z))
check(not (pals[z][1][1] == 255 and pals[z][1][2] == 239 and pals[z][1][3] == 255),
("%s/ogred blackout zone%d color0 is not the raw PAL_BLACK paper"):format(version, z))
end
end
do
setup("yellow", "gbc")
local gbcOut = BattleState.sgbBattlePals({ data = Data, blackedOut = true,
player = { mon = {} }, enemy = { mon = {} } })
setup("yellow", "ogred")
local ogredOut = BattleState.sgbBattlePals({ data = Data, blackedOut = true,
player = { mon = {} }, enemy = { mon = {} } })
same(ogredOut[0], gbcOut[0], "yellow/ogred blackout is byte-identical to yellow/gbc")
end
-- ------------------------------------------------------- #1346 grayFill guard
-- SummaryMenu:draw must pass grayFill behind the same barZoned guard
-- PartyMenu already used, or the fill double-applies through the zone pass.
local SUMMARY = {
{ "blue", "ogred", { 0, 0, 0 }, { 0, 0, 255 } },
{ "red", "gbc", { 25, 16, 16 }, { 74, 165, 90 } },
{ "red", "redpp", { 0, 0, 0 }, { 0, 189, 0 } },
{ "yellow", "ogred", { 25, 25, 25 }, { 0, 255, 0 } },
{ "red", "ogred", { 148, 58, 58 }, { 148, 58, 58 } },
}
for _, c in ipairs(SUMMARY) do
local version, mode, wantOld, wantNew = c[1], c[2], c[3], c[4]
setup(version, mode)
local mon = Pokemon.new(Data, "BULBASAUR", 20)
mon.hp = mon.stats.hp
local game = { data = Data,
save = { options = {}, player = { id = 1, name = "RED" } } }
local menu = SummaryMenu.new(game, mon)
local realBar = HudTiles.drawHPBar
local realShader = PaletteFX.shader
local seenGrayFill
HudTiles.drawHPBar = function(_, _, _, _, _, grayFill)
seenGrayFill = grayFill and true or false
end
PaletteFX.shader = function() return { send = function() end } end
local ok, err = pcall(function() menu:draw() end)
HudTiles.drawHPBar, PaletteFX.shader = realBar, realShader
check(ok, ("%s/%s SummaryMenu:draw runs headless%s"):format(version, mode,
ok and "" or (": " .. tostring(err))))
check(seenGrayFill == true,
("%s/%s SummaryMenu:draw asks for a gray fill when a zone pass will run")
:format(version, mode))
local pal = PaletteFX.pal(Data, "GREENBAR")
check(pal ~= nil, ("%s/%s GREENBAR resolves"):format(version, mode))
local predicted
if seenGrayFill then
predicted = pal[bucket(85 / 255)]
else
local tintR = math.min(1, pal[3][1] / 170)
predicted = pal[bucket((85 / 255) * tintR)]
end
colorEq(predicted, wantNew,
("%s/%s summary HP fill matches the fixed color"):format(version, mode))
local oldPredicted = pal[bucket((85 / 255) * math.min(1, pal[3][1] / 170))]
colorEq(oldPredicted, wantOld,
("%s/%s summary HP fill's pre-fix formula reproduces the reported color")
:format(version, mode))
end
-- ---------------------------------------------------- #1340 animSpriteColors
local ANIM = {
{ "red", "ogred", { 123, 255, 49 }, { 0, 132, 0 }, { 0, 0, 0 } },
{ "blue", "ogred", { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 } },
{ "red", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "blue", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "yellow", "gbc", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
{ "yellow", "ogred", { 255, 239, 255 }, { 25, 16, 16 }, { 25, 16, 16 } },
}
for _, c in ipairs(ANIM) do
local version, mode = c[1], c[2]
setup(version, mode)
local zone = PaletteFX.pack(Data).palettes.GREENBAR
local fake = { data = Data, zoneColorsAt = function() return zone end }
local s = { obp = "f0", x = 72, y = 80 }
local out = BattleState.animSpriteColors(fake, s, 64, 64)
check(out ~= nil, ("%s/%s animSpriteColors returns a triple"):format(version, mode))
local rounded = {}
for i = 1, 3 do
rounded[i] = out and {
math.floor(out[i][1] * 255 + 0.5),
math.floor(out[i][2] * 255 + 0.5),
math.floor(out[i][3] * 255 + 0.5),
} or nil
end
colorEq(rounded[1], c[3], ("%s/%s animSpriteColors f0 shade0"):format(version, mode))
colorEq(rounded[2], c[4], ("%s/%s animSpriteColors f0 shade1"):format(version, mode))
colorEq(rounded[3], c[5], ("%s/%s animSpriteColors f0 shade2"):format(version, mode))
end
GameVersion.set("red")
eq(PaletteFX.usesSpriteObp("ogred"), true, "OG RED uses the boot-ROM OBJ ramp for anims")
GameVersion.set("yellow")
eq(PaletteFX.usesSpriteObp("ogred"), false, "OG YELLOW keeps the SGB anim model")
GameVersion.set(prevVersion)
PaletteFX.setMode(prevMode)
S.finish()