CLOSES #1474, CLOSES #1475, CLOSES #1477, CLOSES #1482, CLOSES #1512, CLOSES #1516, CLOSES #1558, CLOSES #1569, CLOSES #1578, CLOSES #1579

This commit is contained in:
bryanthaboi
2026-08-20 16:04:12 -04:00
parent 83463a5a59
commit 0f8f6d0e4f
51 changed files with 2331 additions and 205 deletions
+105
View File
@@ -0,0 +1,105 @@
-- The bag's item list is LIST_MENU_BOX (#1521): home/list_menu.asm:29-31,
-- :51-52, :364-365, :471-479, :518-521, data/text_boxes.asm:13
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")
-- Font wants a real atlas; the geometry is what this suite is about, so it
-- records the calls instead. ListMenu and Theme bind Font at require time.
local realFont = package.loaded["src.render.Font"]
local calls = {}
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
width = function(text) return #tostring(text) * 8 end,
}
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
local ListMenu = require("src.ui.ListMenu")
local Theme = require("src.ui.Theme")
local function found(kind, pred)
for _, c in ipairs(calls) do
if c[1] == kind and pred(c) then return c end
end
return nil
end
local function newList(count)
local items = {}
for i = 1, count do
items[i] = { value = "ITEM_" .. i, label = "ITEM " .. i, right = "x" .. i }
end
return ListMenu.new({}, "ITEMS", items, { kind = "bag", itemBox = true })
end
do
local list = newList(6)
eq(list.rows, 4, "PrintListMenuEntries prints 4 names, not 7")
eq(list.isOpaque, false,
"the box is partial, so the map keeps drawing behind it")
calls = {}
list:draw()
local box = found("box", function(c) return true end)
if check(box ~= nil, "the list draws LIST_MENU_BOX") then
eq(box[2], 4, "upper-left X 4")
eq(box[3], 2, "upper-left Y 2")
eq(box[4], 16, "through lower-right X 19")
eq(box[5], 11, "through lower-right Y 12")
end
-- names at hlcoord 6, 4 and every two rows after it
for row = 1, 4 do
local y = 32 + (row - 1) * 16
check(found("draw", function(c)
return c[2] == "ITEM " .. row and c[3] == 48 and c[4] == y
end) ~= nil, "name " .. row .. " sits at (48, " .. y .. ")")
end
check(found("draw", function(c) return c[2] == "ITEM 5" end) == nil,
"the fifth name is scrolled out, not printed below the box")
-- the quantity: '×' at column 14, the count right-aligned after it
check(found("draw", function(c)
return c[2] == "x" and c[3] == 112 and c[4] == 40
end) ~= nil, "the first quantity's '×' is a row down at column 14")
check(found("draw", function(c)
return c[2] == "1" and c[3] == 128 and c[4] == 40
end) ~= nil, "with the count right-aligned in the two columns after it")
check(found("code", function(c)
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 32
end) ~= nil, "the cursor is in column 5 (wTopMenuItemX)")
check(found("code", function(c)
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
end) ~= nil, "a full page ends with the '▼'")
-- nothing else: no title, no money footer (wPrintItemPrices = 0)
check(found("draw", function(c) return tostring(c[2]):find("¥") end) == nil,
"the everyday bag has no money box (that is the mart's screen)")
check(found("draw", function(c) return c[2] == "ITEMS" end) == nil,
"and no title row: the box carries no header text")
end
-- a short list stops at its last name, and the terminator's CANCEL row is
-- what would follow -- never the '▼'
do
local list = newList(2)
calls = {}
list:draw()
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"a page that runs out of names prints no '▼' (:372)")
end
package.loaded["src.render.Font"] = realFont
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
require("src.ui.Screens").invalidate()
T.finish()
@@ -0,0 +1,112 @@
-- SwitchPlayerMon (core.asm:2419-2423), AnimateRetreatingPlayerMon (:1769-1796)
-- (#1563); pokeyellow core.asm:1862-1866 (#1545); core.asm:1471-1488 (#1608)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.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()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40), Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function(a) if a then return a end return 0 end
return battle
end
-- ---------------------------------------------------------------------
-- the shrink stages and their frame budget
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:queueRetreatAnim()
T.eq(#battle.queue, 2, "the retreat queues its start act plus the hold")
T.eq(battle.queue[2].wait, 7, "4 frames at 5x5 then Delay3 at 3x3")
T.check(battle:shrinkOutScale(battle.player) == nil, "nothing shrinks yet")
battle.queue[1].fn()
T.eq(battle.shrinkOut.battler, battle.player, "the outgoing pic is the one drawn")
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "wDownscaledMonSize 0 -> 5x5")
battle.shrinkOut.frame = 3
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "for four frames")
battle.shrinkOut.frame = 4
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "wDownscaledMonSize 1 -> 3x3")
battle.shrinkOut.frame = 6
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "through Delay3")
T.check(battle:shrinkOutScale(battle.enemy) == nil,
"AnimateRetreatingPlayerMon is player-side only")
end
-- ---------------------------------------------------------------------
-- the Yellow starter slides off instead of shrinking
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.starterPikachuSendOut = function() return true end
battle.queue, battle.nextInsert = {}, 0
battle:queueRetreatAnim()
T.eq(#battle.queue, 3, "start act, the slide's hold, then the slot clear")
battle.queue[1].fn()
local slide = battle.picOff and battle.picOff.playerMon
T.check(slide ~= nil, "the back pic slot is sliding")
T.eq(slide.to, -64, "8 tiles off the left edge")
T.eq(slide.hold, 3, "wSlideMonDelay 3 V-blanks per tile")
T.eq(battle.queue[2].wait, 24, "8 tiles x 3 frames")
T.check(battle:shrinkOutScale(battle.player) == nil, "and no downscale stage")
battle.queue[3].fn()
T.check((battle.picOff or {}).playerMon == nil, "the slot clears afterwards")
end
-- ---------------------------------------------------------------------
-- resolveSwitch runs the retreat between the withdraw text and the swap
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
local outgoing = battle.player.mon
battle:resolveSwitch(battle.game.save.party[2])
local withdraw = table.remove(battle.queue, 1)
battle.nextInsert = 0
withdraw.fn()
T.check(battle.queue[1] and battle.queue[1].text ~= nil, "RetreatMon text first")
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "then the retreat start")
T.eq(battle.queue[3] and battle.queue[3].wait, 7, "then its hold")
T.eq(battle.player.mon, outgoing, "the party slot has not been swapped yet")
end
-- ---------------------------------------------------------------------
-- a fainted pick reopens the party list (#1608)
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.buildScreen = function(_, _, opts) return opts end
battle:openParty()
local opts = battle.queue[1].ui()
battle.queue, battle.nextInsert = {}, 0
battle.game.save.party[2].hp = 0
opts.onSwitch(battle.game.save.party[2])
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("no will", 1, true) ~= nil,
"HasMonFainted prints NoWillText")
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "and queues the reprompt")
battle.queue[2].fn()
T.check(battle.queue[3] and battle.queue[3].ui ~= nil,
"GoBackToPartyMenu puts the list back up")
battle.queue, battle.nextInsert = {}, 0
opts.onSwitch(battle.player.mon)
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("already out", 1, true) ~= nil,
"AlreadyOutText for the mon that is already out")
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts")
end
T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)")
@@ -0,0 +1,64 @@
-- HandleSelfConfusionDamage (engine/battle/core.asm:3672-3714, enemy side
-- :5806-5811) (#1578)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.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()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
-- cp 50 percent + 1: rand < 128 hurts the user
battle.rng = function() return 0 end
return battle
end
local function rowsOf(battle)
local out = {}
for _, item in ipairs(battle.queue) do
if item.anim then out[#out + 1] = item end
end
return out
end
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.player.confusedTurns = 3
T.eq(battle:statusInterrupt(battle.player, battle.enemy, nil), true,
"the self-hit interrupts the player's action")
local anims = rowsOf(battle)
T.eq(#anims, 2, "the IsConfusedText onomatopoeia, then the self-hit's own")
T.eq(anims[1].anim, "CONF_PLAYER_ANIM", "CONF_PLAYER_ANIM rides IsConfusedText")
T.eq(anims[2].anim, "POUND", "wAnimationID 1 is POUND")
T.eq(anims[2].attackerIsPlayer, false,
"hWhoseTurn is flipped to the opponent, so it plays against the player")
T.check(anims[2].hit == nil, "wAnimationType 0 adds no shake or blink layer")
local text, anim
for i, item in ipairs(battle.queue) do
if item.text and item.text:find("confusion", 1, true) then text = text or i end
if item.anim == "POUND" then anim = i end
end
T.check(text and anim and text < anim, "HurtItselfText prints before the animation")
end
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.enemy.confusedTurns = 3
battle:statusInterrupt(battle.enemy, battle.player, nil)
local anims = rowsOf(battle)
T.eq(anims[#anims].anim, "POUND", "the enemy side animates too")
T.eq(anims[#anims].attackerIsPlayer, true, "from the player's side of hWhoseTurn")
end
T.finish("confusion self-hit animation (#1578)")
@@ -126,6 +126,17 @@ local function useStone(game)
game.input.pressed = "a"
picker:update(1 / 60)
game.input.pressed = nil
-- IsEvolvingText hands off to the movie when it closes
-- (engine/pokemon/evos_moves.asm:120-134)
local intro = game.stack:top()
if not (intro and intro.textBox and intro.done) then
return nil, "the \"is evolving!\" box never opened"
end
if not tostring(intro.text):find("evolving") then
return nil, "the box before the movie is not _IsEvolvingText"
end
game.stack:pop()
intro.done()
return list
end
@@ -0,0 +1,133 @@
-- The evolution dialogue is the cart's, in the cart's order (#1596):
-- engine/pokemon/evos_moves.asm:120-128, :136-150, :151-153
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 played = {}
package.loaded["src.core.Sound"] = {
play = function(_, id) played[#played + 1] = id end,
playCry = function() end,
}
local Fixtures = require("tests.modkit.fixtures")
local Evolution = require("src.pokemon.Evolution")
local EvolutionState = require("src.ui.EvolutionState")
local Input = require("src.core.Input")
local Pokemon = require("src.pokemon.Pokemon")
local StateStack = require("src.core.StateStack")
local TextBox = require("src.render.TextBox")
local Data = Fixtures.fresh()
require("src.render.Font").load(Data)
local EVO_LEVEL = 16
local function newGame()
local game = { data = Data }
local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL)
game.save = {
party = { mon },
player = { name = "RED", id = 1 },
options = { textSpeed = 5 },
flags = {},
pokedex = { seen = {}, owned = {} },
}
game.stack = setmetatable({}, { __index = StateStack })
game.stack:init()
game.input = Input
Input:init()
return game, mon
end
local function step(game)
game.input:step()
game.stack:update(1 / 60)
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- The box that goes up before the movie, and the frames it holds for.
do
local game, mon = newGame()
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
local intro = game.stack:top()
if check(getmetatable(intro) == TextBox,
"_IsEvolvingText goes up in a real bordered text box first") then
check(textOf(intro):find("is evolving"),
"and it is the cart's line: " .. textOf(intro))
check(intro.auto ~= nil and not intro.auto.wait,
"which waits for no button (IsEvolvingText ends in `done`)")
eq(intro.auto.delay, 50,
"and holds DelayFrames 50 before it clears (evos_moves.asm:122)")
end
-- it hands off to the movie on its own, with no input at all
local top
for _ = 1, 900 do
top = game.stack:top()
if getmetatable(top) == EvolutionState then break end
step(game)
end
check(getmetatable(top) == EvolutionState,
"the flash movie opens once that box has cleared itself")
eq(mon.species, "FIXMON_A",
"and nothing has evolved yet while the box was up")
end
-- What closes the movie: EvolvedText + IntoText, and the jingle.
do
local game, mon = newGame()
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
local evo
for _ = 1, 900 do
evo = game.stack:top()
if getmetatable(evo) == EvolutionState then break end
step(game)
end
assert(getmetatable(evo) == EvolutionState, "the movie never opened")
played = {}
for _ = 1, 600 do
if evo.done then break end
step(game)
end
eq(mon.species, "FIXMON_B", "the mon evolved")
local box = game.stack:top()
if check(getmetatable(box) == TextBox, "and the result text is a text box") then
local said = textOf(box)
check(said:find("evolved") and said:find("into"),
"_EvolvedText + _IntoText print together: " .. said)
check(not said:find("Congratulations"),
"no fabricated \"Congratulations!\" line (it is in no ROM)")
check(box.auto ~= nil and box.auto.sound ~= nil,
"and the box carries a jingle the way sound_get_item_1 boxes do")
-- type it out; auto.sound fires once the last page has landed
for _ = 1, 900 do
if box.autoStarted then break end
step(game)
end
local heard = false
for _, id in ipairs(played) do
if id == "Get_Item2" then heard = true end
end
check(heard, "SFX_GET_ITEM_2 plays on that box (evos_moves.asm:151)")
end
end
-- The flash itself carries no text: EvolutionState draws sprites only.
do
local source = assert(io.open("src/ui/EvolutionState.lua")):read("*a")
check(not source:find("Font%.draw"),
"EvolutionState draws no bare text over the flash "
.. "(ClearScreenArea ran before EvolveMon, evos_moves.asm:128)")
end
T.finish()
+23 -9
View File
@@ -52,6 +52,14 @@ local function step(game)
game.stack:update(1 / 60)
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
local function levelUpBox(game, mon)
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
@@ -69,21 +77,27 @@ local function dismissWithB(game, mon)
if not box.done then return nil, "the level-up text never finished typing" end
Input:keypressed(B_KEY)
step(game)
local top = game.stack:top()
-- IsEvolvingText holds its own box for DelayFrames 50 before EvolveMon
-- runs (engine/pokemon/evos_moves.asm:120-134)
local intro = game.stack:top()
if getmetatable(intro) ~= TextBox then
return nil, "the \"is evolving!\" box never opened"
end
if not textOf(intro):find("is evolving") then
return nil, "the box before the movie is not _IsEvolvingText"
end
local top
for _ = 1, 900 do
top = game.stack:top()
if getmetatable(top) == EvolutionState then break end
step(game)
end
if getmetatable(top) ~= EvolutionState then
return nil, "the evolution screen never opened"
end
return top
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- B held out of the text box: the movie must run to the end and evolve.
do
local game, mon = newGame()
@@ -0,0 +1,38 @@
-- wLastDexMode (engine/pokedex/pokedex.asm:60, :97) (#1474)
-- luajit tests/engine/gen2_dex_mode_persist_bug1474.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 save = { position = { map = "ROUTE_30" } }
local game = { data = {}, save = save }
local first = PokedexMenu.new(game, {})
eq(first.modeIndex, 1, "a save with no remembered mode opens in NEW")
-- what the OPTION screen's .ChangeMode leaves behind
first.modeIndex = 3
first:close()
eq(save.lastDexMode, "A-Z", "closing the dex writes the live mode into the save")
local second = PokedexMenu.new(game, {})
eq(second.modeIndex, 3, "reopening the dex restores the remembered mode")
second.modeIndex = 2
second:close()
eq(PokedexMenu.new(game, {}).modeIndex, 2, "OLD survives the same way")
local fresh = PokedexMenu.new({ data = {}, save = {} }, {})
eq(fresh.modeIndex, 1, "a fresh save still starts on NEW")
local closed = false
local menu = PokedexMenu.new(game, { onClose = function() closed = true end })
menu:close()
check(closed, "close still runs the caller's onClose")
T.finish("gen2 pokedex mode persistence bug 1474")
@@ -0,0 +1,32 @@
-- The Fly map draws no Pokegear mode arrow (engine/pokegear/pokegear.asm:1999) (#1477)
-- luajit tests/engine/gen2_fly_map_arrow_bug1477.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
local Pokegear = require("src.ui.gen2.Pokegear")
local function drawnArrow(fly)
local drew = false
local self = setmetatable({
fly = fly,
styled = function() return true end,
groundColor = function() return { 0, 0, 0 } end,
card = function() return { id = "map", label = fly and "FLY" or "MAP" } end,
drawMap = function() end,
drawModeArrow = function() drew = true end,
}, { __index = Pokegear })
local realRect = love.graphics.rectangle
love.graphics.rectangle = function() end
self:drawPanel()
love.graphics.rectangle = realRect
return drew
end
check(drawnArrow(false), "the Pokegear MAP card still animates the arrow")
check(not drawnArrow(true), "_FlyMap draws no mode-indicator arrow")
T.finish("gen2 fly map arrow bug 1477")
@@ -0,0 +1,37 @@
-- #1479 / #1449: TILESET_KANTO takes no map-group roof (home/map.asm:1738-1749)
-- luajit tests/engine/gen2_kanto_no_roof_bug1479.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local MapPreview = require("src.world.gen2.MapPreview")
local baker = {
tilesets = {
TILESET_KANTO = { image = "assets/generated/tilesets/kanto.png" },
TILESET_JOHTO = { image = "assets/generated/tilesets/johto.png" },
},
roofs = {
mapGroupRoofs = { [19] = "ROOF_SILVER" },
roofs = { ROOF_SILVER = {} },
},
atlasCache = {},
mapImages = {},
}
-- data/maps/maps.asm:396: SilverCaveOutside and Route28 are both group 19
-- (MapGroup_Silver) and both TILESET_KANTO.
MapPreview.atlasFor(baker, { tileset = "TILESET_KANTO", group = 19 })
MapPreview.atlasFor(baker, { tileset = "TILESET_JOHTO", group = 19 })
local keys = {}
for key in pairs(baker.atlasCache) do keys[key] = true end
T.check(keys["TILESET_KANTO"],
"the Kanto atlas is cached under the bare tileset, with no roof")
T.check(not keys["TILESET_KANTO|ROOF_SILVER"],
"and never under a map-group roof")
T.check(keys["TILESET_JOHTO|ROOF_SILVER"],
"while TILESET_JOHTO still takes its group's roof")
print("gen2 Kanto roof gate (#1479): ok")
+205
View File
@@ -0,0 +1,205 @@
-- engine/battle/core.asm:2310-2323 (WinTrainerBattle), :2763-2782 (LostBattle),
-- home/trainers.asm:120 and :230 (PrintWinLossText)
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 World = require("src.world.gen2.World")
local LEVEL = 10
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
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 = { NORMAL = { id = "NORMAL", index = 0,
category = "physical" } }, matchups = {} },
items = {},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function mon()
local m = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect })
m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return m
end
local function newSave()
return { player = { name = "GOLD", money = 1000, id = 4242 },
mom = { savedMoney = 0 }, party = {} }
end
local function newBattle(trainer, battleType)
local save = newSave()
local battle = Battle.new({ data = DATA, party = { mon() },
trainer = trainer, battleType = battleType, save = save,
random = function(n) return 99 % math.max(1, n or 1) end })
return battle, save
end
local function trainerRecord(extra)
local record = { name = "SAGE CHOW", baseMoney = 3, party = { mon() } }
for key, value in pairs(extra or {}) do record[key] = value end
return record
end
local function kinds(events)
local out = {}
for i, event in ipairs(events or {}) do out[i] = event.kind end
return table.concat(out, ",")
end
local function indexOf(events, kind)
for i, event in ipairs(events or {}) do
if event.kind == kind then return i, event end
end
return nil
end
-- The win arm: defeated line, frontpic slide-in, the trainer's own line, money.
do
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
battle.enemy.hp = 0
T.check(battle:resolveFaints(), "the last enemy mon ends the battle")
local events = battle:takeEvents()
local defeated = indexOf(events, "faint")
local ret = indexOf(events, "trainer-return")
local win, winEvent = indexOf(events, "win-text")
local money = indexOf(events, "money")
T.check(ret and win and money, "all three win rows are queued: " ..
kinds(events))
T.check(defeated < ret, "BattleText_EnemyWasDefeated comes first")
T.check(ret < win, "the frontpic slides back in before the line")
T.check(win < money, "and PrintWinLossText runs before the payout")
T.eq(winEvent.text, "Th-Thank you!", "the struct's win text is printed")
end
-- The pic comes back whether or not there is a line: the DEBUG_BATTLE_F skip
-- sits in front of PrintWinLossText alone.
do
local battle = newBattle(trainerRecord())
battle.enemy.hp = 0
battle:resolveFaints()
local events = battle:takeEvents()
T.check(indexOf(events, "trainer-return"), "the slide-in is unconditional")
T.eq(indexOf(events, "win-text"), nil, "with no line to print")
end
-- A wild battle has no trainer and no line.
do
local save = newSave()
local wild = mon()
local battle = Battle.new({ data = DATA, party = { mon() }, wild = wild,
save = save, random = function(n) return 99 % math.max(1, n or 1) end })
battle.enemy.hp = 0
battle:resolveFaints()
local events = battle:takeEvents()
T.eq(indexOf(events, "trainer-return"), nil, "no frontpic to slide back in")
T.eq(indexOf(events, "win-text"), nil, "and nothing to print")
end
-- The loss arm: only BATTLETYPE_CANLOSE reaches PrintWinLossText.
do
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!",
lossText = "...Too weak..." }), Battle.BATTLETYPE_CANLOSE)
battle.player.hp = 0
T.check(battle:resolveFaints(), "the whiteout ends the battle")
local events = battle:takeEvents()
local _, lossEvent = indexOf(events, "win-text")
T.check(lossEvent, "the loss line is printed: " .. kinds(events))
T.eq(lossEvent.text, "...Too weak...", "wLossTextPointer, not the win one")
end
do
local battle = newBattle(trainerRecord({ lossText = "...Too weak..." }))
battle.player.hp = 0
battle:resolveFaints()
local events = battle:takeEvents()
T.eq(indexOf(events, "win-text"), nil, "an ordinary loss whites out instead")
end
-- wWinTextPointer / wLossTextPointer: the map object's struct, or whatever
-- `winlosstext` overwrote the pair with.
do
local world = { text = { ["3:4000"] = "Th-Thank you!",
["3:4100"] = "...Too weak...", ["3:4200"] = "Scripted win." } }
world.vm = { trainerObject = { winText = "3:4000", lossText = "3:4100" } }
local win, loss = World.trainerWinLossText(world)
T.eq(win, "Th-Thank you!", "the struct's win text is decoded")
T.eq(loss, "...Too weak...", "and its loss text with it")
world.vm.winTextOverride = "3:4200"
win = World.trainerWinLossText(world)
T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer")
world.vm.trainerObject = nil
world.vm.winTextOverride = nil
win, loss = World.trainerWinLossText(world)
T.eq(win, nil, "a battle with no trainer object has no line")
T.eq(loss, nil, "on either side")
world.vm = nil
T.eq(World.trainerWinLossText(world), nil, "and neither has one with no VM")
end
-- The screen side: the slide-in owns the frames the way SlideBattlePicOut
-- does, and the line pages like the map text it is.
local BattleState = require("src.ui.gen2.BattleState")
local function newScreen(battle, queue, image)
return setmetatable({
battle = battle, queue = queue, picHidden = {}, evolvable = {},
phase = "resolving", messageTimer = 0, enemyTrainerImage = image,
}, { __index = BattleState })
end
do
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
local screen = newScreen(battle, { { kind = "trainer-return" },
{ kind = "win-text", text = "Th-Thank you!" } }, {})
screen:advanceQueue()
T.eq(screen.winSlide, 0, "the slide starts on the frame the event runs")
T.check(screen.winSliding, "and owns the screen while it runs")
T.check(screen.showEnemyTrainer, "the beaten trainer is back on the field")
T.eq(screen.picHidden.enemy, false, "in the box the fainted mon left empty")
T.eq(#screen.queue, 1, "the line is still waiting behind it")
end
do
local battle = newBattle(trainerRecord())
local screen = newScreen(battle, { { kind = "trainer-return" },
{ kind = "win-text", text = "Th-Thank you!\fReally." } }, nil)
screen:advanceQueue()
T.eq(screen.winSlide, nil, "no cached pic, no slide")
T.eq(screen.message, "Th-Thank you!", "the line runs straight away")
T.check(screen.messagePages, "with its `para` page held back")
T.check(screen:nextPage(), "which the queue waits for")
T.eq(screen.message, "Really.", "before the second page shows")
end
T.finish("gen2 win loss text bug 1512")
@@ -0,0 +1,139 @@
-- A gym leader's badge line prints from TrainerBattleVictory (#1606):
-- scripts/CeruleanGym.asm:111, PewterGym.asm:117, home/trainers.asm:341
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local gyms = require("data.scripts.gyms")
local victories = require("data.scripts.victories")
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
-- one page of text per label, so a joined chain is readable in a failure
local text = {}
for _, key in ipairs({ "OPP_BROCK#1", "OPP_MISTY#1", "OPP_LT_SURGE#1",
"OPP_ERIKA#1", "OPP_KOGA#1", "OPP_SABRINA#1",
"OPP_BLAINE#1", "OPP_GIOVANNI#3" }) do
for _, label in ipairs(victories[key].dialogue or {}) do
text[label] = "text:" .. label
end
for _, label in ipairs(victories[key].tmPre or {}) do
text[label] = "text:" .. label
end
for _, label in ipairs(victories[key].tmDialogue or {}) do
text[label] = "text:" .. label
end
end
local fakeGame = { data = { text = text }, save = { flags = {} } }
local armed
local fakeOw = {
engageTrainer = function(_, _, _, endBattleText) armed = endBattleText end,
}
local function armedFor(mapId, textId, victoryKey)
armed = nil
gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end)
local labels = victories[victoryKey].dialogue
local want = {}
for i, label in ipairs(labels) do want[i] = text[label] end
return armed, table.concat(want, "\f")
end
-- every leader, in badge order
local leaders = {
{ "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", "OPP_BROCK#1" },
{ "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", "OPP_MISTY#1" },
{ "VERMILION_GYM", "TEXT_VERMILIONGYM_LT_SURGE", "OPP_LT_SURGE#1" },
{ "CELADON_GYM", "TEXT_CELADONGYM_ERIKA", "OPP_ERIKA#1" },
{ "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_KOGA", "OPP_KOGA#1" },
{ "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA", "OPP_SABRINA#1" },
{ "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", "OPP_BLAINE#1" },
{ "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", "OPP_GIOVANNI#3" },
}
for _, entry in ipairs(leaders) do
local got, want = armedFor(entry[1], entry[2], entry[3])
T.eq(got, want, entry[3] .. " arms its badge line for the battle screen")
end
-- Brock's armed label is one text chain of two text_far pages
-- (PewterGymBrockReceivedBoulderBadgeText), so both ride the battle screen
local brock = select(1, armedFor("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK",
"OPP_BROCK#1"))
T.check(brock:find("\f", 1, true) ~= nil,
"Brock's badge line keeps its BoulderBadgeInfo page")
-- the beaten branch still talks instead of re-engaging
armed = nil
fakeGame.save.flags.EVENT_BEAT_MISTY = true
fakeGame.save.flags.EVENT_GOT_TM11 = true
local realStack = { push = function() end }
gyms.CERULEAN_GYM.talk.TEXT_CERULEANGYM_MISTY(
{ data = { text = text }, save = fakeGame.save, stack = realStack },
fakeOw, { id = "npc#1" }, function() end)
T.eq(armed, nil, "a beaten leader does not re-arm the badge line")
fakeGame.save.flags.EVENT_BEAT_MISTY = nil
fakeGame.save.flags.EVENT_GOT_TM11 = nil
-- checkVictoryRewards must not reprint what the battle screen showed
local boxes
local textBoxStub = {
new = function(_, str, onDone)
boxes[#boxes + 1] = str
return { onDone = onDone }
end,
soundOpts = function() return nil end,
}
local pushed
local rewardGame = {
data = { text = text, items = { TM_BUBBLEBEAM = { name = "TM11" } } },
save = { flags = {}, inventory = {}, player = { name = "RED" } },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
T.check(setUpvalue(OW.checkVictoryRewards, "Game", rewardGame),
"Game upvalue on checkVictoryRewards")
-- TextBox is only named inside the rewardChain closure; the chunk-level
-- upvalue cell is shared, so any closure that names it will do
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
"TextBox upvalue on the reward chain")
local fakeSelf = setmetatable({
map = { id = "CERULEAN_GYM", def = { label = "CeruleanGym" } },
runVictoryHook = function() end,
}, { __index = OW })
local function rewardPages(shownOnBattleScreen)
boxes, pushed = {}, {}
rewardGame.save.flags = {}
rewardGame.save.inventory = {}
fakeSelf:checkVictoryRewards("OPP_MISTY", 1, shownOnBattleScreen)
-- the chain pushes one box at a time; walk it to the end
local i = 1
while pushed[i] do
local box = pushed[i]
i = i + 1
if box.onDone then box.onDone() end
end
return table.concat(boxes, "\f")
end
local badge = text["_CeruleanGymMistyReceivedCascadeBadgeText"]
local onMap = rewardPages(false)
T.check(onMap:find(badge, 1, true) ~= nil,
"without the battle-screen line the reward chain still shows the badge text")
local afterBattleScreen = rewardPages(true)
T.eq(afterBattleScreen:find(badge, 1, true), nil,
"the badge line is not reprinted on the map once the battle screen showed it")
T.check(afterBattleScreen:find(text["_CeruleanGymMistyCascadeBadgeInfoText"],
1, true) ~= nil,
"the TM hand-over still runs on the map")
T.check(rewardGame.save.inventory.CASCADEBADGE == 1, "the badge is still given")
@@ -0,0 +1,163 @@
-- Title -> main menu handoff (#1510) and the Oak-intro naming layout (#1511):
-- title.asm .finishedWaiting, oak_speech2.asm ChoosePlayerName
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local Menu = require("src.ui.Menu")
local NamingScreen = require("src.ui.NamingScreen")
local OakSpeech = require("src.ui.OakSpeech")
local TitleState = require("src.ui.TitleState")
local function newStack()
local stack = { states = {} }
function stack:push(state, ...)
table.insert(self.states, state)
if type(state.enter) == "function" then state:enter(...) end
end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return stack
end
local function newGame()
return {
data = Data,
save = SaveData.newGame(),
stack = newStack(),
input = { wasPressed = function() return false end,
isDown = function() return false end },
}
end
local function run(state, frames)
for _ = 1, frames do
if state.game.stack:top() ~= state then return end
state:update(1 / 60)
end
end
-- ------------------------------------------------- #1510: title -> menu
local game = newGame()
local title = TitleState.new(game, {})
game.stack:push(title)
title:toMenu()
local flash = game.stack:top()
T.check(flash ~= title, "START pushes a state before the menu")
T.eq(flash.isOpaque, true, "GBPalWhiteOutWithDelay3 covers the title art")
T.check(not title.menuOpen, "the title is still drawing itself mid-blink")
run(flash, 60)
local menu = game.stack:top()
T.check(getmetatable(menu) == Menu, "the blink hands off to the main menu")
T.eq(title.menuOpen, true, "MainMenu's ClearScreen takes the title art down")
T.eq(menu.tx, 0, "the CONTINUE / NEW GAME box is still at hlcoord 0,0")
-- OPTION returns to .mainMenuLoop, so its row must not close the box
local option
for _, item in ipairs(menu.items) do
if item.label == "OPTION" then option = item end
end
T.check(option ~= nil and option.keepOpen == true,
"OPTION keeps the main menu on the stack")
-- B: DisplayTitleScreen opens with GBPalWhiteOut
T.check(type(menu.onCancel) == "function", "the main menu cancels back out")
game.stack:pop()
menu.onCancel()
local back = game.stack:top()
T.eq(back.isOpaque, true, "backing out blinks white too")
T.eq(title.menuOpen, true, "the title art stays down until the blink ends")
run(back, 60)
T.eq(title.menuOpen, false, "and comes back once the blink is over")
T.eq(game.stack:top(), title, "leaving the menu lands back on the title")
-- ------------------------------------------- #1511: the intro NAME box
local ngame = newGame()
local naming = NamingScreen.new(ngame, {
presets = { "RED", "ASH", "JACK" }, introBox = true,
})
ngame.stack:push(naming)
local box = ngame.stack:top()
T.check(getmetatable(box) == Menu, "the preset list is a bordered menu")
-- DisplayIntroNameTextBox: TextBoxBorder at hlcoord 0,0 with b=$a, c=$9
T.eq(box.tx, 0, "the name box starts at column 0")
T.eq(box.ty, 0, "the name box starts at row 0")
T.eq(box.tw, 11, "c=$9 plus both border columns is 11 tiles wide")
T.eq(box.th, 12, "b=$a plus both border rows is 12 tiles tall")
T.eq(box.title, "NAME", "the NAME label rides the box's top border")
T.eq(box.itemY, 2, "wTopMenuItemY 2: the list is anchored down from the top")
T.eq(box.cancelable, false, "there is no way out of the naming choice")
-- every other NamingScreen caller keeps the old preset box
local plain = NamingScreen.new(newGame(), { presets = { "RED" } })
local pgame = plain.game
pgame.stack:push(plain)
T.eq(pgame.stack:top().tx, 4, "a non-intro preset list is unchanged")
T.eq(pgame.stack:top().title, nil, "and carries no header")
-- ------------------------------------------ #1511: the pic slide + box
local steps = OakSpeech.defaultSteps({})
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
-- oak_speech.asm:86-91 MovePicLeft, then a text_end box that stays up
T.eq(byId.ask_player_name.reveal, "wipe", "the player pic wipes in")
T.eq(byId.ask_player_name.stay, true, "and its question box stays on screen")
T.eq(byId.ask_rival_name.reveal, "fade", "the rival pic fades in")
T.eq(byId.ask_rival_name.stay, true, "and its box stays on screen too")
local sgame = newGame()
local speech = OakSpeech.new(sgame, function() end)
sgame.stack:push(speech)
speech.steps = steps
speech.step = 0
speech:runStep(byId.name_player)
local slide = sgame.stack:top()
T.check(slide ~= speech, "the name beat slides the pic before the box opens")
T.eq(speech.picSlide, 0, "the slide starts where the pic already sat")
-- six tiles, one per Delay3
run(slide, 6 * 3)
T.eq(speech.picSlide, 48, "OakSpeechSlidePicRight ends six tiles across")
T.check(sgame.stack:top() ~= slide, "the slide pops itself when it lands")
-- ------------------------------------- #1511: the question box stays up
local fgame = newGame()
local flow = OakSpeech.new(fgame, function() end)
fgame.stack:push(flow)
flow.steps = OakSpeech.defaultSteps(flow)
for i, step in ipairs(flow.steps) do
if step.id == "ask_player_name" then flow.step = i - 1 end
end
flow:advance()
local function pump(frames)
for _ = 1, frames do
local top = fgame.stack:top()
if top.update then top:update(1 / 60) end
if getmetatable(fgame.stack:top()) == Menu then return end
end
end
pump(400)
T.check(getmetatable(fgame.stack:top()) == Menu, "the preset list opens")
T.eq(fgame.stack.states[2], flow.holdBox,
"IntroducePlayerText's box is still on the stack under the name list")
T.check(flow.holdBox.done and flow.holdBox.stay,
"it is a `stay` box: text_end returns from PrintText without a wait")
local preset = fgame.stack:top()
preset.index = 2
fgame.input.wasPressed = function(_, b) return b == "a" end
preset:update(1 / 60)
fgame.input.wasPressed = function() return false end
T.eq(fgame.save.player.name, "RED", "picking a preset names the player")
T.eq(flow.holdBox, nil, "and takes the question box down with the list")
run(fgame.stack:top(), 6 * 3)
T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back")
T.finish("intro_title_naming_bug1510_1511")
+95
View File
@@ -0,0 +1,95 @@
-- The PC gains a PKMN LEAGUE row after the Hall of Fame (#1566):
-- DisplayPCMainMenu (engine/pokemon/bills_pc.asm:5), PKMNLeague (pc.asm:67)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
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 fakeGame = {
data = { text = {
_TurnedOnPC1Text = "RED turned on\nthe PC.",
_AccessedHoFPCText = "Accessed POKéMON\nLEAGUE's site.",
} },
save = {
flags = { EVENT_GOT_POKEDEX = true },
player = { name = "RED" },
},
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
local sounds = {}
package.loaded["src.core.Sound"] = {
play = function(_, name) sounds[#sounds + 1] = name end,
}
local menuItems
package.loaded["src.ui.Menu"] = {
new = function(_, items) menuItems = items; return { menu = true } end,
}
local opened = {}
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
T.check(setUpvalue(OW.openPC, "Screens", {
push = function(_, id) opened[#opened + 1] = id end,
}), "Screens upvalue on openPC")
T.check(setUpvalue(OW.openPC, "TextBox", {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
}), "TextBox upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
local function labels()
pushed, menuItems, sounds, opened = {}, nil, {}, {}
fakeSelf:openPC(function() end)
pushed[1].onDone() -- close TurnedOnPC1Text; the menu goes up behind it
local names = {}
for i, item in ipairs(menuItems or {}) do names[i] = item.label end
return names
end
local function indexOf(list, label)
for i, name in ipairs(list) do
if name == label then return i end
end
end
-- wNumHoFTeams == 0: three rows plus LOG OFF (bills_pc.asm .noLeaguePC)
local before = labels()
T.eq(indexOf(before, "<PK><MN>LEAGUE"), nil,
"no PKMN LEAGUE row before the Hall of Fame")
T.eq(#before, 4, "BILL's PC, the player's PC, PROF.OAK's PC and LOG OFF")
-- one recorded team is enough, and it stays for good
fakeGame.save.hallOfFame = { { { species = "PIKACHU", level = 80 } } }
local after = labels()
local iLeague = indexOf(after, "<PK><MN>LEAGUE")
T.check(iLeague, "PKMN LEAGUE appears once a team is in the Hall of Fame")
T.eq(after[iLeague - 1], "PROF.OAK's PC", "it follows PROF.OAK's PC")
T.eq(after[iLeague + 1], "LOG OFF", "and LOG OFF still closes the menu")
T.check(menuItems[iLeague].keepOpen,
"B returns to the PC menu (ReloadMainMenu), it does not log off")
-- selecting it: SFX_ENTER_PC, AccessedHoFPCText, then the roster screen
menuItems[iLeague].onSelect()
T.eq(sounds[#sounds], "Enter_PC", "PKMNLeague plays SFX_ENTER_PC")
local box = pushed[#pushed]
T.eq(box.text, fakeGame.data.text._AccessedHoFPCText,
"PKMNLeaguePC prints AccessedHoFPCText first")
box.onDone()
T.same(opened, { "LeaguePC" }, "the Hall of Fame roster screen opens")
-- without the Pokedex the league row still shows (bills_pc.asm checks
-- wNumHoFTeams before EVENT_GOT_POKEDEX)
fakeGame.save.flags.EVENT_GOT_POKEDEX = nil
local noDex = labels()
T.check(indexOf(noDex, "<PK><MN>LEAGUE"),
"the league row does not depend on EVENT_GOT_POKEDEX")
+3 -2
View File
@@ -136,8 +136,9 @@ do
local list, why = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
check(not inStack(game.stack, isPicker),
"the pickOnly picker popped itself before onSwitch")
-- .useRareCandy over the party list (item_effects.asm:1392-1418) #1594
check(inStack(game.stack, isPicker),
"the party picker is still up under the level text (#1594)")
check(inStack(game.stack, function(s) return s == list end),
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
@@ -0,0 +1,64 @@
-- SAVE confirmation layout (#1522): PrintSaveScreenText's own box and
-- SaveTheGame_YesOrNo's TWO_OPTION_MENU at hlcoord 0, 7
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local StartMenu = require("src.ui.StartMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function newStack()
local stack = { states = {} }
function stack:push(state) table.insert(self.states, state) end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return stack
end
local game = {
data = Data,
save = SaveData.newGame(),
stack = newStack(),
input = { wasPressed = function() return false end,
isDown = function() return false end },
}
game.save.player.name = "RED"
game.save.playTime = 3 * 3600 + 7 * 60
local menu = StartMenu.new(game)
local save
for _, item in ipairs(menu.items) do
if item.label == "SAVE" then save = item end
end
T.check(save ~= nil, "the start menu lists SAVE")
save.onSelect()
T.eq(#game.stack.states, 2, "the panel and the prompt are two separate states")
local panel, prompt = game.stack.states[1], game.stack.states[2]
T.check(type(panel.draw) == "function" and not panel.isTextBox,
"the info panel is its own drawn state, not a TextBox page")
T.check(prompt.isTextBox == true, "the prompt is the dialogue box on top")
T.eq(#prompt.pages, 1, "the prompt is one page: no \\f-merged info panel")
T.check(prompt.pages[1][1]:find("Would you like to"),
"the prompt page is WouldYouLikeToSaveText")
-- save.asm:187 hlcoord 0, 7
T.eq(prompt.choiceBox.tx, 0, "the save Yes/No box sits at column 0 (left)")
T.eq(prompt.choiceBox.ty, 7, "the save Yes/No box sits at row 7")
local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox })
T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement")
-- answering NO takes the panel back down with the prompt
prompt.done = true
prompt:update(1 / 60)
local yesno = game.stack:top()
T.check(getmetatable(yesno) == ChoiceBox, "the prompt pushes the Yes/No box")
T.eq(yesno.tx, 0, "the pushed Yes/No box is the left-hand one")
game.stack:pop()
game.stack:pop()
prompt.choice(false)
T.eq(#game.stack.states, 0, "declining closes the info panel too")
T.finish("save_confirm_layout_bug1522")
@@ -49,6 +49,13 @@ local function indexOf(rows, name)
return nil
end
local function hasText(battle, needle)
for _, item in ipairs(battle.queue) do
if item.text and item.text:find(needle, 1, true) then return true end
end
return false
end
-- ---------------------------------------------------------------------
-- the player's setup turn: the effect animation precedes the move's own
-- ---------------------------------------------------------------------
@@ -73,6 +80,16 @@ do
battle:performMove(battle.player, battle.enemy, slot)
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
"a locked-in Thrash queues no setup animation")
-- .ThrashingAboutCheck falls into PlayerCalcMoveDamage, the same
-- animation pipeline every other move uses (core.asm:3540) (#1577)
T.check(indexOf(animRows(battle), "FIX_THRASH") ~= nil,
"but the move's own animation still plays on a continuation turn")
T.check(hasText(battle, "thrashing about"),
"ThrashingAboutText prints in place of the used-move line")
T.check(not hasText(battle, "used FIX THRASH"),
"and the used-move line does not")
T.eq(battle.player.thrashTurns, 1,
"the continuation turn runs wPlayerNumAttacksLeft down")
end
-- ---------------------------------------------------------------------
@@ -104,6 +121,11 @@ do
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
"the setup animation survives a miss")
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
-- ThrashPetalDanceEffect commits before MoveHitTest
-- (core.asm:3129-3133, effects.asm:791-808) (#1565)
T.eq(battle.player.thrashTurns, 2, "the miss still rolls wPlayerNumAttacksLeft")
T.check(battle:menuLockedAction(battle.player) ~= nil,
"and the user is locked into Thrash next turn")
end
T.finish("thrash setup animation (#1532)")
+40
View File
@@ -0,0 +1,40 @@
-- GainExperience compares MON_OTID against wPlayerID at every award
-- (engine/battle/experience.asm:69-88) (#1488)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.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 boostedText(mutate)
local save = SaveData.newGame()
save.player.id = save.player.id or 1234
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
mutate(save.party[1], save)
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 10)
battle.participants = { [save.party[1]] = true }
battle:enemyMonFainted()
for _, item in ipairs(battle.queue) do
if item.text and item.text:find("boosted", 1, true) then return true end
end
return false
end
T.eq(boostedText(function(mon, save) mon.otId = save.player.id end), false,
"a mon whose OTID is the player's own earns no boost")
T.eq(boostedText(function(mon, save) mon.otId = (save.player.id or 0) + 1 end), true,
"a foreign OTID trips BoostExp")
T.eq(boostedText(function(mon, save)
-- traded away and traded back: the stored OTID is the player's again
mon.traded = true
mon.otId = save.player.id
end), false, "and a mon traded back to its original trainer loses it (#1488)")
T.finish("traded exp boost is an OTID comparison (#1488)")