mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 03:02:39 +02:00
Merge branch 'dev' of https://github.com/bryanthaboi/gen1recomp into dev
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
-- Public battle auxiliary actions are a narrow semantic entry point for tool
|
||||
-- mods. They run only at the same settled ordinary decision boundary as a
|
||||
-- battle checkpoint, consume no FIGHT/PKMN/ITEM/RUN action, and receive no
|
||||
-- live BattleState object.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle menu auxiliary action")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Hooks = require("src.mods.Hooks")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame(kind)
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-menu-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = save.player.map },
|
||||
player = { cellX = save.player.x, cellY = save.player.y, facing = save.player.facing },
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
local game = { data = Data, save = save, stack = stack }
|
||||
game.input = { wasPressed = function(_, button) return button == "start" end }
|
||||
game.overworld = overworld
|
||||
stack.states[1] = overworld
|
||||
local battle = kind == "trainer"
|
||||
and BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
|
||||
or BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.checkpointOrigin = kind == "trainer"
|
||||
and { kind = "trainer_encounter", map = save.player.map, npcId = "TRAINER_1",
|
||||
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, event = "EVENT_BEAT_TRAINER_1" }
|
||||
or { kind = "wild_encounter", map = save.player.map }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, battle
|
||||
end
|
||||
|
||||
local oldHooks = Runtime.hooks
|
||||
local hooks = Hooks.new()
|
||||
Runtime.hooks = hooks
|
||||
|
||||
local game, battle = makeGame("wild")
|
||||
local calls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function(nextFn, liveGame, context)
|
||||
calls = calls + 1
|
||||
T.check(liveGame == game, "auxiliary action receives the live game")
|
||||
T.same(context, { kind = "wild" }, "auxiliary action receives only data-only battle context")
|
||||
return true
|
||||
end, 0, "tool_fixture")
|
||||
|
||||
local originalIndex = battle.menuIndex
|
||||
battle:update(1 / 60)
|
||||
T.eq(calls, 1, "START reaches the public auxiliary action at a wild decision")
|
||||
T.eq(battle.phase, "menu", "handled auxiliary action does not advance the battle")
|
||||
T.eq(battle.menuIndex, originalIndex, "handled auxiliary action preserves cursor")
|
||||
T.eq(#battle.queue, 0, "handled auxiliary action does not enqueue a turn")
|
||||
|
||||
hooks:removeOwner("tool_fixture")
|
||||
local trainerGame, trainer = makeGame("trainer")
|
||||
local trainerCalls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
|
||||
trainerCalls = trainerCalls + 1
|
||||
T.check(liveGame == trainerGame, "trainer action receives its live game")
|
||||
T.same(context, { kind = "trainer" }, "trainer context remains data-only")
|
||||
return true
|
||||
end, 0, "trainer_fixture")
|
||||
trainer:update(1 / 60)
|
||||
T.eq(trainerCalls, 1, "START reaches the public auxiliary action at a trainer decision")
|
||||
hooks:removeOwner("trainer_fixture")
|
||||
|
||||
local scriptedGame, scripted = makeGame("trainer")
|
||||
scripted.checkpointOrigin = { kind = "script_battle", scriptId = "STORY_TEST", pc = 4 }
|
||||
scripted.checkpointScriptContinuation = { kind = "script_battle" }
|
||||
local scriptedCalls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function(_, liveGame, context)
|
||||
scriptedCalls = scriptedCalls + 1
|
||||
T.check(liveGame == scriptedGame,
|
||||
"scripted action receives the live game without its runner")
|
||||
T.same(context, { kind = "trainer" },
|
||||
"supported scripted trainer context remains data-only")
|
||||
return true
|
||||
end, 0, "scripted_fixture")
|
||||
scripted:update(1 / 60)
|
||||
T.eq(scriptedCalls, 1,
|
||||
"START reaches the public auxiliary action at a supported scripted decision")
|
||||
hooks:removeOwner("scripted_fixture")
|
||||
|
||||
local unsafeGame, unsafe = makeGame("wild")
|
||||
unsafe.phase = "messages"
|
||||
local unsafeCalls = 0
|
||||
hooks:wrap("battle.menu_auxiliary", function() unsafeCalls = unsafeCalls + 1 return true end,
|
||||
0, "unsafe_fixture")
|
||||
unsafe:update(1 / 60)
|
||||
T.eq(unsafeCalls, 0, "messages never expose the auxiliary action")
|
||||
hooks:removeOwner("unsafe_fixture")
|
||||
|
||||
Runtime.hooks = oldHooks
|
||||
T.finish()
|
||||
@@ -0,0 +1,208 @@
|
||||
-- Regression test for the title-music-bleeds-into-the-map bug: Continue,
|
||||
-- F2 quickload, and checkpoint-resume used to drop the player into the
|
||||
-- overworld while the old song (the title screen's, or F2's previous
|
||||
-- location) was still cross-fading in over Music.MAP_FADE's ~1.2s,
|
||||
-- audibly wrong since the player already had control. See
|
||||
-- OverworldState:setMap (src/world/OverworldController.lua) for the
|
||||
-- opts.freshBoot mechanism this exercises, and Game.lua for where it's
|
||||
-- set (onContinue, New Game, F2, restoreCheckpointSave) and where it's
|
||||
-- deliberately not (dev tooling's reuse of opts.via == "boot").
|
||||
--
|
||||
-- (A)-(A4) and (C) call the real Game:restoreSave, Game:keypressed("f2"),
|
||||
-- Game:restoreCheckpointSave and Console:exec("warp ...") -- SaveData.load
|
||||
-- stubbed to skip the slot/persistence format -- so a dropped freshBoot at
|
||||
-- any real call site fails this test, not just a hand-built opts table.
|
||||
-- (D) simulates HotReload's { via = "boot" } shape instead of calling
|
||||
-- through its local, unexported reloadMap.
|
||||
--
|
||||
-- ROM-free (fixture dataset -- FIX_TOWN/FIX_ROUTE, tests/fixture_data),
|
||||
-- like tests/engine/warp_sprite_hidden_bug916.lua, so the CI headless
|
||||
-- tier (no data/generated/) runs it.
|
||||
-- luajit tests/engine/resume_boot_music_no_fade.lua
|
||||
|
||||
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 check = T.check
|
||||
local eq = T.eq
|
||||
|
||||
local Source = {}
|
||||
Source.__index = Source
|
||||
function Source:play() self.playing = true end
|
||||
function Source:stop() self.playing = false end
|
||||
function Source:pause() self.playing = false end
|
||||
function Source:isPlaying() return self.playing end
|
||||
function Source:setLooping() end
|
||||
function Source:setVolume(v) self.volume = v end
|
||||
function Source:setPitch() end
|
||||
function Source:setFilter() end
|
||||
function Source:getDuration() return 1 end
|
||||
|
||||
local made = {} -- file -> the last source built for it
|
||||
love.audio = {
|
||||
newSource = function(file, mode)
|
||||
made[file] = setmetatable({ file = file, mode = mode }, Source)
|
||||
return made[file]
|
||||
end,
|
||||
}
|
||||
|
||||
local Data = T.fixtures.fresh()
|
||||
-- fixture patches that let the overworld boot and run headlessly (same
|
||||
-- set tests/engine/warp_sprite_hidden_bug916.lua needs for the same reason)
|
||||
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 = {} }
|
||||
-- no data.audio in the fixture dataset either; synthesize just enough for
|
||||
-- real Music.lua playback to run against the real FIX_TOWN/FIX_ROUTE maps
|
||||
Data.audio = Data.audio or {}
|
||||
Data.audio.songs = Data.audio.songs or {}
|
||||
Data.audio.songs.Music_TitleScreen = { file = "title.wav" }
|
||||
Data.audio.mapSongs = Data.audio.mapSongs or {}
|
||||
Data.audio.mapSongs.FIX_TOWN = "Music_FixTown"
|
||||
Data.audio.songs.Music_FixTown = { file = "town.wav" }
|
||||
Data.audio.mapSongs.FIX_ROUTE = "Music_FixRoute"
|
||||
Data.audio.songs.Music_FixRoute = { file = "route.wav" }
|
||||
|
||||
local Music = require("src.core.Music")
|
||||
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 Console = require("src.dev.Console")
|
||||
|
||||
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 -- set once at boot in the real game (Game.lua)
|
||||
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 function playing()
|
||||
for file, src in pairs(made) do
|
||||
if src.playing then return file end
|
||||
end
|
||||
return "(silence)"
|
||||
end
|
||||
|
||||
local function finishFade()
|
||||
for _ = 1, 7 * Music.MAP_FADE do Music.update(Data) end
|
||||
end
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A) The real Game:restoreSave, called the way onContinue calls it.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
eq(playing(), "title.wav", "title screen music is playing before Continue")
|
||||
|
||||
local loaded = SaveData.newGame()
|
||||
loaded.player.map = "FIX_TOWN"
|
||||
Game:restoreSave(loaded, false, { freshBoot = true })
|
||||
|
||||
eq(playing(), "town.wav",
|
||||
"Continue's real restoreSave(..., {freshBoot=true}) swaps at once")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A2) The same real Game:restoreSave with no opts at all -- its own
|
||||
-- default (e.g. for any future caller that doesn't ask for freshBoot) is
|
||||
-- the safe, ordinary crossfade, not a silent hard-cut.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen") -- stand-in for whatever was playing
|
||||
local loaded2 = SaveData.newGame()
|
||||
loaded2.player.map = "FIX_TOWN"
|
||||
Game:restoreSave(loaded2, false)
|
||||
|
||||
eq(playing(), "title.wav",
|
||||
"restoreSave(...) with no opts still fades, not an instant swap")
|
||||
finishFade()
|
||||
eq(playing(), "town.wav", "...landing on the loaded save's map song")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A3) The real Game:keypressed("f2") handler, both ways it's reachable:
|
||||
-- at the title screen and mid-session. SaveData.load is stubbed rather
|
||||
-- than round-tripped through the in-memory love.filesystem, to isolate
|
||||
-- this test from the slot/persistence format.
|
||||
-- ===========================================================================
|
||||
local realLoad = SaveData.load
|
||||
local loaded3 = SaveData.newGame()
|
||||
loaded3.player.map = "FIX_TOWN"
|
||||
SaveData.load = function() return loaded3, false end
|
||||
|
||||
StateStack:init() -- no overworld on the stack: "at the title screen"
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
Game:keypressed("f2")
|
||||
eq(playing(), "town.wav",
|
||||
"F2 from the title screen (overworld not on the stack) swaps at once")
|
||||
|
||||
StateStack:init()
|
||||
StateStack.states[1] = OverworldState -- overworld already active: mid-session
|
||||
Music.play(Data, "Music_TitleScreen") -- stand-in for the session's own song
|
||||
Game:keypressed("f2")
|
||||
eq(playing(), "town.wav",
|
||||
"F2 mid-session (a live overworld already on the stack) also swaps at once")
|
||||
|
||||
SaveData.load = realLoad
|
||||
StateStack:init()
|
||||
|
||||
-- ===========================================================================
|
||||
-- (A4) The real Game:restoreCheckpointSave, called the way Checkpoint.resume
|
||||
-- (RFC 0006's mod.checkpoint:resume) calls it.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
local checkpointSave = SaveData.newGame()
|
||||
checkpointSave.player.map = "FIX_TOWN"
|
||||
Game:restoreCheckpointSave(checkpointSave)
|
||||
eq(playing(), "town.wav",
|
||||
"a title-session checkpoint resume swaps at once, no lingering title music")
|
||||
|
||||
StateStack:init()
|
||||
|
||||
-- ===========================================================================
|
||||
-- (B) An ordinary warp (e.g. walking into a house) is unaffected: it still
|
||||
-- cross-fades like any other map-to-map transition.
|
||||
-- ===========================================================================
|
||||
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", {})
|
||||
eq(playing(), "town.wav",
|
||||
"an ordinary warp still fades: the old song is still playing right after setMap")
|
||||
finishFade()
|
||||
eq(playing(), "route.wav",
|
||||
"...and lands on the new map's song once the fade completes")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (C) The real dev console `warp` verb (src/dev/Console.lua VERBS.warp).
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen") -- re-arm a "stale" song to prove intent
|
||||
Console.new(Game):exec("warp FIX_TOWN 5 5")
|
||||
|
||||
eq(playing(), "title.wav",
|
||||
"Console's real `warp` verb still fades, like an ordinary warp")
|
||||
finishFade()
|
||||
eq(playing(), "town.wav", "...landing on the target map's song")
|
||||
|
||||
-- ===========================================================================
|
||||
-- (D) src/dev/HotReload.lua's reloadMap opts shape, simulated (see header)
|
||||
-- rather than called through: reloadMap is local/unexported, and
|
||||
-- HotReload.run's full loader teardown is out of scope for this fix.
|
||||
-- ===========================================================================
|
||||
Music.play(Data, "Music_TitleScreen")
|
||||
OverworldState:setMap("FIX_ROUTE", 3, 3, "up", { via = "boot" })
|
||||
eq(playing(), "title.wav",
|
||||
"HotReload's { via = \"boot\" } setMap still fades, not an instant swap")
|
||||
finishFade()
|
||||
eq(playing(), "route.wav", "...landing on the reloaded map's song")
|
||||
|
||||
T.finish("resume_boot_music_no_fade")
|
||||
Reference in New Issue
Block a user