CLOSES #998, CLOSES #1472, CLOSES #1526, CLOSES #1529, CLOSES #1530, CLOSES #1532, CLOSES #1534, CLOSES #1547, CLOSES #1549, CLOSES #1550, CLOSES #1551

This commit is contained in:
bryanthaboi
2026-08-19 11:19:54 -04:00
parent 9713977755
commit 4c8c1cf36b
46 changed files with 1650 additions and 194 deletions
@@ -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)")
+25 -3
View File
@@ -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
+100
View File
@@ -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)")
+87
View File
@@ -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)")
@@ -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)")
+109
View File
@@ -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)")
+11 -3
View File
@@ -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 } }
+4
View File
@@ -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 ----------------------------------------------------
+20 -7
View File
@@ -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
+2 -1
View File
@@ -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")
+2
View File
@@ -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
View File
@@ -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")
+1
View File
@@ -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,
+2 -2
View File
@@ -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()
+11
View File
@@ -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()
+3 -2
View File
@@ -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
View File
@@ -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