CLOSES #1211, CLOSES #1228, CLOSES #1229, CLOSES #1232, CLOSES #1251, CLOSES #1265, CLOSES #1267, CLOSES #1276, CLOSES #1279, CLOSES #1282, CLOSES #1293, CLOSES #1296, CLOSES #1303, CLOSES #1329, CLOSES #1338, CLOSES #1341, CLOSES #1343, CLOSES #1344, CLOSES #1368, CLOSES #1385, CLOSES #1388, CLOSES #1389, CLOSES #1391

This commit is contained in:
bryanthaboi
2026-08-16 08:55:40 -04:00
parent 65128e13a4
commit 1151c188a7
50 changed files with 2835 additions and 276 deletions
@@ -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,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,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,94 @@
-- engine/battle/effect_commands.asm:5458 BattleCommand_Charge
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
GROUND = { id = "GROUND", index = 1, category = "physical" },
FLYING = { id = "FLYING", index = 2, category = "physical" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
DIG = { id = "DIG", name = "DIG", power = 60, type = "GROUND",
accuracy = 100, pp = 10, effect = "EFFECT_FLY" },
FLY = { id = "FLY", name = "FLY", power = 70, type = "FLYING",
accuracy = 95, pp = 15, effect = "EFFECT_FLY" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function highRoll(n) return (n or 1) - 1 end
local function newBattle(moveId)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = { { id = moveId, pp = 20, maxPp = 20 } }
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = highRoll }), player, wild
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
do
local battle, player, wild = newBattle("DIG")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev = moveEvent(battle.events)
T.check(ev ~= nil, "the charge turn queues a move event")
T.eq(ev and ev.animParam, 1,
"DIG's charge (burrow) turn carries animParam 1, the take-cover script arm")
battle.events = {}
battle:useMove(player, wild, "DIG")
local ev2 = moveEvent(battle.events)
T.check(ev2 ~= nil, "the strike turn also queues a move event")
T.eq(ev2 and ev2.animParam, nil,
"DIG's strike turn leaves animParam nil, the hit script arm")
end
do
local battle, player, wild = newBattle("FLY")
battle.events = {}
battle:useMove(player, wild, "FLY")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, 1, "FLY's take-off turn also carries animParam 1")
end
-- a plain hit-and-run move never sets a parameter at all
do
local battle, player, wild = newBattle("DIG")
player.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
battle.events = {}
battle:useMove(player, wild, "TACKLE")
local ev = moveEvent(battle.events)
T.eq(ev and ev.animParam, nil, "a non-charge move never carries animParam")
end
T.finish("gen2 charge move anim param bug 1293")
+83
View File
@@ -0,0 +1,83 @@
-- #1251: the Game Corner's `CheckCoinsAndCoinCase` transcription must ask
-- the bag about the real COIN_CASE item id, not SILVER_WING.
-- constants/item_constants.asm:62 (COIN_CASE = $36); SILVER_WING is $47.
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Vm = require("src.script.gen2.Vm")
local Specials = require("src.script.gen2.Specials")
local Events = require("src.world.gen2.Events")
local COIN_CASE = 0x36
local SILVER_WING = 0x47
-- 1) the id the handler actually queries the bag with
local seenId
local vm = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 100 end,
hasItem = function(id) seenId = id return true end,
gameCornerGame = function(_, done) done() end,
},
})
vm.showTextFn = function() end
vm.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm) end)
coroutine.resume(vm.co)
T.eq(seenId, COIN_CASE,
"SlotMachine's CheckItem call carries the real COIN_CASE id ($36)")
T.check(seenId ~= SILVER_WING,
"and specifically not SILVER_WING ($47), the pre-fix value")
-- 2) a bag holding ONLY the real coin case (not the silver wing) must be
-- enough to open both machines: this is what would still fail if the id
-- above were merely logged and not actually used to gate the machine.
local bag = { [COIN_CASE] = true }
local slotsOpened, flipOpened
local vm2 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) slotsOpened = kind done() end,
},
})
vm2.showTextFn = function() end
vm2.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm2) end)
coroutine.resume(vm2.co)
T.eq(slotsOpened, "slots",
"a bag with the real COIN CASE and coins opens the slot machine")
local vm3 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return bag[id] == true end,
gameCornerGame = function(kind, done) flipOpened = kind done() end,
},
})
vm3.showTextFn = function() end
vm3.co = coroutine.create(function() Specials.HANDLERS.CardFlip(vm3) end)
coroutine.resume(vm3.co)
T.eq(flipOpened, "cardflip",
"and the same bag opens card flip too")
-- 3) the mirror case: SILVER_WING in the bag, no coin case, must still
-- refuse with _NoCoinCaseText (this is the exact symptom in #1251, and it
-- is the yield the coroutine parks on, not a call, so opening never runs
-- behind it).
local wrongBag = { [SILVER_WING] = true }
local opened = false
local vm4 = Vm.new({ generation = 2 }, {}, Events.new(), {
specials = {
coins = function() return 50 end,
hasItem = function(id) return wrongBag[id] == true end,
gameCornerGame = function() opened = true end,
},
})
vm4.showTextFn = function() end
vm4.co = coroutine.create(function() Specials.HANDLERS.SlotMachine(vm4) end)
local _, refusal = coroutine.resume(vm4.co)
T.eq(refusal and refusal.text, "You don't have a\nCOIN CASE.",
"holding only SILVER_WING gets the real _NoCoinCaseText refusal")
T.check(not opened, "and the machine never opens behind it")
T.finish("gen2_coin_case_bug1251")
@@ -0,0 +1,166 @@
-- engine/battle/effect_commands.asm:1958-1961 (the 40 frame hold),
-- engine/battle/effect_commands.asm:3615 (.CheckAIRandomFail, the 25% roll)
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local UI = require("src.ui.gen2.BattleState")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
GROWL = { id = "GROWL", name = "GROWL", power = 0, type = "NORMAL",
accuracy = 100, pp = 40, effect = "EFFECT_ATTACK_DOWN" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
-- a controllable roll queue; falls back to a high roll (never fails an AI
-- check) once drained
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ---------------------------------------------------------------- gap 2:
-- the AI's 25% "miss" on a support move, and who is exempt from the roll.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 10 } -- accuracy roll, then the AI roll (10 < 64: fails)
battle:useMove(wild, player, "GROWL")
T.check(findText(battle.events, "But it failed!"),
"enemy GROWL fails 25% of the time with the specific line")
T.eq(moveEvent(battle.events) and moveEvent(battle.events).missed, true,
"an AI-failed move is marked missed (feeds the 40 frame hold)")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "GROWL", pp = 40, maxPp = 40 } })
battle.events = {}
rolls = { 0, 200 } -- AI roll passes (>=64): lands
battle:useMove(wild, player, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the same move lands when the AI roll passes")
end
do
local battle, player, wild = newBattle(
{ { id = "GROWL", pp = 40, maxPp = 40 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
rolls = { 0, 10 } -- if the player rolled too, 10 would fail it
battle:useMove(player, wild, "GROWL")
T.check(not findText(battle.events, "But it failed!"),
"the player's own GROWL is exempt from the AI roll")
end
-- --------------------------------------------------------------- gap 1:
-- the reported symptom -- the "used X!" line must hold before a failure.
do
local input = { wasPressed = function() return false end }
local ui = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = {
{ kind = "move", side = "enemy", move = "GROWL",
text = "Enemy MACHOP used GROWL!", missed = true },
{ kind = "message", text = "But it failed!" },
},
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
}, { __index = UI })
ui:advanceQueue()
T.eq(ui.message, "Enemy MACHOP used GROWL!", "the used-move line is shown first")
T.eq(ui.messageDelay, 40,
"a missed move arms the 40 frame delay (effect_commands.asm's MoveDelay)")
T.eq(ui.messageTimer, 0, "no separate A/B hold on the move line itself")
local frames = 0
for _ = 1, 100 do
if ui.message == "But it failed!" then break end
ui:update(1 / 60)
frames = frames + 1
end
T.eq(ui.message, "But it failed!", "the queue eventually reaches the failure line")
T.eq(frames, 41, "the used line held for exactly the 40 delay frames")
T.check(ui.messageTimer > 0, "the failure line itself still holds for A/B")
end
do
local input = { wasPressed = function() return false end }
local ui2 = setmetatable({
game = { input = input },
phase = "resolving", slideFrame = 999, messageTimer = 0,
picHidden = { player = false, enemy = false },
queue = { { kind = "move", side = "player", move = "TACKLE",
text = "MACHOP used TACKLE!" } },
updateAlarm = function() end,
stepHpAnim = function() return false end,
stepExpAnim = function() return false end,
animForMove = function() return false end,
}, { __index = UI })
ui2:advanceQueue()
T.eq(ui2.messageDelay or 0, 0, "a move that lands arms no delay at all")
end
T.finish("gen2 enemy move fail text bug 1296")
@@ -0,0 +1,92 @@
-- The fishgroup bite roll, missing entirely before #1368: .Fish rolls the
-- group's OWN chance byte before the rod's cumulative list even runs
-- (engine/events/fish.asm:24-30), so every rod bites at whatever that byte
-- says (vanilla Gold is 50 percent + 1 for every group, not 2/3 or 1/2 by
-- rod). A cache built before the extractor carried the byte has no
-- `chance` field on the group row at all and must keep fishing unconditionally.
-- luajit tests/engine/gen2_fishing_bite_gate_bug1368.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
MAGIKARP = {
name = "MAGIKARP", types = { "WATER", "WATER" },
baseStats = { hp = 20, attack = 10, defense = 55, speed = 80,
specialAttack = 15, specialDefense = 20 },
levelMoves = {},
},
},
}
local COLL_FLOOR, COLL_WATER = 0x00, 0x29
local function fakeMap(waterCell)
return {
id = "TEST_MAP",
def = { fishGroup = "FISHGROUP_POND" },
cellCollision = function(_, x, y)
return (x == waterCell[1] and y == waterCell[2])
and COLL_WATER or COLL_FLOOR
end,
}
end
-- Rod lists that always hand back a species once a bite happens, so only the
-- group gate (or its absence) decides the outcome below.
local function fishGroups(chance)
return {
FISHGROUP_POND = {
chance = chance,
old = { { chance = 256, species = "MAGIKARP", level = 10 } },
good = { { chance = 256, species = "MAGIKARP", level = 20 } },
super = { { chance = 256, species = "MAGIKARP", level = 40 } },
},
}
end
local function fakeWorld(chance)
local game = { data = DATA, save = { party = {} } }
local world = World.new(game)
world.map = fakeMap({ 5, 4 })
world.maps = { TEST_MAP = world.map.def }
world.encounters = { fishGroups = fishGroups(chance) }
world.player = { cellX = 5, cellY = 5, facing = "up" }
return world
end
-- ---- chance 0: the group byte fails Random every time, always a nibble ---
-- engine/events/fish.asm:24-30
do
local world = fakeWorld(0)
for rod = 1, 3 do
local outcome = world:rollFishing(({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
eq(outcome, "nibble",
"chance 0 nibbles on " .. ({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" })[rod])
end
end
-- ---- chance 256: the group byte always passes, every rod finds the mon ---
do
local world = fakeWorld(256)
for _, rod in ipairs({ "OLD_ROD", "GOOD_ROD", "SUPER_ROD" }) do
local outcome, wild = world:rollFishing(rod)
eq(outcome, "battle", "chance 256 always bites on " .. rod)
check(wild and wild.species == "MAGIKARP",
"and the rod's own list still resolves a species")
end
end
-- ---- no chance field at all: an old cache keeps fishing unconditionally --
do
local world = fakeWorld(nil)
local outcome = world:rollFishing("OLD_ROD")
eq(outcome, "battle",
"a cache with no group chance byte is not gated at all")
end
T.finish("gen2 fishing bite gate bug1368")
@@ -0,0 +1,115 @@
-- Grass encounters must key off the CLOCK (wTimeOfDay), never the palette
-- set a map header pins (wTimeOfDayPal): engine/overworld/wildmons.asm:283
-- reads wTimeOfDay for both the rate (GetMapEncounterRate) and the slot list
-- (ChooseWildEncounter). A PALETTE_DAY tower like Sprout Tower must still
-- roll its night table after dark (#1389, Gastly unobtainable), and a
-- PALETTE_NITE cave must still roll its morning/day table at noon.
-- luajit tests/engine/gen2_grass_encounter_tod_bug1389.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local World = require("src.world.gen2.World")
local DATA = {
pokemon = {
RATTATA = {
name = "RATTATA", types = { "NORMAL", "NORMAL" },
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
levelMoves = {},
},
GASTLY = {
name = "GASTLY", types = { "GHOST", "POISON" },
baseStats = { hp = 30, attack = 35, defense = 30, speed = 80,
specialAttack = 100, specialDefense = 35 },
levelMoves = {},
},
},
}
local function fullList(species)
local slots = {}
for i = 1, 7 do slots[i] = { species = species, level = 10 } end
return slots
end
-- data/wild/johto_grass.asm's own shape for a pinned tower: day is common
-- and worthless, night is the whole reason the room exists. A second,
-- separate map stands in for a PALETTE_NITE dungeon (Ilex Forest, Mt Moon):
-- the rates are flipped so the two fixtures cannot agree by accident.
local ENCOUNTERS = {
grass = {
SPROUT_TOWER_2F = {
rates = { MORN = 0, DAY = 0, NITE = 256 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
ILEX_FOREST = {
rates = { MORN = 256, DAY = 256, NITE = 0 },
slots = {
MORN = fullList("RATTATA"),
DAY = fullList("RATTATA"),
NITE = fullList("GASTLY"),
},
},
},
}
local COLL_FLOOR = 0x00
local function fakeWorld(mapId, tod, daytime)
local game = { data = DATA,
save = { party = { { species = "RATTATA", level = 5 } } } }
local world = World.new(game)
world.map = {
id = mapId,
def = { environment = "DUNGEON" },
cellCollision = function() return COLL_FLOOR end,
}
world.maps = { [mapId] = world.map.def }
world.encounters = ENCOUNTERS
world.player = { cellX = 5, cellY = 5, facing = "down" }
-- World:applyPalettes writes both fields every load; a PALETTE_DAY tower
-- pins `daytime` to DAY no matter the hour, while `tod` keeps tracking the
-- clock (src/world/gen2/World.lua:8271-8326).
world.tod = tod
world.daytime = daytime
local battled
world.startBattle = function(_, opts)
battled = opts.wild and opts.wild.species
return true
end
return world, function() return battled end
end
-- ---- PALETTE_DAY tower, at night: the clock says NITE, the pin says DAY --
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "NITE", "DAY")
check(world:tryWildEncounter(), "the tower rolls at night despite the pin")
eq(battled(), "GASTLY",
"the night list wins because the lookup is the clock, not the pin")
end
-- ---- the same tower at actual daytime: the clock and the pin now agree ---
do
local world, battled = fakeWorld("SPROUT_TOWER_2F", "DAY", "DAY")
check(not world:tryWildEncounter(),
"DAY's rate is zero, so a daytime step in the tower rolls nothing")
eq(battled(), nil, "and nothing battled")
end
-- ---- a PALETTE_NITE dungeon at actual noon: the pin says NITE, clock DAY --
do
local world, battled = fakeWorld("ILEX_FOREST", "DAY", "NITE")
check(world:tryWildEncounter(),
"a pinned-night map still rolls its day table at the clock's noon")
eq(battled(), "RATTATA",
"the day list wins because the lookup ignores the palette pin")
end
T.finish("gen2 grass encounter tod bug1389")
@@ -0,0 +1,88 @@
-- Gold #DEX AREA page drew no nest markers or landmark name because
-- PokedexMenu:drawArea read the non-existent self.data.landmarks instead of
-- the gen2Landmarks table Nests already resolves through (#1267).
-- engine/pokegear/pokegear.asm:2427
-- luajit tests/engine/gen2_pokedex_area_landmark_bug1267.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
local Nests = require("src.core.gen2.Nests")
-- one Johto landmark (index 5), one species that nests there
local data = {
gen2Encounters = {
grass = {
ROUTE_30 = { slots = { day = { { species = "RATTATA" } } } },
},
},
gen2Maps = {
ROUTE_30 = { landmark = 5 },
},
gen2Landmarks = {
landmarks = {
LANDMARK_ROUTE_30 = { index = 5, x = 40, y = 60, name = "ROUTE 30" },
},
},
}
-- sanity: Nests.landmark itself resolves the index (never broken, per the
-- verifier) so a failure below is isolated to drawArea's own lookup
eq(Nests.landmark(data, 5) and Nests.landmark(data, 5).name, "ROUTE 30",
"Nests.landmark resolves index 5 to the ROUTE 30 record")
-- capture what drawArea actually paints, without needing a real tile sheet
-- or font: fill/blank/current/monName are stubbed on the instance, which
-- Lua resolves before the PokedexMenu metatable's own methods.
local function newSelf()
local texts = {}
local rects = {}
local self = setmetatable({
game = { save = {} },
data = data,
mapGfx = { maps = { johto = { 1 } } }, -- non-nil `cells`, no real sheet
areaRegion = "johto",
areaBlink = 0, -- (0 % 32) < 20, so markers are in their "on" phase
current = function() return { species = "RATTATA" } end,
monName = function() return "RATTATA" end,
fill = function() end,
blank = function() end,
text = function(_, str, tx, ty)
texts[#texts + 1] = { str = str, tx = tx, ty = ty }
end,
}, { __index = PokedexMenu })
return self, texts, rects
end
local realRect = love.graphics.rectangle
local self, texts, rects
do
self, texts, rects = newSelf()
love.graphics.rectangle = function(mode, x, y, w, h)
rects[#rects + 1] = { mode = mode, x = x, y = y, w = w, h = h }
end
self:drawArea()
love.graphics.rectangle = realRect
end
local function hasRect(x, y)
for _, r in ipairs(rects) do
if r.x == x and r.y == y then return true end
end
return false
end
check(hasRect(40 - 2, 60 - 2), "the nest marker is drawn at the landmark's x-2,y-2")
local function hasText(str)
for _, t in ipairs(texts) do
if t.str == str then return true end
end
return false
end
check(hasText("ROUTE 30"), "the landmark name is printed on row 16")
T.finish("gen2 pokedex area landmark bug 1267")
@@ -0,0 +1,93 @@
-- GetMapMusic (pokegold home/map.asm:2550), #1385
-- luajit tests/engine/gen2_rocket_map_music_bug1385.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local Source = {}
Source.__index = Source
function Source:play() self.playing = true end
function Source:stop() self.playing = false end
function Source:pause() self.playing = false end
function Source:isPlaying() return self.playing end
function Source:setLooping() end
function Source:setVolume(v) self.volume = v end
function Source:setPitch() end
function Source:setFilter() end
function Source:getDuration() return 1 end
local made = {}
love.audio = {
newSource = function(file, mode)
made[file] = setmetatable({ file = file, mode = mode }, Source)
return made[file]
end,
}
local World = require("src.world.gen2.World")
local Music = require("src.core.Music")
-- constants/music_constants.asm:100,:109
local MUSIC_MAHOGANY_MART = 100
local RADIO_TOWER_SENTINEL = 0x80 + 61
local order = {}
order[61 + 1] = "Music_GoldenrodCity"
local audio = {
musicOrder = order,
songs = {
Music_RocketHideout = { file = "rocket_hideout.wav" },
Music_CherrygroveCity = { file = "cherrygrove.wav" },
Music_RocketTheme = { file = "rocket_theme.wav" },
Music_GoldenrodCity = { file = "goldenrod.wav" },
},
}
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, true, false),
"Music_RocketHideout",
"MAHOGANY_MART_1F with rockets in the mart plays the hideout theme")
eq(World.mapMusicLabel(audio, MUSIC_MAHOGANY_MART, false, false),
"Music_CherrygroveCity",
"MAHOGANY_MART_1F after the hideout is cleared plays Cherrygrove")
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, true),
"Music_RocketTheme",
"RADIO_TOWER floors during the takeover play the rocket theme")
eq(World.mapMusicLabel(audio, RADIO_TOWER_SENTINEL, false, false),
"Music_GoldenrodCity",
"RADIO_TOWER floors otherwise fall back to the low bits of the byte")
eq(World.mapMusicLabel(audio, 72, true, true), nil,
"a plain song id stays with the mapSongs table")
eq(World.mapMusicLabel(audio, nil, true, true), nil,
"a missing music byte resolves to nothing")
local data = { audio = {
songs = {
Music_RocketHideout = { file = "rocket_hideout.wav" },
Music_Victory = { file = "victory.wav" },
},
mapSongs = {},
} }
local function playing()
for file, src in pairs(made) do
if src.playing then return file end
end
return "(silence)"
end
Music.stop()
Music.playMap(data, "MAHOGANY_MART_1F", false, false, nil,
"Music_RocketHideout")
eq(playing(), "rocket_hideout.wav",
"the resolved song overrides the empty mapSongs table")
Music.play(data, "Music_Victory", nil, { reason = "battle" })
eq(playing(), "victory.wav", "the battle result theme takes over")
Music.restoreMap(data)
eq(playing(), "rocket_hideout.wav",
"restoreMap replays the resolved song, ending the victory loop")
T.finish("gen2_rocket_map_music_bug1385")
+171
View File
@@ -0,0 +1,171 @@
-- engine/battle/move_effects/safeguard.asm:1, engine/battle/effect_commands.asm:6325
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local TYPES = {
NORMAL = { id = "NORMAL", index = 0, category = "physical" },
ELECTRIC = { id = "ELECTRIC", index = 1, category = "special" },
FIRE = { id = "FIRE", index = 2, category = "special" },
}
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
SAFEGUARD = { id = "SAFEGUARD", name = "SAFEGUARD", power = 0,
type = "NORMAL", accuracy = 100, pp = 25, effect = "EFFECT_SAFEGUARD" },
THUNDER_WAVE = { id = "THUNDER_WAVE", name = "THUNDER WAVE", power = 0,
type = "ELECTRIC", accuracy = 100, pp = 20, effect = "EFFECT_PARALYZE" },
SACRED_FIRE = { id = "SACRED_FIRE", name = "SACRED FIRE", power = 100,
type = "FIRE", accuracy = 95, pp = 5, effect = "EFFECT_SACRED_FIRE",
effectChance = 50 },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
MACHOP = { id = "MACHOP", index = 66, name = "MACHOP",
baseStats = { hp = 70, attack = 80, defense = 50, speed = 35,
specialAttack = 35, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 180, baseExp = 75,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 63,
levelMoves = {}, evolutions = {} },
}
local DATA = { pokemon = POKEMON, moves = MOVES,
type_chart = { types = TYPES, matchups = {} }, items = {} }
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local rolls
local function rng(n)
if rolls and #rolls > 0 then return table.remove(rolls, 1) % math.max(1, n) end
return (n or 1) - 1
end
local function newBattle(pmoves, emoves)
local player = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
player.moves = pmoves
local wild = Mon.new(DATA, "MACHOP", 50, { dvs = perfect })
wild.moves = emoves
return Battle.new({ data = DATA, party = { player }, wild = wild,
random = rng }), player, wild
end
local function findText(events, sub)
for _, e in ipairs(events or {}) do
if e.kind == "message" and e.text and e.text:find(sub, 1, true) then
return true
end
end
return false
end
local function moveEvent(events)
for _, e in ipairs(events or {}) do
if e.kind == "move" then return e end
end
end
-- ------------------------------------------------------------- 1388a
-- Safeguard sets the USER's own side, not the target's.
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
T.eq(battle.screens.player.safeguard, 5, "Safeguard sets the CASTER's side for 5 turns")
T.check((battle.screens.enemy.safeguard or 0) == 0,
"and never touches the opposing side")
T.check(findText(battle.events, "covered by a veil"), "the veil line is emitted")
end
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
battle.events = {}
rolls = { 200 } -- force the enemy's AI roll not to fail on its own
battle:useMove(wild, player, "THUNDER_WAVE")
T.check(findText(battle.events, "protected by SAFEGUARD"),
"an incoming status move from the OTHER side is blocked, loudly")
T.eq(player.status, nil, "and the paralysis never lands")
local ev = moveEvent(battle.events)
T.eq(ev and ev.missed, true, "the blocked move is marked missed")
end
do
local battle, player, wild = newBattle(
{ { id = "SAFEGUARD", pp = 25, maxPp = 25 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
battle.events = {}
battle:useMove(player, wild, "SAFEGUARD")
T.check(findText(battle.events, "But it failed!"),
"using it again while it is already up simply fails")
end
do
-- the player's OWN status move against a safeguarded enemy is blocked too:
-- the effect reads whichever side is being TARGETED, not just "the enemy".
local battle, player, wild = newBattle(
{ { id = "THUNDER_WAVE", pp = 20, maxPp = 20 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.screens.enemy.safeguard = 5
battle.events = {}
battle:useMove(player, wild, "THUNDER_WAVE")
T.check(findText(battle.events, "protected by SAFEGUARD"),
"the player's status move is blocked by the enemy's own safeguard")
T.eq(wild.status, nil, "the enemy stays unstatused under its own screen")
end
do
local battle = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "TACKLE", pp = 35, maxPp = 35 } })
battle.screens.player.safeguard = 1
battle.events = {}
battle:tickScreens()
T.check(findText(battle.events, "SAFEGUARD faded"), "it fades after its five turns")
T.eq(battle.screens.player.safeguard, nil, "and clears off the side entirely")
end
-- ------------------------------------------------------------- 1388b
-- Sacred Fire's burn was never implemented at all.
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
battle.events = {}
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 } -- everything rolls low: hit, no
-- crit, and the 50% secondary effect chance all pass
battle:useMove(wild, player, "SACRED_FIRE")
T.eq(player.status, "burn", "Sacred Fire can now burn its target")
end
do
local battle, player, wild = newBattle(
{ { id = "TACKLE", pp = 35, maxPp = 35 } },
{ { id = "SACRED_FIRE", pp = 5, maxPp = 5 } })
battle.screens.player.safeguard = 5
battle.events = {}
rolls = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }
battle:useMove(wild, player, "SACRED_FIRE")
T.eq(player.status, nil, "Safeguard blocks the burn a damaging move carries")
T.check(not findText(battle.events, "protected by SAFEGUARD"),
"the secondary block is silent (SafeCheckSafeguard, not CheckSafeguard)")
local ev = moveEvent(battle.events)
T.check(ev and not ev.missed,
"the hit itself still lands and is not marked missed")
end
T.finish("gen2 safeguard bug 1388")
@@ -0,0 +1,47 @@
-- 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
T.finish("gen2 shadow ball bgp 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")
+94
View File
@@ -0,0 +1,94 @@
-- PKMN LEAGUE (the post-E4 Hall of Fame viewer) never had a screen backing
-- it, so a PC row wired up to open it would have had nowhere to go (#1282).
-- src/ui/LeaguePC.lua is the missing viewer; it resolves through the
-- registry's builtin fallback with no id table edit needed, because every
-- unregistered id falls through to `require("src.ui." .. id)`.
-- engine/menus/league_pc.asm:1, constants/pokemon_data_constants.asm:65 (cap 50)
-- luajit tests/engine/league_pc_bug1282.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Screens = require("src.ui.Screens")
local LeaguePC = require("src.ui.LeaguePC")
local function team(species, level)
return { { species = species, level = level, nickname = nil } }
end
local function newGame(teamCount)
local teams = {}
for i = 1, teamCount do teams[i] = team("RATTATA", i) end
local popped = false
local game = {
data = { pokemon = {}, text = {} },
save = { hallOfFame = teams },
stack = { pop = function() popped = true end },
}
return game, function() return popped end
end
-- the registry needs no LeaguePC id entry: an unregistered id falls
-- through resolve()'s builtin path straight to src/ui/LeaguePC.lua
local factory = Screens.get({ data = {} }, "LeaguePC")
check(factory == LeaguePC, "Screens.get(\"LeaguePC\") resolves to this module")
check(type(factory.new) == "function", "the resolved factory is constructible")
-- 60 recorded teams: HOF_TEAM_CAPACITY (50) means the oldest 10 are gone,
-- so the viewer opens on the OLDEST STILL-RECORDED team, index 11
do
local game = newGame(60)
local pc = LeaguePC.new(game)
eq(pc.teamIndex, 11, "60 teams over a cap of 50 starts at team 11 (60-50+1)")
eq(pc.monIndex, 1, "starts on the first mon of that team")
check(pc:currentMon() ~= nil, "a current mon is resolved")
end
-- A steps through every remaining team (49 more presses, 11 -> 60), then
-- one more A on the last team's only mon closes the whole viewer
do
local game, wasPopped = newGame(60)
local pc = LeaguePC.new(game)
local doneCalled = false
pc.onDone = function() doneCalled = true end
for _ = 1, 49 do
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
end
eq(pc.teamIndex, 60, "49 A-presses walk from team 11 to team 60")
check(not wasPopped(), "the viewer is still open on the last team")
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
check(wasPopped(), "one more A on the last team's last mon closes the viewer")
check(doneCalled, "onDone fires on close")
end
-- B always closes immediately, from any position
do
local game, wasPopped = newGame(3)
local pc = LeaguePC.new(game)
game.input = { wasPressed = function(_, b) return b == "b" end }
pc:update(0)
check(wasPopped(), "B closes the viewer")
end
-- an empty Hall of Fame (no wins recorded yet, or the extreme edge case of
-- a save with the row reachable but no completed run) must not crash: A on
-- a nil current mon closes cleanly instead of indexing into nothing
do
local game, wasPopped = newGame(0)
local pc = LeaguePC.new(game)
eq(pc.teamIndex, 1, "an empty roster clamps teamIndex to 1, not 0 or negative")
check(pc:currentMon() == nil, "there is no current mon")
local ok = pcall(function()
game.input = { wasPressed = function(_, b) return b == "a" end }
pc:update(0)
end)
check(ok, "A on an empty Hall of Fame does not raise")
check(wasPopped(), "...and closes the viewer instead")
end
T.finish("league pc bug 1282")
@@ -0,0 +1,86 @@
-- The NEW NAME / preset box drew on top of a full letter grid because
-- NamingScreen stayed isOpaque while the preset Menu was up, so the stack's
-- visibleBase never fell through to the screen underneath (#1329).
-- engine/movie/oak_speech/oak_speech2.asm:1
-- luajit tests/engine/naming_screen_opacity_bug1329.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
package.loaded["src.core.Sound"] = { play = function() end }
local StateStack = require("src.core.StateStack")
local NamingScreen = require("src.ui.NamingScreen")
local function newGame()
local stack = setmetatable({}, { __index = StateStack })
stack:init()
local game = { data = {} }
game.stack = stack
game.input = {
queue = {},
wasPressed = function(self, btn) return self.queue[btn] or false end,
isDown = function() return false end,
}
return game, stack
end
local game, stack = newGame()
-- stand-in for OakSpeech: opaque, and the state a fixed background lives on
local backgroundDraws = 0
local background = { isOpaque = true, draw = function() backgroundDraws = backgroundDraws + 1 end }
stack:push(background)
local result = { name = nil }
local ns = NamingScreen.new(game,
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result.name = n end })
stack:push(ns) -- StateStack:push calls ns:enter(), which pushes the preset Menu
eq(ns.choosing, true, "the screen marks itself as choosing a preset")
eq(ns.isOpaque, false, "isOpaque is shadowed false while the preset box is up")
local menu = stack:top()
check(menu ~= nil and menu ~= ns, "the preset Menu is on top of the naming screen")
eq(#menu.items, 4, "NEW NAME plus the three presets")
eq(menu.items[1].label, "NEW NAME", "row 1 is NEW NAME")
eq(stack:visibleBase(), 1, "the background (index 1) is visible, not the naming screen")
backgroundDraws = 0
stack:draw()
eq(backgroundDraws, 1, "the background actually got a draw call this frame")
-- picking NEW NAME (row 1) restores the grid's normal opacity
menu.index = 1
menu.game.input.queue.a = true
menu:update(0)
menu.game.input.queue.a = false
check(stack:top() == ns, "the Menu popped itself, the naming screen is back on top")
eq(ns.choosing, nil, "choosing cleared")
eq(rawget(ns, "isOpaque"), nil, "the instance field is cleared, unshadowing the class default")
eq(ns.isOpaque, true, "isOpaque now reads true again, through the class default")
eq(stack:visibleBase(), 2, "the naming screen itself is now the opaque base")
-- a preset pick instead closes the whole naming flow with that name
local game2, stack2 = newGame()
local background2 = { isOpaque = true, draw = function() end }
stack2:push(background2)
local result2 = { name = nil }
local ns2 = NamingScreen.new(game2,
{ presets = { "RED", "ASH", "JACK" }, onDone = function(n) result2.name = n end })
stack2:push(ns2)
local menu2 = stack2:top()
eq(menu2.items[4].label, "JACK", "row 4 is the third preset")
menu2.index = 4 -- "JACK"
menu2.game.input.queue.a = true
menu2:update(0)
eq(result2.name, "JACK", "selecting a preset pops the whole flow with that name")
eq(#stack2.states, 1, "only the background remains on the stack")
T.finish("naming screen opacity bug 1329")
+96
View File
@@ -0,0 +1,96 @@
-- The walking-NPC animation cadence, which the port ran at half the cart's
-- rate (#1303). UpdateSpriteInWalkingAnimation advances one animation frame
-- every 4 fixed steps regardless of how long the whole cell takes
-- (engine/overworld/movement.asm:301), so a 32-frame NPC cell must show the
-- same two-pulse cadence Player:pose already shows across its own 16.
-- luajit tests/engine/npc_walk_cadence_bug1303.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local NPC = require("src.world.NPC")
local DATA = {
sprites = {
SPRITE_TEST_NPC = { image = "fixture_npc.png", frames = 6, walker = true },
},
}
local function newNpc()
return NPC.new(DATA, "TEST_MAP", {
index = 1, x = 5, y = 5, sprite = "SPRITE_TEST_NPC", range = "ANY_DIR",
movement = "STAY",
})
end
-- ---- the pure cadence: two walk pulses across one 32-frame NPC step ------
do
local npc = newNpc()
npc.moving = true
local phases = {}
for clock = 0, 31 do
npc.animClock = clock
phases[clock] = npc:walkPhase()
end
local risingEdges = 0
for clock = 1, 31 do
if phases[clock] == 1 and phases[clock - 1] == 0 then
risingEdges = risingEdges + 1
end
end
eq(risingEdges, 2, "a 32-frame NPC cell shows two walk pulses, not one")
eq(phases[0], 0, "frame 0 stands")
eq(phases[4], 1, "frame 4 opens the first walk pulse")
eq(phases[11], 1, "frame 11 is still the first pulse")
eq(phases[12], 0, "frame 12 closes it back to standing")
eq(phases[20], 1, "frame 20 opens the second walk pulse")
eq(phases[27], 1, "frame 27 is still the second pulse")
eq(phases[28], 0, "frame 28 closes the cycle back to standing")
end
-- ---- the flip half-cycle: one flip per 16-frame half, not per whole cell -
do
local npc = newNpc()
npc.moving = true
npc.animClock = 0
local _, _, _, _, _, flip0 = npc:pose()
npc.animClock = 15
local _, _, _, _, _, flip15 = npc:pose()
npc.animClock = 16
local _, _, _, _, _, flip16 = npc:pose()
npc.animClock = 31
local _, _, _, _, _, flip31 = npc:pose()
check(not flip0, "the first half of the cell is unflipped")
check(not flip15, "still unflipped just before frame 16")
check(flip16, "frame 16 flips, matching Player:pose's own half-cycle")
check(flip31, "and stays flipped through the second half")
end
-- ---- standing keeps the externally-written flip (Pikachu idle contract) --
do
local npc = newNpc()
npc.moving = false
npc.stepFlip = true
local _, _, _, _, phase, flip = npc:pose()
eq(phase, 0, "a standing NPC has no walk phase")
check(flip, "and pose() reads stepFlip back exactly, not the moving formula")
end
-- ---- the wiring: NPC:update advances animClock alongside progress -------
do
local npc = newNpc()
npc.facing = "down"
npc.moving = true
npc.targetX, npc.targetY = npc.cellX, npc.cellY + 1
local map = {}
for i = 1, 16 do
npc:update(map, {})
eq(npc.animClock, i, "animClock ticks once per update, step " .. i)
end
check(npc.moving, "still mid-cell at 16 of the 32 ticks")
end
T.finish("npc walk cadence bug1303")
+1 -1
View File
@@ -65,7 +65,7 @@ T.check(pushed[1].text:find(BYE, 1, true) == nil,
pushed[1].onDone()
T.eq(#pushed, 1, "the farewell waits for the bow")
T.eq(nurse.frameOverride, 3, "image index $1: the nurse bows")
T.eq(nurse.frameOverride, 1, "image index $14: the nurse bows")
T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause")
local hold = fakeSelf.emote or {}
T.eq(hold.npc, nurse, "the hold is anchored on the nurse")
@@ -0,0 +1,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,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,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")
+116
View File
@@ -0,0 +1,116 @@
-- The post-battle grace period, which the port never re-armed after an
-- UNSCRIPTED wild battle (#1229): World:tryWildEncounter's own
-- self:startBattle({ wild = wild }) carries no onDone, so no VM resume ever
-- ran the script's own `reloadmapafterbattle` (which is where the cart's
-- SetUpFiveStepWildEncounterCooldown lives, engine/overworld/events.asm:1158-
-- 1162), and the counter sat at zero for the very next step. What is
-- asserted here is the real World:startBattle -> onDone chain, same as
-- tests/gen2_canlose_test.lua exercises for the loss arm.
--
-- GOLD_CACHE=".../gold" luajit tests/gen2_wild_cooldown_bug1229_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 wild cooldown")
local check, eq = S.check, S.eq
local World = require("src.world.gen2.World")
local Mon = require("src.battle.gen2.Mon")
local Screens = require("src.ui.Screens")
local cache = os.getenv("GOLD_CACHE")
if not cache then
local home = os.getenv("HOME") or ""
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
end
local probe = io.open(cache .. "/data/generated/pokemon.lua", "r")
if not probe then
check(true, "gold cache absent (SKIP)")
S.finish()
return
end
probe:close()
local function loadLua(rel) return assert(loadfile(cache .. "/" .. rel))() end
local pokemon = loadLua("data/generated/pokemon.lua")
local moves = loadLua("data/generated/moves.lua")
-- The battle screen as a registry fake, same shape gen2_canlose_test uses:
-- the real World:startBattle pushes it and parks its onDone for the test.
-- Screens.get caches by id process-wide, so a suite dofile'd earlier in
-- tests/run_tests.lua can leave a stale "Gen2BattleState" behind.
Screens.invalidate()
local battleDone
local registry = {
Gen2BattleState = { new = function(_g, opts)
battleDone = opts.onDone
return { screenId = "Gen2BattleState" }
end },
}
local function makeStack()
local stack = { items = {} }
function stack:push(inst) self.items[#self.items + 1] = inst end
function stack:pop()
local top = self.items[#self.items]
self.items[#self.items] = nil
return top
end
return stack
end
local function makeWorld()
battleDone = nil
local game = {
data = { audio = {}, screens = registry, pokemon = pokemon, moves = moves },
-- No roamer state and no map at all: World:pushBattleTransition needs
-- self.map to push a wipe and finds none, so pushBattle() runs straight
-- away, exactly like a headless battle would. World:restoreMapMusic and
-- World:battleMusicContext both already guard a nil self.map.
save = { player = { name = "GOLD", money = 3000 }, party = {} },
stack = makeStack(),
}
local mon = Mon.new(game.data, "CYNDAQUIL", 5)
check(mon ~= nil, "the cache can build the player's starter")
game.save.party[1] = mon
local w = World.new(game)
return w, game
end
-- ---- the unscripted path itself: World:tryWildEncounter's own call -------
do
local w, game = makeWorld()
local wild = Mon.new(game.data, "MAGIKARP", 10)
check(wild ~= nil, "the cache can build the wild mon")
-- A grace period already partway spent, so a re-arm is the only way to
-- land back on 5.
w.wildCooldown = 2
check(w:startBattle({ wild = wild }),
"the unscripted wild path starts a battle")
check(battleDone ~= nil, "the battle screen is up")
battleDone("win")
eq(w.wildCooldown, 5,
"reloadmapafterbattle's SetUpFiveStepWildEncounterCooldown re-arms " ..
"the counter (events.asm:1158-1162), even with no script waiting")
end
-- ---- the counter itself, once re-armed: four blocked steps then a roll --
do
local w = makeWorld()
w.wildCooldown = 5
for step = 1, 4 do
check(w:wildCooldownStep(),
"step " .. step .. " of the grace period is still blocked")
end
check(not w:wildCooldownStep(), "the fifth step may roll")
end
-- ---- a battle NOTHING started re-arms nothing: only startBattle's onDone -
do
local w = makeWorld()
w.wildCooldown = 0
check(w.wildCooldown == 0, "a fresh world never re-arms on its own")
end
S.finish()
+5 -13
View File
@@ -183,32 +183,24 @@ do
end
end
check(dirsEqual(bow, { 4, 3, 2, 1 }), "the bow sails west a column a step")
-- 7 is missing because that is the block the player is stood on
check(dirsEqual(wake, { 8, 6, 5, 4, 3, 2, 1 }),
check(dirsEqual(wake, { 8, 7, 6, 5, 4, 3, 2, 1 }),
"water closes in astern, stern column first")
-- EraseSSAnne leaves the player's own block alone ("south of the player
-- and won't be redrawn"), so he never stands on water on the way out
local pbx, pby = 7, 1
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
check(not (r[2] == pbx and r[3] == pby),
"the block under the player is never rewritten")
check(r[2] >= 1 and r[2] <= DOCK_HULL.x1,
"the slide stays inside the dock's water, off the pier column 0")
end
-- she has to end up gone: every hull block bar the player's is water by
-- the last edit that touches it
-- scripts/VermilionDock.asm:182-203: the tile fill covers the whole ship,
-- the gangway block under the player included (#1211)
local final = {}
for _, r in ipairs(rowsOfKind(rows, "replace_block")) do
final[r[2] .. "," .. r[3]] = r[4]
end
for bx = DOCK_HULL.x0, DOCK_HULL.x1 do
for by = DOCK_HULL.y0, DOCK_HULL.y1 do
if not (bx == pbx and by == pby) then
check(WATER[final[bx .. "," .. by]],
("hull block (%d,%d) ends as open water"):format(bx, by))
end
check(WATER[final[bx .. "," .. by]],
("hull block (%d,%d) ends as open water"):format(bx, by))
end
end
+4 -3
View File
@@ -178,8 +178,8 @@ do
end
check(sawSurf, "departure plays Music_Surfing")
check(ow._queued ~= nil, "departure queues the sail-away script")
-- #360: the surf override must NOT ride into Vermilion City, she has to
-- sail west block by block, and the block under the player stays dry
-- #360: the surf override must NOT ride into Vermilion City, and she
-- sails west block by block
local kept, horns, slid, underPlayer = false, 0, 0, false
for _, row in ipairs(ow._queued or {}) do
if row[1] == "play_music" and row[3] and row[3].keep then kept = true end
@@ -194,7 +194,8 @@ do
eq(kept, false, "departure lets VERMILION_CITY's own theme take the warp")
eq(horns, 2, "the horn blows before and after she sails")
check(slid > 8, "she sails west block by block instead of vanishing")
eq(underPlayer, false, "the block under the player is never watered over")
-- scripts/VermilionDock.asm:182-203 (#1211)
eq(underPlayer, true, "the gangway block under the player is erased too")
end
-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text.
+4 -1
View File
@@ -49,7 +49,10 @@ local dex = DexEntryMenu.new(game, "PIKACHU")
check(dex.spriteTrueColor == true,
"Pokedex keeps a Pokemon sprite's trueColor flag")
local dexRects = uiRects(function() dex:draw() end)
local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight())
-- engine/menus/pokedex.asm:503: the pic sits in the 7x7 window at (8,8)
local dw, dh = dex.sprite:getDimensions()
local dx = 8 + math.floor((8 - dw / 8) / 2) * 8
local dy = 8 + (7 - dh / 8) * 8
check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
and dexRects[1].w == dex.sprite:getWidth()
and dexRects[1].h == dex.sprite:getHeight(),
+1
View File
@@ -3559,6 +3559,7 @@ runSuites({
"tests/gen2_hof_continue_test.lua",
"tests/gen2_pokecenter_stairs_test.lua",
"tests/gen2_canlose_test.lua",
"tests/gen2_wild_cooldown_bug1229_test.lua",
"tests/gen2_pc_screens_test.lua",
"tests/gen2_badge_boosts_test.lua",
"tests/gen2_held_items_test.lua",