mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
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:
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)")
|
||||
Reference in New Issue
Block a user