mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 12:40:21 +02:00
@@ -0,0 +1,50 @@
|
||||
-- The bike shop's "A shiny new BICYCLE!" is six hidden_event rows, not a
|
||||
-- bg_event: data/events/hidden_events.asm:542-549 points every one of them
|
||||
-- at PrintNewBikeText (engine/events/hidden_events/new_bike.asm:1), which
|
||||
-- tx_pre_jumps NewBicycleText with ANY_FACING and no gating. The port's
|
||||
-- field extractor lifts none of that family, so BIKE_SHOP had no display
|
||||
-- text at all (#1530).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local pushed
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||
}
|
||||
|
||||
local scripts = require("data.scripts.flavor.bike_shop")
|
||||
local onInteract = scripts.BIKE_SHOP.onInteract
|
||||
T.check(type(onInteract) == "function", "BIKE_SHOP carries an onInteract hook")
|
||||
|
||||
local game = {
|
||||
data = { text = { _NewBicycleText = "A shiny new\nBICYCLE!" } },
|
||||
stack = { push = function(_, box) pushed = box end },
|
||||
}
|
||||
|
||||
-- data/events/hidden_events.asm:543-548 (the macro emits y then x, so the
|
||||
-- source pairs read x, y)
|
||||
for _, cell in ipairs({ { 1, 0 }, { 2, 1 }, { 1, 2 }, { 3, 2 }, { 0, 4 }, { 1, 5 } }) do
|
||||
pushed = nil
|
||||
local consumed = onInteract(game, {}, cell[1], cell[2])
|
||||
T.eq(consumed, true, ("(%d,%d) is a display tile"):format(cell[1], cell[2]))
|
||||
T.check(pushed ~= nil and pushed.text == game.data.text._NewBicycleText,
|
||||
("(%d,%d) prints _NewBicycleText"):format(cell[1], cell[2]))
|
||||
end
|
||||
|
||||
-- data/generated/maps.lua BIKE_SHOP object_events sit at (6,2), (5,6) and
|
||||
-- (1,3): none of the six, so the hook never steals an NPC's talk
|
||||
for _, cell in ipairs({ { 4, 4 }, { 6, 2 }, { 5, 6 }, { 1, 3 }, { 0, 0 } }) do
|
||||
pushed = nil
|
||||
local consumed = onInteract(game, {}, cell[1], cell[2])
|
||||
T.eq(consumed, false, ("(%d,%d) is not a display tile"):format(cell[1], cell[2]))
|
||||
T.eq(pushed, nil, "and pushes nothing")
|
||||
end
|
||||
|
||||
-- with no cache text the hook still prints the line
|
||||
pushed = nil
|
||||
onInteract({ data = {}, stack = game.stack }, {}, 1, 0)
|
||||
T.check(pushed ~= nil and pushed.text:find("BICYCLE", 1, true) ~= nil,
|
||||
"a dataset without the label falls back to the engine wording")
|
||||
|
||||
T.finish("bike shop display text (#1530)")
|
||||
@@ -146,12 +146,34 @@ T.eq(resumed, 1, "the script resumed once")
|
||||
|
||||
-- ------------------------------------------------------------- plain gift
|
||||
-- gotText == false is the script-shows-its-own-text form (Oak's 5 POKE
|
||||
-- BALLs): nothing to hang the sound on, so it plays on the spot
|
||||
-- BALLs): the received text the script prints next carries the jingle
|
||||
-- (scripts/OaksLab.asm:1058-1062), so give_item arms it and plays nothing
|
||||
plays = {}
|
||||
Commands.give_item(ctx, "FIX_POTION", 1, false)
|
||||
T.eq(jingles(), 1, "the no-text gift still plays its jingle immediately")
|
||||
T.eq(plays[1], "item.wav", "with the plain-item sound")
|
||||
T.eq(jingles(), 0, "the no-text gift plays nothing on the spot")
|
||||
T.eq(stack:top(), nil, "and pushes no box of its own")
|
||||
T.check(ctx.textOpts and ctx.textOpts.auto and ctx.textOpts.auto.sound ~= nil,
|
||||
"the jingle is armed for the next show_text")
|
||||
|
||||
Commands.show_text(ctx, "{PLAYER} GOT\nSOMETHING!")
|
||||
local box2 = stack:top()
|
||||
T.check(getmetatable(box2) == TextBox, "the script's own received box is up")
|
||||
T.eq(ctx.textOpts, nil, "show_text consumed the armed jingle")
|
||||
for _ = 1, 2000 do
|
||||
if box2.done then break end
|
||||
step(box2.waiting and "a" or nil)
|
||||
end
|
||||
T.check(box2.done, "the received text typed out")
|
||||
T.eq(jingles(), 0, "silent until the last character is placed")
|
||||
step()
|
||||
T.eq(jingles(), 1, "the jingle fires once the text is out")
|
||||
T.eq(plays[#plays], "item.wav", "with the plain-item sound")
|
||||
step("a")
|
||||
T.eq(stack:top(), box2, "A does not close the box during the jingle")
|
||||
sources["item.wav"].playing = false
|
||||
step()
|
||||
step("a")
|
||||
T.eq(stack:top(), nil, "A closes the box after the jingle")
|
||||
|
||||
-- ------------------------------------------------------------ script data
|
||||
-- both Viridian Mart paths must hand give_item the quest text, since that
|
||||
|
||||
@@ -29,4 +29,12 @@ check(patch:find("int w_pickFileKinds", 1, true)
|
||||
check(patch:find('("GRPickerBridge.swift", ID_FILE_PICKER', 1, true),
|
||||
"iOS build patch compiles the required-import picker bridge")
|
||||
|
||||
local bootstrap = read("mobile/ios/native/GRBootstrap.m")
|
||||
check(bootstrap:find("struct utsname", 1, true)
|
||||
and bootstrap:find("SIMULATOR_MODEL_IDENTIFIER", 1, true),
|
||||
"iOS native bridge reads the hardware model on device and simulator")
|
||||
check(patch:find("int w_getDeviceModel", 1, true)
|
||||
and patch:find('{ "getDeviceModel", w_getDeviceModel }', 1, true),
|
||||
"iOS liblove patch exposes the hardware model to Lua")
|
||||
|
||||
print("ios_required_import_picker_test: ok")
|
||||
|
||||
@@ -50,20 +50,34 @@ local imp = read("src/import/RomImporter.lua")
|
||||
|
||||
check(view:find('id = "skins"', 1, true) ~= nil,
|
||||
"LauncherView registers a skins tab")
|
||||
check(view:find('id = "bug"', 1, true) ~= nil,
|
||||
"LauncherView registers a bug tab")
|
||||
check(view:find("drawSkinGlyph", 1, true) ~= nil,
|
||||
"the skins tab draws its own glyph rather than shipping an asset")
|
||||
check(view:find('assets/launcher/bug.png', 1, true) ~= nil,
|
||||
"the bug tab uses the standard bug report asset")
|
||||
-- the tab has to be next to Find, which is what the request was
|
||||
local order = view:match("local HEADER_TABS = %{(.-)%}\n")
|
||||
check(order ~= nil, "HEADER_TABS found")
|
||||
if order then
|
||||
local findAt = order:find('id = "find"', 1, true)
|
||||
local skinsAt = order:find('id = "skins"', 1, true)
|
||||
local bugAt = order:find('id = "bug"', 1, true)
|
||||
check(findAt and skinsAt and skinsAt > findAt,
|
||||
"the skins tab sits immediately after Find")
|
||||
check(skinsAt and bugAt and bugAt > skinsAt,
|
||||
"the bug tab sits after Skins")
|
||||
end
|
||||
check(view:find('imp.tab == "skins"', 1, true) ~= nil,
|
||||
"the panel dispatch routes the skins tab")
|
||||
check(view:find("buildSkinsPanel", 1, true) ~= nil, "and a panel builds it")
|
||||
check(view:find('imp.tab == "bug"', 1, true) ~= nil,
|
||||
"the panel dispatch routes the bug tab")
|
||||
check(view:find("buildBugPanel", 1, true) ~= nil, "and the bug panel builds it")
|
||||
check(view:find('Kit.toggle', 1, true) ~= nil,
|
||||
"the bug panel uses a switch for safe mode")
|
||||
check(view:find('bug-report', 1, true) ~= nil,
|
||||
"the bug panel has a report action")
|
||||
-- the panel must not offer the studio when the host did not supply it
|
||||
check(view:find("if imp.onOpenSkinStudio then", 1, true) ~= nil,
|
||||
"the Studio button is hidden without a host hook (mobile)")
|
||||
@@ -79,8 +93,15 @@ check(imp:find("_installMod", 1, true) ~= nil,
|
||||
local cycle = imp:match("local order = %{(.-)%}")
|
||||
check(cycle and cycle:find('"skins"', 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the skins tab")
|
||||
check(cycle and cycle:find('"bug"', 1, true) ~= nil,
|
||||
"shoulder-button tab cycling reaches the bug tab")
|
||||
local switch = imp:match("function RomImporter:_switchTab%(id%)(.-)\nend")
|
||||
check(switch and switch:find("_ensureSkins(true)", 1, true) ~= nil,
|
||||
"switching to the tab re-reads the skin list")
|
||||
check(imp:find('function RomImporter:_safeModeEnabled', 1, true) ~= nil
|
||||
and imp:find('function RomImporter:_toggleSafeMode', 1, true) ~= nil,
|
||||
"the importer owns the safe mode state")
|
||||
check(imp:find('function RomImporter:_reportIssue', 1, true) ~= nil,
|
||||
"the importer owns issue report opening")
|
||||
|
||||
T.finish("launcher_skins_tab")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- engine/menus/pc.asm prints the access text between SFX_ENTER_PC and the
|
||||
-- farcall: BillsPC (:73-85) picks AccessedBillsPCText / AccessedSomeonesPCText
|
||||
-- off EVENT_MET_BILL, .playersPC (:50-59) prints AccessedMyPCText. The port
|
||||
-- opened BoxMenu / PlayerPC straight from the sound (#1529).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
Data.text._TurnedOnPC1Text = "{PLAYER} turned on\nthe PC."
|
||||
Data.text._AccessedBillsPCText = "Accessed BILL's\nPC.\fAccessed POKéMON\nStorage System."
|
||||
Data.text._AccessedSomeonesPCText =
|
||||
"Accessed someone's\nPC.\fAccessed POKéMON\nStorage System."
|
||||
Data.text._AccessedMyPCText = "Accessed my PC.\fAccessed Item\nStorage System."
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
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, screens = {}, {}
|
||||
local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end }
|
||||
local textBoxStub = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { kind = "text", text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}
|
||||
local menuStub = {
|
||||
new = function(_, items, opts) return { kind = "menu", items = items, opts = opts or {} } end,
|
||||
}
|
||||
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||
package.loaded["src.ui.Menu"] = menuStub
|
||||
|
||||
local fakeGame = { data = Data, save = SaveData.newGame(), stack = stackStub }
|
||||
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||
T.check(setUpvalue(OW.openPC, "TextBox", textBoxStub), "TextBox upvalue on openPC")
|
||||
T.check(setUpvalue(OW.openPC, "Screens",
|
||||
{ push = function(_, id) screens[#screens + 1] = id end }), "Screens upvalue on openPC")
|
||||
|
||||
local fakeSelf = setmetatable({}, { __index = OW })
|
||||
|
||||
local function openMenu(metBill)
|
||||
pushed, screens = {}, {}
|
||||
fakeGame.save = SaveData.newGame()
|
||||
if metBill then fakeGame.save.flags.EVENT_MET_BILL = true end
|
||||
fakeSelf:openPC(function() end)
|
||||
local pcOn = pushed[#pushed]
|
||||
T.eq(pcOn.kind, "text", "the session opens with TurnedOnPC1Text")
|
||||
pcOn.onDone()
|
||||
local menu = pushed[#pushed]
|
||||
T.eq(menu.kind, "menu", "then the PC menu")
|
||||
return menu
|
||||
end
|
||||
|
||||
-- === SOMEONE'S PC: the access text before BoxMenu
|
||||
do
|
||||
local menu = openMenu(false)
|
||||
menu.items[1].onSelect()
|
||||
local box = pushed[#pushed]
|
||||
T.eq(box.kind, "text", "the box row opens a text box")
|
||||
T.check(tostring(box.text):find("Accessed someone's", 1, true) ~= nil,
|
||||
"before meeting BILL it is AccessedSomeonesPCText")
|
||||
T.eq(#screens, 0, "and the box screen has NOT opened yet")
|
||||
box.onDone()
|
||||
T.eq(screens[1], "BoxMenu", "BoxMenu follows the text, as the farcall does")
|
||||
end
|
||||
|
||||
-- === BILL'S PC once EVENT_MET_BILL is set
|
||||
do
|
||||
local menu = openMenu(true)
|
||||
menu.items[1].onSelect()
|
||||
local box = pushed[#pushed]
|
||||
T.check(tostring(box.text):find("Accessed BILL's", 1, true) ~= nil,
|
||||
"after meeting BILL it is AccessedBillsPCText")
|
||||
box.onDone()
|
||||
T.eq(screens[1], "BoxMenu", "and still opens BoxMenu")
|
||||
end
|
||||
|
||||
-- === the player's item storage
|
||||
do
|
||||
local menu = openMenu(false)
|
||||
menu.items[2].onSelect()
|
||||
local box = pushed[#pushed]
|
||||
T.eq(box.kind, "text", "the item row opens a text box")
|
||||
T.check(tostring(box.text):find("Accessed my PC.", 1, true) ~= nil,
|
||||
"and it is AccessedMyPCText")
|
||||
T.eq(#screens, 0, "PlayerPC has not opened yet")
|
||||
box.onDone()
|
||||
T.eq(screens[1], "PlayerPC", "PlayerPC follows the text")
|
||||
end
|
||||
|
||||
T.finish("PC access text (#1529)")
|
||||
@@ -0,0 +1,152 @@
|
||||
-- A landed secondary POISON runs PoisonEffect's tail
|
||||
-- (engine/battle/effects.asm:119-151): SHAKE_SCREEN_ANIM when the foe
|
||||
-- poisoned you, ENEMY_HUD_SHAKE_ANIM when you poisoned the foe, through
|
||||
-- PlayBattleAnimation2 (:1461-1471), which also stamps wAnimationType 6 /
|
||||
-- 3 so the slow applying shake runs even with battle animations off.
|
||||
-- FreezeBurnParalyzeEffect (:194-255) zeroes wAnimationType and only
|
||||
-- shakes the enemy HUD on the player's turn. The port queued neither
|
||||
-- (#1526).
|
||||
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)
|
||||
|
||||
Data.moves.FIX_POISON_STING = {
|
||||
id = "FIX_POISON_STING", index = 5, name = "FIX PSN STING", type = "POISON",
|
||||
power = 15, accuracy = 100, pp = 35, effect = "POISON_SIDE_EFFECT1",
|
||||
}
|
||||
|
||||
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)
|
||||
battle.rng = function() return 0 end -- every roll lands
|
||||
return battle
|
||||
end
|
||||
|
||||
local function animRows(battle)
|
||||
local rows = {}
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.anim then rows[#rows + 1] = item end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function find(rows, name)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.anim == name then return i, row end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function textIndex(battle, needle)
|
||||
for i, item in ipairs(battle.queue) do
|
||||
if item.text and item.text:find(needle, 1, true) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function animIndex(battle, name)
|
||||
for i, item in ipairs(battle.queue) do
|
||||
if item.anim == name then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the foe poisons you: SE_SHAKE_SCREEN plus wAnimationType 3
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.enemy, battle.player, { id = "FIX_POISON_STING", pp = 35 })
|
||||
T.eq(battle.player.mon.status, "PSN", "the secondary poison landed")
|
||||
local rows = animRows(battle)
|
||||
local i, row = find(rows, "SHAKE_SCREEN_ANIM")
|
||||
T.check(i ~= nil, "the enemy's turn queues SHAKE_SCREEN_ANIM")
|
||||
T.eq(row.attackerIsPlayer, false, "attributed to the enemy side")
|
||||
T.eq(row.hit and row.hit.animType, 3, "wAnimationType 3 rides the row")
|
||||
T.eq(row.animDelayed, true, "PlayBattleAnimationGotID pays no Delay3")
|
||||
local moveIdx = animIndex(battle, "FIX_POISON_STING")
|
||||
local shakeIdx = animIndex(battle, "SHAKE_SCREEN_ANIM")
|
||||
local textIdx = textIndex(battle, "poisoned")
|
||||
T.check(moveIdx and shakeIdx and moveIdx < shakeIdx,
|
||||
"the move animation still runs first")
|
||||
T.check(textIdx and shakeIdx < textIdx,
|
||||
"PlayBattleAnimation2 precedes PrintText (effects.asm:149-151)")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- you poison the foe: SE_SHAKE_ENEMY_HUD plus wAnimationType 6
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_POISON_STING", pp = 35 })
|
||||
T.eq(battle.enemy.mon.status, "PSN", "the secondary poison landed")
|
||||
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
|
||||
T.check(row ~= nil, "the player's turn queues ENEMY_HUD_SHAKE_ANIM")
|
||||
T.eq(row.attackerIsPlayer, true, "attributed to the player side")
|
||||
T.eq(row.hit and row.hit.animType, 6, "wAnimationType 6 rides the row")
|
||||
T.check(find(animRows(battle), "SHAKE_SCREEN_ANIM") == nil,
|
||||
"and never the enemy-side id")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- burn takes the FreezeBurnParalyzeEffect arms: HUD shake on the player's
|
||||
-- turn only, and no wAnimationType at all
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_EMBERISH", pp = 25 })
|
||||
T.eq(battle.enemy.mon.status, "BRN", "the secondary burn landed")
|
||||
local _, row = find(animRows(battle), "ENEMY_HUD_SHAKE_ANIM")
|
||||
T.check(row ~= nil, "the player's burn shakes the enemy HUD")
|
||||
T.eq(row.hit, nil, "FreezeBurnParalyzeEffect zeroes wAnimationType")
|
||||
|
||||
local other = newBattle()
|
||||
other.queue, other.nextInsert = {}, 0
|
||||
other:performMove(other.enemy, other.player, { id = "FIX_EMBERISH", pp = 25 })
|
||||
T.eq(other.player.mon.status, "BRN", "the enemy's burn landed too")
|
||||
T.check(find(animRows(other), "ENEMY_HUD_SHAKE_ANIM") == nil,
|
||||
"the enemy's-turn arm plays nothing")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- a plain damaging move queues neither
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_TACKLE", pp = 35 })
|
||||
local rows = animRows(battle)
|
||||
T.check(find(rows, "ENEMY_HUD_SHAKE_ANIM") == nil
|
||||
and find(rows, "SHAKE_SCREEN_ANIM") == nil,
|
||||
"no status, no PlayBattleAnimation2 row")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the residual tick plays BURN_PSN_ANIM with NO shake: core.asm:490-491
|
||||
-- explicitly zeroes wAnimationType before it
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.mon.status = "PSN"
|
||||
battle:residualFor(battle.player, battle.enemy)
|
||||
local _, row = find(animRows(battle), "BURN_PSN_ANIM")
|
||||
T.check(row ~= nil, "the poison tick animates")
|
||||
T.eq(row.hit, nil, "with no applying-attack shake")
|
||||
T.eq(row.attackerIsPlayer, true, "on the hurt mon's side")
|
||||
end
|
||||
|
||||
T.finish("secondary status animation (#1526)")
|
||||
@@ -0,0 +1,94 @@
|
||||
-- JoypadOverworld calls RunMapScript every overworld frame before input is
|
||||
-- even read (home/overworld.asm:1816-1821), and the gate guards are
|
||||
-- per-frame "is the player standing on these coords" checks
|
||||
-- (scripts/Route16Gate1F.asm:16, Route5Gate.asm:19, Route22Gate.asm:21).
|
||||
-- The port only evaluated them on a completed step, so saving on a guard's
|
||||
-- tile and reloading walked past the guard (#1547).
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
Data.tilesets.FIX_OUT.tilesPerRow = 16
|
||||
Data.field.flyWarps = Data.field.flyWarps or {}
|
||||
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
|
||||
Data.field.waterTilesets = {}
|
||||
Data.field.forcedMovement = { tiles = {} }
|
||||
Data.audio = Data.audio or {}
|
||||
Data.audio.songs = Data.audio.songs or {}
|
||||
Data.audio.mapSongs = Data.audio.mapSongs or {}
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Game = require("src.core.Game")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
local MapScripts = require("src.script.MapScripts")
|
||||
|
||||
Game.data = Data
|
||||
Game.save = SaveData.newGame()
|
||||
Game.save.player.name = "RED"
|
||||
Game.save.player.map = "FIX_TOWN"
|
||||
StateStack:init()
|
||||
Game.stack = StateStack
|
||||
Game.overworld = OverworldState
|
||||
Game.input = {
|
||||
isDown = function() return false end,
|
||||
wasPressed = function() return false end,
|
||||
step = function() end, state = {}, pressQueue = {},
|
||||
}
|
||||
Game.renderer = {
|
||||
beginWorldPass = function() end, endWorldPass = function() end,
|
||||
beginUIPass = function() end, endUIPass = function() end,
|
||||
worldViewSize = function() return 160, 144 end,
|
||||
setSGBZones = function() end,
|
||||
}
|
||||
|
||||
local TRIGGER_X, TRIGGER_Y = 4, 4
|
||||
local fired = {}
|
||||
MapScripts.attachBase("FIX_TOWN", {
|
||||
onStep = function(_, _, x, y)
|
||||
if x == TRIGGER_X and y == TRIGGER_Y then
|
||||
fired[#fired + 1] = { x, y }
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
})
|
||||
|
||||
local function loadedSave()
|
||||
local save = SaveData.newGame()
|
||||
save.player.map = "FIX_TOWN"
|
||||
save.player.x, save.player.y = TRIGGER_X, TRIGGER_Y
|
||||
return save
|
||||
end
|
||||
|
||||
-- === the exploit: F2 / CONTINUE onto the guard's tile re-fires the guard
|
||||
fired = {}
|
||||
Game:restoreSave(loadedSave(), false, { freshBoot = true })
|
||||
T.eq(#fired, 1, "a freshBoot restore re-evaluates the standing-tile trigger")
|
||||
T.same(fired[1], { TRIGGER_X, TRIGGER_Y },
|
||||
"at the coords the save left the player on")
|
||||
|
||||
-- === an ordinary warp arrival must NOT fire it
|
||||
fired = {}
|
||||
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", {})
|
||||
T.eq(#fired, 0, "a plain warp arrival still leaves the trigger to onStepComplete")
|
||||
|
||||
-- === dev tooling reuses opts.via == "boot" WITHOUT freshBoot
|
||||
fired = {}
|
||||
OverworldState:setMap("FIX_TOWN", TRIGGER_X, TRIGGER_Y, "up", { via = "boot" })
|
||||
T.eq(#fired, 0, "the console warp / hot reload shape does not fire it")
|
||||
|
||||
-- === checkpoint resume must never re-run map scripts
|
||||
fired = {}
|
||||
Game:restoreCheckpointSave(loadedSave())
|
||||
T.eq(#fired, 0, "a checkpoint resume re-runs nothing")
|
||||
|
||||
-- === restoring somewhere harmless fires nothing
|
||||
fired = {}
|
||||
local elsewhere = loadedSave()
|
||||
elsewhere.player.x, elsewhere.player.y = 3, 3
|
||||
Game:restoreSave(elsewhere, false, { freshBoot = true })
|
||||
T.eq(#fired, 0, "a restore off the trigger cell is untouched")
|
||||
|
||||
T.finish("standing-tile triggers on restore (#1547)")
|
||||
@@ -0,0 +1,87 @@
|
||||
-- The Nugget Bridge recruiter has no def_trainers header, so the port's
|
||||
-- headerless engageTrainer fallback re-printed his contest line as the
|
||||
-- pre-battle box (#1550) and his loss line never reached the battle
|
||||
-- screen (#1551). scripts/Route24.asm:120-134: .JoinTeamRocketText, then
|
||||
-- SaveEndBattleTextPointers with .DefeatedText, then EngageMapTrainer with
|
||||
-- no further box; Route24AfterRocketBattleScript (:62-78) prints
|
||||
-- .YouCouldBecomeATopLeaderText on the map after the win.
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
Data.items.NUGGET = Data.items.NUGGET
|
||||
or { id = "NUGGET", index = 49, name = "NUGGET", price = 10000 }
|
||||
Data.text._Route24CooltrainerM1YouBeatOurContestText =
|
||||
"Congratulations!\nYou beat our 5\ncontest trainers!"
|
||||
Data.text._Route24CooltrainerM1YouJustEarnedAPrizeText = "You just earned\na prize!"
|
||||
Data.text._Route24CooltrainerM1ReceivedNuggetText = "{PLAYER} got\n{RAM:wStringBuffer}!"
|
||||
Data.text._Route24CooltrainerM1JoinTeamRocketText = "Want to join us?"
|
||||
Data.text._Route24CooltrainerM1DefeatedText = "Arrgh!\nYou are good!"
|
||||
Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText =
|
||||
"With your ability,\nyou could become\na top leader!"
|
||||
|
||||
local pushed = {}
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
substitute = function(_, s) return s end,
|
||||
soundOpts = function(_, sound, opts)
|
||||
opts = opts or {}
|
||||
opts.auto = { sound = sound, wait = true, delay = 0 }
|
||||
return opts
|
||||
end,
|
||||
}
|
||||
|
||||
local scripts = dofile("data/scripts/story4.lua")
|
||||
local handler = scripts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M1
|
||||
T.check(type(handler) == "function", "the recruiter has a hand-ported handler")
|
||||
|
||||
local game = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
}
|
||||
|
||||
local defeated = false
|
||||
local engaged
|
||||
local ow = {
|
||||
trainerDefeated = function() return defeated end,
|
||||
engageTrainer = function(_, npc, onDone, endBattleText, skipBattleText)
|
||||
engaged = { npc = npc, onDone = onDone,
|
||||
endBattleText = endBattleText, skipBattleText = skipBattleText }
|
||||
end,
|
||||
}
|
||||
local npc = { id = "ROUTE24_ROCKET", def = { index = 1 } }
|
||||
|
||||
-- the prize has already been taken: the talk goes straight to the battle
|
||||
game.save.flags.EVENT_GOT_NUGGET = true
|
||||
local doneCalls = 0
|
||||
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
|
||||
|
||||
T.check(engaged ~= nil, "the recruiter engages")
|
||||
T.eq(#pushed, 0, "no text box is pushed before the battle (#1550)")
|
||||
T.eq(engaged.skipBattleText, true,
|
||||
"skipBattleText stops the map text becoming the pre-battle box")
|
||||
T.eq(engaged.endBattleText, Data.text._Route24CooltrainerM1DefeatedText,
|
||||
"the loss line rides the battle, as SaveEndBattleTextPointers does (#1551)")
|
||||
|
||||
-- the win: Route24AfterRocketBattleScript prints the top-leader line
|
||||
defeated = true
|
||||
engaged.onDone()
|
||||
T.eq(#pushed, 1, "the win prints exactly one box")
|
||||
T.eq(pushed[1].text, Data.text._Route24CooltrainerM1YouCouldBecomeATopLeaderText,
|
||||
"and it is .YouCouldBecomeATopLeaderText")
|
||||
pushed[1].onDone()
|
||||
T.eq(doneCalls, 1, "control returns once the box closes")
|
||||
|
||||
-- the blackout arm: wIsInBattle == $ff rets before the DisplayTextID
|
||||
pushed, defeated, doneCalls = {}, false, 0
|
||||
handler(game, ow, npc, function() doneCalls = doneCalls + 1 end)
|
||||
engaged.onDone()
|
||||
T.eq(#pushed, 0, "a loss prints nothing")
|
||||
T.eq(doneCalls, 1, "and just unfreezes the player")
|
||||
|
||||
T.finish("Nugget Bridge Rocket battle text (#1550, #1551)")
|
||||
@@ -41,7 +41,8 @@ _G.love = {
|
||||
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
|
||||
system = {
|
||||
getOS = function() return "iOS" end,
|
||||
getModel = function() return "iPad Test" end,
|
||||
getDeviceModel = function() return "iPhone16,2" end,
|
||||
getModel = function() return "Apple A17 Pro GPU" end,
|
||||
openURL = function(url) openedURL = url end,
|
||||
},
|
||||
graphics = {
|
||||
@@ -78,10 +79,13 @@ check(not url:find("game=", 1, true)
|
||||
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
|
||||
and fields.steps == "" and fields.expected == "",
|
||||
"report leaves user-entered fields blank")
|
||||
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
|
||||
check(info.device == "iPhone 15 Pro Max"
|
||||
and info.metadata:find("Device: iPhone 15 Pro Max", 1, true) ~= nil
|
||||
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
|
||||
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
|
||||
"report metadata includes device and app details")
|
||||
check(not info.metadata:find("Simulator GPU", 1, true),
|
||||
"report metadata does not mistake the renderer device for the device")
|
||||
check(not info.metadata:find("unknown", 1, true),
|
||||
"report metadata omits unknown values")
|
||||
check(not info.metadata:find("Game id", 1, true)
|
||||
@@ -104,25 +108,31 @@ check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
|
||||
|
||||
local previousOS = love.system.getOS
|
||||
local previousModel = love.system.getModel
|
||||
local previousDeviceModel = love.system.getDeviceModel
|
||||
local previousIO = _G.io
|
||||
love.system.getOS = function() return "OS X" end
|
||||
love.system.getModel = nil
|
||||
love.system.getDeviceModel = nil
|
||||
_G.io = {
|
||||
popen = function()
|
||||
popen = function(command)
|
||||
local output = command:find("system_profiler", 1, true)
|
||||
and "Hardware Overview:\n Model Name: MacBook Air\n Model Identifier: Mac14,15\n Chip: Apple M2\n"
|
||||
or "Mac14,15\n"
|
||||
return {
|
||||
read = function() return "MacBookPro18,3" end,
|
||||
read = function() return output end,
|
||||
close = function() end,
|
||||
}
|
||||
end,
|
||||
}
|
||||
local desktopInfo = IssueReport.metadata({}, { mods = {} })
|
||||
check(desktopInfo.device == "MacBookPro18,3",
|
||||
check(desktopInfo.device == "MacBook Air (Apple M2)",
|
||||
"report finds desktop device model when LOVE has no model")
|
||||
love.system.getOS = function() return "UWP" end
|
||||
local xboxInfo = IssueReport.metadata({}, { mods = {} })
|
||||
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
|
||||
love.system.getOS = previousOS
|
||||
love.system.getModel = previousModel
|
||||
love.system.getDeviceModel = previousDeviceModel
|
||||
_G.io = previousIO
|
||||
|
||||
local opened = IssueReport.open({ safeMode = false }, {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
-- SwitchPlayerMon (engine/battle/core.asm:2419-2423) prints RetreatMon and
|
||||
-- holds 50 frames BEFORE the outgoing pic is recalled, and only then does
|
||||
-- SendOutMon shout "Go! X!" (#1534). The port queued the send-out line
|
||||
-- alone, so the withdraw box never existed. PlayerMon2Text's adjective
|
||||
-- (engine/battle/common_text.asm:167-243) reads the ENEMY HP lost since
|
||||
-- this mon switched in, from wLastSwitchInEnemyMonHP.
|
||||
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 Timing = require("src.core.Timing")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.rng = function() return 0 end
|
||||
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
|
||||
return battle
|
||||
end
|
||||
|
||||
-- drain the queue the way updateQueue does, recording rows in order plus
|
||||
-- who was in the player slot when each row was emitted
|
||||
local function drain(battle)
|
||||
local rows = {}
|
||||
for _ = 1, 400 do
|
||||
local item = table.remove(battle.queue, 1)
|
||||
if not item then return rows end
|
||||
rows[#rows + 1] = { text = item.text, anim = item.anim,
|
||||
auto = item.auto, autoDelay = item.autoDelay,
|
||||
playerSpecies = battle.player.mon.species }
|
||||
if item.fn then
|
||||
battle.nextInsert = 0
|
||||
item.fn()
|
||||
end
|
||||
end
|
||||
error("the queue never drained")
|
||||
end
|
||||
|
||||
local function indexOf(rows, pred)
|
||||
for i, row in ipairs(rows) do
|
||||
if pred(row) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the voluntary party-menu switch: withdraw line, then the send-out
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
drain(battle) -- the intro stamps lastSwitchInEnemyHP through sendOutText
|
||||
local outgoing = battle.player.mon.species
|
||||
local oldNick = battle.player.name
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:resolveSwitch(battle.game.save.party[2])
|
||||
local rows = drain(battle)
|
||||
|
||||
local wIdx = indexOf(rows, function(r)
|
||||
return r.text and r.text:find("Come back!", 1, true) ~= nil
|
||||
end)
|
||||
T.check(wIdx ~= nil, "the withdraw line is queued")
|
||||
T.eq(rows[wIdx].text, oldNick .. " enough!\nCome back!",
|
||||
"an untouched foe gives the `enough!` variant")
|
||||
T.eq(rows[wIdx].auto, true, "the page ends `done`, so it never waits on A")
|
||||
T.eq(rows[wIdx].autoDelay, Timing.SWITCH_PLAYER_MON,
|
||||
"it holds the 50 frames DelayFrames pays (core.asm:2421-2422)")
|
||||
T.eq(rows[wIdx].playerSpecies, outgoing,
|
||||
"the outgoing mon is still in the slot while the line prints")
|
||||
|
||||
local sIdx = indexOf(rows, function(r)
|
||||
return r.text and r.text:find("! ", 1, true) and r.text:find("Come back!", 1, true) == nil
|
||||
end)
|
||||
T.check(sIdx ~= nil and sIdx > wIdx, "the send-out shout follows the withdraw line")
|
||||
T.eq(rows[sIdx].auto, true, "the send-out page ends `done` too (#1472)")
|
||||
T.neq(rows[sIdx].playerSpecies, outgoing, "the swap happened between the two")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the adjective branches on enemy HP lost since the switch-in
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
drain(battle)
|
||||
local nick = battle.player.name
|
||||
local max = battle.enemy.mon.stats.hp
|
||||
local quarter = math.floor(max / 4)
|
||||
local function withdrawAt(dropPercent)
|
||||
battle.lastSwitchInEnemyHP = max
|
||||
battle.enemy.mon.hp = max - math.floor(dropPercent * quarter / 25)
|
||||
return battle:withdrawText(nick)
|
||||
end
|
||||
T.eq(withdrawAt(0), nick .. " enough!\nCome back!", "no damage -> `enough!`")
|
||||
T.eq(withdrawAt(50), nick .. " OK!\nCome back!", "30-69 -> `OK!`")
|
||||
T.eq(withdrawAt(80), nick .. " good!\nCome back!", "70+ -> `good!`")
|
||||
T.eq(withdrawAt(10), nick .. " \nCome back!", "1-29 -> no adjective at all")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- ChooseNextMon (core.asm:1086-1128) calls SendOutMon with NO RetreatMon
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
drain(battle)
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.mon.hp = 0
|
||||
battle:openReplacementMenu()
|
||||
local rows = drain(battle)
|
||||
T.check(indexOf(rows, function(r)
|
||||
return r.text and r.text:find("Come back!", 1, true) ~= nil
|
||||
end) == nil, "the post-faint replacement prints no withdraw line")
|
||||
end
|
||||
|
||||
T.finish("switch withdraw text (#1534)")
|
||||
@@ -0,0 +1,109 @@
|
||||
-- THRASH/PETAL DANCE is a SpecialEffectsCont entry
|
||||
-- (data/battle/special_effects.asm:22), so on the SETUP turn only,
|
||||
-- engine/battle/core.asm:3129-3133 runs ThrashPetalDanceEffect before
|
||||
-- damage; it ends in PlayBattleAnimation2 with SHRINKING_SQUARE_ANIM
|
||||
-- (ANIM_B1 on the enemy's turn) plus the slow horizontal screen shake
|
||||
-- (engine/battle/effects.asm:791-808, :1461-1471). The port queued only
|
||||
-- the move's own animation (#1532).
|
||||
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)
|
||||
|
||||
Data.moves.FIX_THRASH = {
|
||||
id = "FIX_THRASH", index = 91, name = "FIX THRASH", type = "NORMAL",
|
||||
power = 90, accuracy = 100, pp = 20, effect = "THRASH_PETAL_DANCE_EFFECT",
|
||||
}
|
||||
|
||||
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)
|
||||
battle.rng = function(a) if a then return a end return 0 end
|
||||
return battle
|
||||
end
|
||||
|
||||
local function animRows(battle)
|
||||
local rows = {}
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.anim then
|
||||
rows[#rows + 1] = { anim = item.anim, hit = item.hit,
|
||||
attackerIsPlayer = item.attackerIsPlayer }
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function indexOf(rows, name)
|
||||
for i, row in ipairs(rows) do
|
||||
if row.anim == name then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the player's setup turn: the effect animation precedes the move's own
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
local slot = { id = "FIX_THRASH", pp = 20 }
|
||||
battle:performMove(battle.player, battle.enemy, slot)
|
||||
local rows = animRows(battle)
|
||||
local setup = indexOf(rows, "SHRINKING_SQUARE_ANIM")
|
||||
local move = indexOf(rows, "FIX_THRASH")
|
||||
T.check(setup ~= nil, "the setup turn queues SHRINKING_SQUARE_ANIM")
|
||||
T.check(move ~= nil and setup < move,
|
||||
"it plays BEFORE PlayPlayerMoveAnimation, as SpecialEffectsCont runs first")
|
||||
T.eq(rows[setup].attackerIsPlayer, true, "on the player's side")
|
||||
T.eq(rows[setup].hit and rows[setup].hit.animType, 6,
|
||||
"wAnimationType 6 -> ShakeScreenHorizontallySlow2 on the player's turn")
|
||||
|
||||
-- the continuation turn never reaches the effect (.ThrashingAboutCheck,
|
||||
-- core.asm:3532-3550 jumps straight to PlayerCalcMoveDamage)
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.player, battle.enemy, slot)
|
||||
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
|
||||
"a locked-in Thrash queues no setup animation")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the enemy's turn takes ANIM_B1 and wAnimationType 3
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:performMove(battle.enemy, battle.player, { id = "FIX_THRASH", pp = 20 })
|
||||
local rows = animRows(battle)
|
||||
local setup = indexOf(rows, "ANIM_B1")
|
||||
T.check(setup ~= nil, "the enemy's setup turn queues ANIM_B1")
|
||||
T.eq(rows[setup].attackerIsPlayer, false, "on the enemy's side")
|
||||
T.eq(rows[setup].hit and rows[setup].hit.animType, 3,
|
||||
"wAnimationType 3 -> ShakeScreenHorizontallySlow on the enemy's turn")
|
||||
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") == nil,
|
||||
"and never the player-side id")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- a missed setup turn still runs the effect (it precedes MoveHitTest)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.accuracyRoll = function() return false end
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
|
||||
local rows = animRows(battle)
|
||||
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")
|
||||
end
|
||||
|
||||
T.finish("thrash setup animation (#1532)")
|
||||
@@ -0,0 +1,120 @@
|
||||
-- _TrainerSentOutText (data/text/text_2.asm:923) and the
|
||||
-- Go!/Do it!/Get'm! chain ending in _PlayerMon1Text (:1274-1294) end in
|
||||
-- `done`, not `prompt`: PrintText returns and the flow runs straight into
|
||||
-- AnimateSendingOutMon + PlayCry (engine/battle/core.asm:1421-1434,
|
||||
-- :1723-1765). _AIBattleWithdrawText (:1-7) does end in `prompt` and
|
||||
-- keeps its button wait. The port made every send-out box wait (#1472).
|
||||
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", 30), Pokemon.new(Data, "FIXMON_B", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 30)
|
||||
battle.rng = function() return 0 end
|
||||
battle.enemyAction = function() return { id = "FIX_SCRATCH", pp = 35 } end
|
||||
return battle
|
||||
end
|
||||
|
||||
local function rowWith(battle, needle)
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.text and item.text:find(needle, 1, true) then return item end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the voluntary switch: the shout auto-continues into the send-out anim
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:resolveSwitch(battle.game.save.party[2])
|
||||
-- run the first act so the nested switch rows land in the queue
|
||||
local first = table.remove(battle.queue, 1)
|
||||
battle.nextInsert = 0
|
||||
first.fn()
|
||||
local withdraw = rowWith(battle, "Come back!")
|
||||
T.check(withdraw ~= nil, "the withdraw line is queued")
|
||||
T.eq(withdraw.auto, true, "RetreatMon's page ends `done` (#1534)")
|
||||
local swap = table.remove(battle.queue, 2)
|
||||
battle.nextInsert = 1
|
||||
swap.fn()
|
||||
local shout = rowWith(battle, battle.player.name)
|
||||
T.check(shout ~= nil, "the send-out shout is queued")
|
||||
T.eq(shout.auto, true, "_PlayerMon1Text ends `done`, so no button wait")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the AI switch: the sent-out box goes auto, the withdraw box does not,
|
||||
-- and EnemySendOut's grow-in + cry now follow it
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
battle.rng = function() return 0 end
|
||||
battle.enemyParty = { Pokemon.new(Data, "FIXMON_B", 30),
|
||||
Pokemon.new(Data, "FIXMON_C", 30) }
|
||||
battle.enemyIndex = 1
|
||||
battle.enemy = battle.enemy or nil
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:executeAction(battle.enemy, battle.player,
|
||||
{ special = "aiSwitch", index = 2 })
|
||||
local withdrew = rowWith(battle, "with-")
|
||||
local sent = rowWith(battle, "sent")
|
||||
T.check(withdrew ~= nil, "the AI withdraw line is queued")
|
||||
T.eq(withdrew.auto, nil, "_AIBattleWithdrawText ends `prompt` and still waits")
|
||||
T.check(sent ~= nil, "the sent-out line is queued")
|
||||
T.eq(sent.auto, true, "_TrainerSentOutText ends `done`")
|
||||
T.eq(battle.enemySendingOut, true,
|
||||
"the new pic stays hidden until AnimateSendingOutMon")
|
||||
local acts = 0
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.fn then acts = acts + 1 end
|
||||
end
|
||||
T.check(acts >= 1, "EnemySendOut queues the grow-in act after the text")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the post-faint replacement (ChooseNextMon -> SendOutMon, core.asm:1124)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
local pushedUI
|
||||
battle.game.stack.push = function(_, s) pushedUI = s end
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.mon.hp = 0
|
||||
battle:openReplacementMenu()
|
||||
local onSwitch
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.ui then
|
||||
local screen = item.ui()
|
||||
onSwitch = screen and screen.onSwitch
|
||||
end
|
||||
end
|
||||
onSwitch = onSwitch or (pushedUI and pushedUI.onSwitch)
|
||||
if onSwitch then
|
||||
battle.nextInsert = 0
|
||||
onSwitch(battle.game.save.party[2])
|
||||
local shout = rowWith(battle, battle.player.name)
|
||||
T.check(shout ~= nil, "the replacement shout is queued")
|
||||
T.eq(shout.auto, true, "SendOutMon's message ends `done` here too")
|
||||
else
|
||||
T.check(false, "the replacement menu offers an onSwitch callback")
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("trainer send-out boxes (#1472)")
|
||||
@@ -96,6 +96,8 @@ local DATA = {
|
||||
CYNDAQUIL = {
|
||||
id = "CYNDAQUIL", name = "CYNDAQUIL", dex = 155, index = 155,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW",
|
||||
-- data/pokemon/base_stats/cyndaquil.asm:10 GENDER_F12_5
|
||||
genderRatio = 31,
|
||||
types = { "FIRE", "FIRE" },
|
||||
baseStats = {
|
||||
hp = 39, attack = 52, defense = 43, speed = 65,
|
||||
@@ -105,6 +107,8 @@ local DATA = {
|
||||
TOTODILE = {
|
||||
id = "TOTODILE", name = "TOTODILE", dex = 158, index = 158,
|
||||
growthRate = "GROWTH_MEDIUM_SLOW",
|
||||
-- data/pokemon/base_stats/totodile.asm:10 GENDER_F12_5
|
||||
genderRatio = 31,
|
||||
types = { "WATER", "WATER" },
|
||||
baseStats = {
|
||||
hp = 50, attack = 65, defense = 64, speed = 43,
|
||||
@@ -163,8 +167,11 @@ end
|
||||
-- is `levelMoves` and Gen 1 reads level1Moves / learnset.
|
||||
local function mon(species, level, opts)
|
||||
opts = opts or {}
|
||||
-- engine/pokemon/mon_stats.asm:126 GetGender reads the Attack DV, so the
|
||||
-- gender a page prints follows the DVs, not a hand-set field
|
||||
local built = Mon.new(DATA, species, level, {
|
||||
dvs = { attack = 15, defense = 15, speed = 15, special = 15 },
|
||||
dvs = opts.dvs
|
||||
or { attack = 15, defense = 15, speed = 15, special = 15 },
|
||||
moves = opts.moves,
|
||||
})
|
||||
for key, value in pairs(opts.fields or {}) do built[key] = value end
|
||||
@@ -177,12 +184,13 @@ local CYNDA = mon("CYNDAQUIL", 12, {
|
||||
{ id = "EMBER", pp = 25, maxPp = 25 },
|
||||
{ id = "LEER", pp = 7, maxPp = 30 },
|
||||
},
|
||||
fields = { nickname = "CYNDAQUIL", gender = "male", item = "BERRY",
|
||||
fields = { nickname = "CYNDAQUIL", item = "BERRY",
|
||||
otName = "GOLD", otId = 12345 },
|
||||
})
|
||||
local TOTO = mon("TOTODILE", 10, {
|
||||
moves = { { id = "SURF", pp = 15, maxPp = 15 } },
|
||||
fields = { nickname = "TOTODILE", gender = "female" },
|
||||
dvs = { attack = 0, defense = 15, speed = 15, special = 15 },
|
||||
fields = { nickname = "TOTODILE" },
|
||||
})
|
||||
|
||||
local SAVE = { player = { name = "GOLD", id = 12345 }, party = { CYNDA, TOTO } }
|
||||
|
||||
@@ -226,6 +226,10 @@ return { spawns = { SPAWN_NEW_BARK = { map = "TEST_MAP", x = 1, y = 1 } } }
|
||||
spawnAfterChampion = "SPAWN_LANCE",
|
||||
position = { map = "PLAYERS_HOUSE_2F", x = 1, y = 1, facing = "down" } }),
|
||||
"and the post-credits spawn is a warp even though a position exists")
|
||||
|
||||
love.filesystem.remove("data/generated/maps.lua")
|
||||
love.filesystem.remove("data/generated/tilesets.lua")
|
||||
love.filesystem.remove("data/generated/landmarks.lua")
|
||||
end
|
||||
|
||||
-- ---- the two clock faces ----------------------------------------------------
|
||||
|
||||
@@ -2876,8 +2876,9 @@ check(selGame.save.registeredItem == nil,
|
||||
eq(selWorld:useSelectItem(), "not_registered",
|
||||
"SELECT with nothing registered answers not_registered")
|
||||
|
||||
-- The PACK side: SELECT on a highlighted row is RegisterItem, the one PACK
|
||||
-- button this port left unbound.
|
||||
-- The PACK side: the item submenu's SEL row is RegisterItem's only door --
|
||||
-- the cart's SELECT is the bag's own item shuffle
|
||||
-- (engine/items/pack.asm:1290 Pack_InterpretJoypad .select).
|
||||
selGame.save.inventory.POTION = 3
|
||||
selGame.input = stubInput()
|
||||
local selPack = PackMenu.new(selGame, { pocket = "ITEM" })
|
||||
@@ -2885,7 +2886,18 @@ selPack.index = 1
|
||||
check(selPack.rows[1].id == "POTION", "the ITEM pocket row under test")
|
||||
selGame.input:press("select")
|
||||
selPack:update(0)
|
||||
check(selPack.message ~= nil, "SELECT on a row opens RegisteredItemText")
|
||||
eq(selPack.switching, 1, "SELECT on a row arms the item shuffle")
|
||||
check(selGame.save.registeredItem == nil, "and registers nothing")
|
||||
selGame.input:press("b")
|
||||
selPack:update(0)
|
||||
check(selPack.switching == nil and selPack.message == nil,
|
||||
"B backs out of the shuffle")
|
||||
selPack:openSubmenu()
|
||||
check(table.concat(selPack.submenu.rows, ","):find("sel", 1, true) ~= nil,
|
||||
"the POTION submenu offers the SEL row")
|
||||
selPack:closeSubmenu()
|
||||
selPack:registerSelected()
|
||||
check(selPack.message ~= nil, "SEL opens RegisteredItemText")
|
||||
eq(selGame.save.registeredItem.id, "POTION",
|
||||
"and World:registerItem actually ran")
|
||||
selGame.input:press("a")
|
||||
@@ -2897,7 +2909,9 @@ selPack.index = selPack:total()
|
||||
selGame.save.registeredItem = nil
|
||||
selGame.input:press("select")
|
||||
selPack:update(0)
|
||||
check(selPack.message == nil, "SELECT on CANCEL registers nothing")
|
||||
check(selPack.switching == nil and selPack.message == nil,
|
||||
"SELECT on CANCEL arms nothing")
|
||||
selPack:registerSelected()
|
||||
check(selGame.save.registeredItem == nil, "and the slot stays empty")
|
||||
|
||||
-- CantRegisterText: a TM/HM row refuses from the PACK too.
|
||||
@@ -2906,9 +2920,8 @@ selPack:rebuild()
|
||||
local tmPack = PackMenu.new(selGame, { pocket = "TM_HM" })
|
||||
tmPack.index = 1
|
||||
check(tmPack.rows[1].id == "HM_CUT", "the TM/HM pocket row under test")
|
||||
selGame.input:press("select")
|
||||
tmPack:update(0)
|
||||
check(tmPack.message ~= nil, "SELECT on the HM still opens a message")
|
||||
tmPack:registerSelected()
|
||||
check(tmPack.message ~= nil, "SEL on the HM still opens a message")
|
||||
check(selGame.save.registeredItem == nil,
|
||||
"CantRegisterText: the HM never becomes the registered item")
|
||||
end
|
||||
|
||||
@@ -315,8 +315,9 @@ local vanillaSave = SaveData.newGame(Data.field.boot)
|
||||
-- special_warps.asm NewGameWarp is REDS_HOUSE_2F, 3, 6 -- the bedroom. This
|
||||
-- previously asserted PALLET_TOWN (5, 6), which is where you stand after
|
||||
-- walking out of the house, so a new game skipped Red's house entirely.
|
||||
-- Red/Blue land facing up (#944); only Yellow keeps boot.startFacing.
|
||||
check(vanillaSave.player.map == "REDS_HOUSE_2F" and vanillaSave.player.x == 3
|
||||
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "down",
|
||||
and vanillaSave.player.y == 6 and vanillaSave.player.facing == "up",
|
||||
"the seeded boot config reproduces the NewGameWarp bedroom spawn")
|
||||
check(vanillaSave.player.name == "RED" and vanillaSave.player.rival == "BLUE"
|
||||
and vanillaSave.money == 3000, "the seeded boot config reproduces the Red start")
|
||||
|
||||
@@ -276,6 +276,8 @@ do
|
||||
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
|
||||
if game.stringBuffer then
|
||||
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
|
||||
-- wNameBuffer reads the same buffer in this port (TextBox.TOKENS.RAM)
|
||||
text = text:gsub("{RAM:wNameBuffer}", game.stringBuffer)
|
||||
end
|
||||
text = text:gsub("{[%w_:]+}", "")
|
||||
return text
|
||||
|
||||
+44
-30
@@ -282,13 +282,19 @@ local function optGame()
|
||||
end
|
||||
local om = OptionsMenu.new(optGame())
|
||||
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
|
||||
"battleFit", "battleBg", "uiLayout",
|
||||
"battleFit", "battleHud", "battleBg", "uiLayout",
|
||||
"ruleset", "musicVol", "sfxVol", "musicFilter",
|
||||
"performance", "colors",
|
||||
"tilt", "gbcfx", "zoom", "voidFill", "videoMode",
|
||||
"faithfulRes", "fpsCap",
|
||||
"speedOverworld", "speedBattle", "speedMenu",
|
||||
"mods", "controls", "dateFormat", "timeFormat" }
|
||||
local function orow(menu, id)
|
||||
for _, row in ipairs(menu.rows) do
|
||||
if row.id == id then return row end
|
||||
end
|
||||
error("no options row '" .. id .. "'")
|
||||
end
|
||||
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
|
||||
for i, id in ipairs(WANT_IDS) do
|
||||
check(om.rows[i].id == id, "options row order: " .. id)
|
||||
@@ -296,11 +302,12 @@ end
|
||||
|
||||
-- ruleset row cycles the sorted non-hidden registry ids showing name
|
||||
om.game.save.options.ruleset = "gen1_faithful"
|
||||
check(om.rows[8].value(om.game) == "GEN 1", "ruleset row shows record.name")
|
||||
om.rows[8].step(om.game, 1)
|
||||
check(orow(om, "ruleset").value(om.game) == "GEN 1",
|
||||
"ruleset row shows record.name")
|
||||
orow(om, "ruleset").step(om.game, 1)
|
||||
check(om.game.save.options.ruleset == "modern_clean",
|
||||
"ruleset row cycles sorted registry ids")
|
||||
om.rows[8].step(om.game, 1)
|
||||
orow(om, "ruleset").step(om.game, 1)
|
||||
check(om.game.save.options.ruleset == "gen1_faithful",
|
||||
"hidden rulesets are excluded from the cycle")
|
||||
|
||||
@@ -319,42 +326,42 @@ check(om.game.save.options.battleLayout == "wide", "battle layout flips to WIDE"
|
||||
check(om.rows[4].value(om.game) == "WIDE", "the WIDE layout renders its label")
|
||||
om.rows[4].step(om.game, 1)
|
||||
check(om.game.save.options.battleLayout == "og", "battle layout flips back")
|
||||
om.rows[9].step(om.game, -1)
|
||||
orow(om, "musicVol").step(om.game, -1)
|
||||
check(om.game.save.options.musicVol == 6, "music volume steps down")
|
||||
for _ = 1, 10 do om.rows[9].step(om.game, -1) end
|
||||
for _ = 1, 10 do orow(om, "musicVol").step(om.game, -1) end
|
||||
check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
|
||||
|
||||
-- ZOOM / VOID FILL rows (indices track WANT_IDS above; the battle
|
||||
-- composition rows -- BATTLE SIZE / BATTLE BG / UI LAYOUT -- sit ahead of
|
||||
-- RULESET, and FAITHFUL RATIO lands between VIDEO MODE and MAX FPS)
|
||||
-- ZOOM / VOID FILL rows (looked up by id; WANT_IDS above pins the order)
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
om.game.save.options.zoom = 0
|
||||
Zoom.offset = 0
|
||||
check(om.rows[16].value(om.game) == "FIT", "ZOOM row shows FIT at offset 0")
|
||||
om.rows[16].step(om.game, 1)
|
||||
check(orow(om, "zoom").value(om.game) == "FIT",
|
||||
"ZOOM row shows FIT at offset 0")
|
||||
orow(om, "zoom").step(om.game, 1)
|
||||
check(om.game.save.options.zoom == 1 and Zoom.offset == 1,
|
||||
"ZOOM row steps to IN1")
|
||||
om.rows[17].step(om.game, 1)
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "water"
|
||||
and TileRenderer.voidFill == "water",
|
||||
"VOID FILL row cycles TREES → WATER")
|
||||
om.rows[17].step(om.game, 1)
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "black", "VOID FILL steps to BLACK")
|
||||
om.rows[17].step(om.game, 1)
|
||||
orow(om, "voidFill").step(om.game, 1)
|
||||
check(om.game.save.options.voidFill == "trees", "VOID FILL wraps to TREES")
|
||||
|
||||
-- the MAX FPS row cycles the render-cap steps and shows the value plain
|
||||
om.game.save.options.fpsCap = nil
|
||||
check(om.rows[20].value(om.game) == "60",
|
||||
check(orow(om, "fpsCap").value(om.game) == "60",
|
||||
"MAX FPS row defaults to 60 with no saved cap")
|
||||
om.rows[20].step(om.game, 1)
|
||||
orow(om, "fpsCap").step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
|
||||
check(om.rows[20].value(om.game) == "75", "the MAX FPS row renders the cap")
|
||||
check(orow(om, "fpsCap").value(om.game) == "75",
|
||||
"the MAX FPS row renders the cap")
|
||||
om.game.save.options.fpsCap = 160
|
||||
om.rows[20].step(om.game, 1)
|
||||
orow(om, "fpsCap").step(om.game, 1)
|
||||
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
|
||||
om.rows[20].step(om.game, -1)
|
||||
orow(om, "fpsCap").step(om.game, -1)
|
||||
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
|
||||
|
||||
-- ------- FrameCap normalize / cycle (issue #88)
|
||||
@@ -384,7 +391,7 @@ check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 6
|
||||
-- the MODS row is the manager's discoverable home
|
||||
local mgGame = optGame()
|
||||
om = OptionsMenu.new(mgGame)
|
||||
om.rows[24].activate(mgGame)
|
||||
orow(om, "mods").activate(mgGame)
|
||||
check(getmetatable(mgGame.stack:top()) == ManagerState,
|
||||
"the MODS row opens the manager")
|
||||
check(mgGame.stack:top().screenId == "ManagerState",
|
||||
@@ -394,7 +401,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
|
||||
local BindingsMenu = require("src.ui.BindingsMenu")
|
||||
local cbGame = optGame()
|
||||
om = OptionsMenu.new(cbGame)
|
||||
om.rows[25].activate(cbGame)
|
||||
orow(om, "controls").activate(cbGame)
|
||||
local bm = cbGame.stack:top()
|
||||
check(getmetatable(bm) == BindingsMenu,
|
||||
"the CONTROLS row opens the rebind list")
|
||||
@@ -413,17 +420,17 @@ check(cbGame.save.options.bindings == nil,
|
||||
-- engine UI and mods without becoming checkpoint progress
|
||||
om.game.save.options.dateFormat = "device"
|
||||
om.game.save.options.timeFormat = "device"
|
||||
check(om.rows[26].value(om.game) == "DEVICE",
|
||||
check(orow(om, "dateFormat").value(om.game) == "DEVICE",
|
||||
"DATE FORMAT defaults to device locale")
|
||||
om.rows[26].step(om.game, 1)
|
||||
orow(om, "dateFormat").step(om.game, 1)
|
||||
check(om.game.save.options.dateFormat == "dmy"
|
||||
and om.rows[26].value(om.game) == "DD-MM-YYYY",
|
||||
and orow(om, "dateFormat").value(om.game) == "DD-MM-YYYY",
|
||||
"DATE FORMAT exposes deterministic DMY override")
|
||||
check(om.rows[27].value(om.game) == "DEVICE",
|
||||
check(orow(om, "timeFormat").value(om.game) == "DEVICE",
|
||||
"TIME FORMAT defaults to device locale")
|
||||
om.rows[27].step(om.game, 1)
|
||||
orow(om, "timeFormat").step(om.game, 1)
|
||||
check(om.game.save.options.timeFormat == "24h"
|
||||
and om.rows[27].value(om.game) == "24 HOUR",
|
||||
and orow(om, "timeFormat").value(om.game) == "24 HOUR",
|
||||
"TIME FORMAT exposes deterministic 24-hour override")
|
||||
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
|
||||
"no raw-input claim until a capture is armed")
|
||||
@@ -583,11 +590,19 @@ check(not fpm.submenu and forced == fgame.save.party[1],
|
||||
|
||||
-- ------- issues #320/#385: the STRENGTH texts print over the party menu
|
||||
do
|
||||
-- PartyMenu delegates the move to OverworldState:useStrengthFieldMove;
|
||||
-- parity_I_M covers that side, this one covers what the menu does after
|
||||
local owStub = { strengthActive = false,
|
||||
map = { def = { tileset = "OVERWORLD" } }, dark = false,
|
||||
partyKnows = function(self, id) return self.knows == id end,
|
||||
knows = "STRENGTH" }
|
||||
local sgame = partyGame()
|
||||
owStub.useStrengthFieldMove = function(self, _mon, onClose)
|
||||
self.strengthActive = true
|
||||
sgame.stack:push(require("src.render.TextBox").new(
|
||||
sgame, "used\nSTRENGTH.", onClose))
|
||||
return true
|
||||
end
|
||||
sgame.overworld = owStub
|
||||
sgame.data.text = {} -- the strength texts fall back to Strings sources
|
||||
sgame.save.inventory.RAINBOWBADGE = 1
|
||||
@@ -1233,7 +1248,7 @@ local Loader = require("src.mods.Loader")
|
||||
local uiFiles = {
|
||||
["mods/uikit/manifest.json"] =
|
||||
'{"id":"uikit","name":"uikit","version":"1.0.0","entry":"main.lua","api":2}',
|
||||
["mods/uikit/main.lua"] = "return function(mod) _G.MOD_UI_API = mod end",
|
||||
["mods/uikit/main.lua"] = "return function(mod) mod.exports.api = mod end",
|
||||
}
|
||||
local uiFs = {
|
||||
read = function(path) return uiFiles[path] end,
|
||||
@@ -1267,8 +1282,7 @@ local uiFs = {
|
||||
}
|
||||
local uiLoader = Loader.new({ fs = uiFs })
|
||||
check(uiLoader:load({}) == true, "the uikit fixture loads clean")
|
||||
local uiApi = _G.MOD_UI_API
|
||||
_G.MOD_UI_API = nil
|
||||
local uiApi = (uiLoader.exports.uikit or {}).api
|
||||
check(uiApi ~= nil, "the entry chunk received its api")
|
||||
check(uiApi.ui == ModUI, "mod.ui is the toolkit facade")
|
||||
check(uiApi.ui.Theme == Theme, "mod.ui.Theme reaches the theme module")
|
||||
|
||||
@@ -125,6 +125,7 @@ do
|
||||
function tb:enemyAction() return { special = "bound" } end
|
||||
tb:resolveSwitch(Game.save.party[2])
|
||||
acts[1]() -- send-out clears foe trap
|
||||
acts[#acts]()
|
||||
eq(tb.enemy.trappingTurns, nil, "player switch clears foe Wrap/Bind/etc.")
|
||||
eq(tb.enemy.trapMove, nil, "player switch clears trapMove")
|
||||
check(tb:fightLockedAction(tb.player) == nil,
|
||||
|
||||
@@ -222,7 +222,7 @@ eq(next(t), nil, "already-left Route 25 enter is a no-op")
|
||||
Commands.hide_object = realHide
|
||||
Commands.show_object = realShow
|
||||
package.loaded["src.ui.Menu"] = realMenu
|
||||
if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end
|
||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||
package.loaded["src.core.Music"] = realMusic
|
||||
package.loaded["src.core.Sound"] = realSound
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -35,6 +35,13 @@ local WILD_SONG = Data.audio.battle.wild
|
||||
-- every song request in order: "the theme was never restored" and "the theme
|
||||
-- was never started" have to read differently. Music.playMap still sets the
|
||||
-- state Music.restoreMap reads, so the real restore path is under test.
|
||||
-- singletons this file stands doubles on; the originals go back at the tail,
|
||||
-- or a later suite in the same process inherits them (a stubbed startWarpTo
|
||||
-- eats every warp after this one)
|
||||
local realPlay, realPlayBattle = Music.play, Music.playBattle
|
||||
local realStartWarpTo = OW.startWarpTo
|
||||
local realEvents = Runtime.events
|
||||
|
||||
local songs = {}
|
||||
Music.play = function(_, song) songs[#songs + 1] = song end
|
||||
local function lastSong() return songs[#songs] end
|
||||
@@ -160,4 +167,8 @@ eq(labResult, "lose", "it still finishes as a loss")
|
||||
eq(Game.save.money, 3000, "no money is lost in the lab")
|
||||
eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript")
|
||||
|
||||
OW.startWarpTo = realStartWarpTo
|
||||
Music.play, Music.playBattle = realPlay, realPlayBattle
|
||||
Runtime.install(realEvents, Runtime.hooks)
|
||||
|
||||
S.finish()
|
||||
|
||||
@@ -33,7 +33,8 @@ local function battleWith(partyHP, result)
|
||||
for i, hp in ipairs(partyHP) do
|
||||
party[i] = { species = "SQUIRTLE", hp = hp, stats = { hp = 20 } }
|
||||
end
|
||||
return {
|
||||
-- the metatable so playerMonFainted can reach playerPartyView
|
||||
return setmetatable({
|
||||
kind = "wild",
|
||||
result = result,
|
||||
afterQueue = nil,
|
||||
@@ -44,7 +45,7 @@ local function battleWith(partyHP, result)
|
||||
sayNext = function(self, m) self.said[#self.said + 1] = m end,
|
||||
say = function(self, m) self.said[#self.said + 1] = m end,
|
||||
ui = function() end,
|
||||
}
|
||||
}, BattleState)
|
||||
end
|
||||
|
||||
local function saidBlackout(b)
|
||||
|
||||
+36
-41
@@ -2642,7 +2642,6 @@ do
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local GameSpeed = require("src.core.GameSpeed")
|
||||
local VideoMode = require("src.core.VideoMode")
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
local SD = require("src.core.SaveData")
|
||||
-- Isolate from earlier save/options writes in this suite
|
||||
@@ -2663,6 +2662,19 @@ do
|
||||
om:update(1 / 60)
|
||||
OInput.pressed = {}
|
||||
end
|
||||
-- walk the cursor down to a row by id, so a row added to OptionsMenu
|
||||
-- shifts these blocks instead of silently retargeting them
|
||||
local function seek(id)
|
||||
local want = -1
|
||||
for i, row in ipairs(om.rows) do
|
||||
if row.id == id then want = i end
|
||||
end
|
||||
for _ = 1, #om.rows do
|
||||
if om.index == want then break end
|
||||
press("down")
|
||||
end
|
||||
return om.index == want
|
||||
end
|
||||
eq(og.save.options.textSpeed, 3,
|
||||
"new saves default to MEDIUM text (InitOptions TEXT_DELAY_MEDIUM)")
|
||||
eq(og.save.options.colors, "gbc", "new saves default COLORS to GBC")
|
||||
@@ -2673,65 +2685,58 @@ do
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"new saves default VIDEO MODE to WINDOWED")
|
||||
eq(om.scroll, 0, "options viewport starts at the top")
|
||||
for _ = 1, 3 do press("down") end
|
||||
eq(om.index, 4, "cursor reaches BATTLE LAYOUT")
|
||||
check(seek("battleLayout"), "cursor reaches BATTLE LAYOUT")
|
||||
press("a")
|
||||
eq(og.save.options.battleLayout, "wide",
|
||||
"A switches the battle screen to the WIDE layout")
|
||||
press("a")
|
||||
eq(og.save.options.battleLayout, "og", "BATTLE LAYOUT wraps back to OG")
|
||||
for _ = 1, 5 do press("down") end
|
||||
eq(om.index, 9, "cursor reaches MUSIC VOL")
|
||||
eq(om.scroll, 5, "viewport scrolls to keep MUSIC VOL on screen")
|
||||
check(seek("musicVol"), "cursor reaches MUSIC VOL")
|
||||
eq(om.scroll, om.index - require("src.ui.OptionRows").VISIBLE,
|
||||
"viewport scrolls to keep MUSIC VOL on screen")
|
||||
press("left")
|
||||
eq(og.save.options.musicVol, 6, "left lowers MUSIC VOL")
|
||||
press("right")
|
||||
eq(og.save.options.musicVol, 7, "right raises MUSIC VOL back")
|
||||
press("right")
|
||||
eq(og.save.options.musicVol, 7, "MUSIC VOL clamps at 7")
|
||||
press("down"); press("left")
|
||||
seek("sfxVol"); press("left")
|
||||
eq(og.save.options.sfxVol, 6, "SFX VOL adjusts on its own row")
|
||||
press("down")
|
||||
seek("musicFilter")
|
||||
for _ = 1, 3 do press("a") end
|
||||
eq(og.save.options.musicFilter, 3, "A cycles MUSIC FILTER to 3X")
|
||||
press("a")
|
||||
eq(og.save.options.musicFilter, 0, "MUSIC FILTER wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 12, "cursor reaches PERFORMANCE")
|
||||
check(seek("performance"), "cursor reaches PERFORMANCE")
|
||||
press("a")
|
||||
eq(og.save.options.performance, "high", "A cycles PERFORMANCE to HIGH")
|
||||
eq(require("src.core.Performance").tier, "high",
|
||||
"the live tier tracks the PERFORMANCE option")
|
||||
for _ = 1, 3 do press("a") end
|
||||
eq(og.save.options.performance, "auto", "PERFORMANCE wraps back to AUTO")
|
||||
press("down")
|
||||
eq(om.index, 13, "cursor reaches COLORS")
|
||||
check(seek("colors"), "cursor reaches COLORS")
|
||||
press("a")
|
||||
for _ = 1, 4 do press("a") end
|
||||
press("down")
|
||||
eq(om.index, 14, "cursor reaches TILT")
|
||||
check(seek("tilt"), "cursor reaches TILT")
|
||||
press("a")
|
||||
eq(og.save.options.tilt, 1, "A cycles TILT to 15")
|
||||
eq(Tilt.level, 1, "Tilt level tracks TILT option")
|
||||
press("a"); press("a"); press("a")
|
||||
eq(og.save.options.tilt, 0, "TILT wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 15, "cursor reaches GBC FX")
|
||||
check(seek("gbcfx"), "cursor reaches GBC FX")
|
||||
press("a")
|
||||
eq(og.save.options.gbcfx, 1, "A cycles GBC FX to 1")
|
||||
eq(GBCFX.level, 1, "GBCFX level tracks GBC FX option")
|
||||
for _ = 1, 4 do press("a") end
|
||||
eq(og.save.options.gbcfx, 0, "GBC FX wraps back to OFF")
|
||||
press("down")
|
||||
eq(om.index, 16, "cursor reaches ZOOM")
|
||||
check(seek("zoom"), "cursor reaches ZOOM")
|
||||
local ZoomOpt = require("src.render.Zoom")
|
||||
press("a")
|
||||
eq(og.save.options.zoom, 1, "A cycles ZOOM to IN1")
|
||||
eq(ZoomOpt.offset, 1, "Zoom.offset tracks ZOOM option")
|
||||
press("left")
|
||||
eq(og.save.options.zoom, 0, "left steps ZOOM back to FIT")
|
||||
press("down")
|
||||
eq(om.index, 17, "cursor reaches VOID FILL")
|
||||
check(seek("voidFill"), "cursor reaches VOID FILL")
|
||||
local TR = require("src.render.TileRenderer")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "water", "A cycles VOID FILL to WATER")
|
||||
@@ -2740,18 +2745,15 @@ do
|
||||
eq(og.save.options.voidFill, "black", "A cycles VOID FILL to BLACK")
|
||||
press("a")
|
||||
eq(og.save.options.voidFill, "trees", "VOID FILL wraps back to TREES")
|
||||
press("down")
|
||||
eq(om.index, 18, "cursor reaches VIDEO MODE")
|
||||
check(seek("videoMode"), "cursor reaches VIDEO MODE")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "borderless",
|
||||
"A cycles VIDEO MODE to BORDERLESS")
|
||||
press("a")
|
||||
eq(og.save.options.videoMode, "windowed",
|
||||
"VIDEO MODE wraps back to WINDOWED")
|
||||
press("down")
|
||||
eq(om.index, 19, "cursor reaches FAITHFUL RATIO")
|
||||
press("down")
|
||||
eq(om.index, 20, "cursor reaches MAX FPS")
|
||||
check(seek("faithfulRes"), "cursor reaches FAITHFUL RATIO")
|
||||
check(seek("fpsCap"), "cursor reaches MAX FPS")
|
||||
press("a")
|
||||
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
|
||||
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
|
||||
@@ -2761,8 +2763,7 @@ do
|
||||
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
|
||||
-- RFC 0007: the single GAME SPEED row is now three independent rows,
|
||||
-- one per GameSpeed.CATEGORIES entry.
|
||||
press("down")
|
||||
eq(om.index, 21, "cursor reaches OVERWORLD SPEED")
|
||||
check(seek("speedOverworld"), "cursor reaches OVERWORLD SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speedOverworld, 2, "A cycles OVERWORLD SPEED to 2X")
|
||||
-- Driven by the level list rather than a literal press count: adding a
|
||||
@@ -2770,26 +2771,20 @@ do
|
||||
-- bug when the cycling is fine and the row is simply one longer.
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speedOverworld, 1, "OVERWORLD SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 22, "cursor reaches BATTLE SPEED")
|
||||
check(seek("speedBattle"), "cursor reaches BATTLE SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speedBattle, 2, "A cycles BATTLE SPEED to 2X")
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speedBattle, 1, "BATTLE SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 23, "cursor reaches MENU SPEED")
|
||||
check(seek("speedMenu"), "cursor reaches MENU SPEED")
|
||||
press("a")
|
||||
eq(og.save.options.speedMenu, 2, "A cycles MENU SPEED to 2X")
|
||||
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
|
||||
eq(og.save.options.speedMenu, 1, "MENU SPEED wraps back to NORMAL")
|
||||
press("down")
|
||||
eq(om.index, 24, "cursor reaches MODS")
|
||||
press("down")
|
||||
eq(om.index, 25, "cursor reaches CONTROLS")
|
||||
press("down")
|
||||
eq(om.index, 26, "cursor reaches DATE FORMAT")
|
||||
press("down")
|
||||
eq(om.index, 27, "cursor reaches TIME FORMAT")
|
||||
check(seek("mods"), "cursor reaches MODS")
|
||||
check(seek("controls"), "cursor reaches CONTROLS")
|
||||
check(seek("dateFormat"), "cursor reaches DATE FORMAT")
|
||||
check(seek("timeFormat"), "cursor reaches TIME FORMAT")
|
||||
press("down")
|
||||
-- CANCEL is appended after the descriptor list rather than living in it, so
|
||||
-- it lands one past #rows and the window holds the last six boxes. Counted
|
||||
@@ -2812,7 +2807,7 @@ do
|
||||
GBCFX.applyOptions(og.save.options)
|
||||
require("src.render.Zoom").applyOptions(og.save.options)
|
||||
require("src.render.TileRenderer").applyOptions(og.save.options)
|
||||
VideoMode.applyOptions(og.save.options)
|
||||
require("src.core.VideoMode").applyOptions(og.save.options)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user