diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b8d85b9c..de98d99f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,6 +36,7 @@ body: - Silver - Crystal - FireRed + - Emerald - N/A validations: required: true diff --git a/README.md b/README.md index e4999481..0f8d53de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # G1R Deluxe aka Gen1Recomp -A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, and LeafGreen. +A native LÖVE2D recreation of Poke Red, Blue, Yellow, Gold, Silver, Crystal, FireRed, LeafGreen, and Emerald. The engine and map behavior are hand-written Lua; game data and graphics are decoded from a ROM supplied by the player. @@ -61,7 +61,7 @@ And before you say, "that's not a recomp", you're wrong. Recomp is an acronym. * ## What this is G1R Deluxe aka Gen1Recomp is a native LÖVE2D recreation of Pokemon Red, Blue, Yellow, Gold, -Silver, Crystal, FireRed, and LeafGreen. The engine and map behavior are +Silver, Crystal, FireRed, LeafGreen, and Emerald. The engine and map behavior are hand-written Lua, ported from the [pret](https://github.com/pret) disassemblies & C. Game data, graphics, and audio programs are decoded on first launch from a ROM you supply. @@ -70,7 +70,7 @@ The project does not include a ROM, emulate the Game Boy, transpile assembly, or download a disassembly. Your ROM is verified, used during import, and released from memory. It is never copied into the cache, and later launches load the private generated cache without asking for it again. Music, sound -effects, and cries are synthesized while the game runs. All eight games can be +effects, and cries are synthesized while the game runs. All nine games can be imported side by side. Gen 2 support is still under construction. ## Quick Start @@ -84,7 +84,7 @@ imported side by side. Gen 2 support is still under construction. 4. Repeat for any other game you own. Each one gets its own tab in the launcher. Only the canonical US English ROMs below are accepted. The importer checks the -SHA-1 before creating any game data. FireRed and LeafGreen support is in beta. +SHA-1 before creating any game data. FireRed, LeafGreen, and Emerald support is in beta. | Game | Revision | ROM size | SHA-1 | | --- | --- | --- | --- | @@ -99,6 +99,7 @@ SHA-1 before creating any game data. FireRed and LeafGreen support is in beta. | FireRed | 1.1 | 16 MiB | `dd5945db9b930750cb39d00c84da8571feebf417` | | LeafGreen | 1.0 | 16 MiB | `574fa542ffebb14be69902d1d36f1ec0a4afd71e` | | LeafGreen | 1.1 | 16 MiB | `7862c67bdecbe21d1d69ce082ce34327e1c6ed5e` | +| Emerald | 1.0 | 16 MiB | `f3ae088181bf583e55daf962a92bb46f4f1d07b7` | **Platform notes:** [Linux](docs/platforms/linux.md), [iOS](docs/platforms/ios.md), [Xbox Dev Mode](docs/platforms/xbox.md), diff --git a/src/core/Game3.lua b/src/core/Game3.lua index b66cf65f..43fe50d6 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -1246,7 +1246,8 @@ local function clearFieldScreens() FieldView.setCameraPanning(0, 0) end -function Game3:returnToTitle() +function Game3:returnToTitle(opts) + opts = opts or {} self.questPlayback=nil Help.reset() Audio.stopAll() @@ -1285,7 +1286,8 @@ function Game3:returnToTitle() end Boot.setSaveStatus(self.boot, saveStatus) Boot.setTextSpeed(self.boot, Options.block(self.options).textSpeed) - if not self.boot.custom then + if not self.boot.custom or opts.skipIntro then + if self.boot.custom then self.boot.custom.coldBoot = false end self.boot.phase = Boot.PHASE.TITLE self.boot.timer = 0 Boot.enterTitle(self.boot) diff --git a/src/core/game3/audio.lua b/src/core/game3/audio.lua index e4a687fa..d504a20c 100644 --- a/src/core/game3/audio.lua +++ b/src/core/game3/audio.lua @@ -421,6 +421,8 @@ function Audio.playSong(id, opts) opts = opts or {} id = Audio.resolveSong(id) if id == nil or id == 0 or id == 0xFFFF then + Audio._fadeOut, Audio._fadeIn = nil, nil + Audio._fanfareRestore, Audio._fanfareDeferred = nil, nil stop_bgm_source() Audio._currentSong = nil return true @@ -428,7 +430,8 @@ function Audio.playSong(id, opts) if opts.fanfare or (Audio.songInfo(id) and Audio.songInfo(id).kind == "fanfare") or (fanfare_entry(id) ~= nil) then return Audio.playFanfare(id) end - if not opts.restart and Audio._currentSong and Audio._currentSong.id == id then + if not opts.restart and not Audio._fadeOut and Audio._currentSong and Audio._currentSong.id == id then + if Audio._fanfareActive then Audio._fanfareDeferred = nil end return true end local info = Audio.songInfo(id) or {} @@ -488,8 +491,11 @@ function Audio.playSong(id, opts) end function Audio.currentMapMusic() - local pending = Audio._fadeOut and Audio._fadeOut.nextSong - return pending or (Audio._currentSong and Audio._currentSong.id) or 0 + if Audio._fanfareActive and Audio._fanfareDeferred ~= nil then + return Audio._fanfareDeferred + end + if Audio._fadeOut then return Audio._fadeOut.nextSong or 0 end + return (Audio._currentSong and Audio._currentSong.id) or 0 end local function play_policy_song(id, fadeOut, fadeIn) @@ -630,9 +636,7 @@ function Audio.changeMusicTo(id) if t then return Audio.fadeOutAndPlay(t.song, t.fadeOut) end return true end - local cur = Audio._currentSong and Audio._currentSong.id - local pending = Audio._fadeOut and Audio._fadeOut.nextSong - if (pending or cur) == id then return true end + if Audio.currentMapMusic() == id then return true end return Audio.fadeOutAndPlay(id, 8) end @@ -671,9 +675,8 @@ function Audio.bikeMusic(on, forced) end Audio.setSavedSong(nil) local id = Audio.specialMapSong() - local pending = Audio._fadeOut and Audio._fadeOut.nextSong - if id and id ~= (pending or (Audio._currentSong and Audio._currentSong.id)) then - Audio.playSong(id) + if id and id ~= Audio.currentMapMusic() then + play_policy_song(id) end end @@ -693,7 +696,8 @@ end function Audio.fadeDefaultBgm(speed) if rse_policy() then return Audio.changeMusicToDefault() end local id = Audio._mapSong - if id and Audio._currentSong and Audio._currentSong.id == id then return true end + if id and Audio.currentMapMusic() == id then return true end + if Audio._fanfareActive then return play_policy_song(id) end Audio.fadeOutBgm(speed) if id then return Audio.playSong(id) end return true diff --git a/src/core/game3/field.lua b/src/core/game3/field.lua index 707ae5a1..1ce7d48f 100644 --- a/src/core/game3/field.lua +++ b/src/core/game3/field.lua @@ -1702,6 +1702,50 @@ function Field.flyDestination(section) return baked[id] end +-- pokefirered/src/overworld.c:289 +local function resetCyclingRoadAfterTravel() + local session = Field._session + if not session then return end + local Profile = require("src.core.game3.profile") + local Flags = require("src.core.game3.scripting.flags") + local Runtime = package.loaded["src.core.game3.runtime"] + local Space = package.loaded["src.core.game3.scripting.space"] + local profile = Profile.forSession(session) + local defs = Flags.forVersion(profile.id) + local roadName = profile.family == "rse" + and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD" + local road = assert(defs.IDS[roadName]) + local scene = profile.family ~= "rse" and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16) + local live = Runtime and Runtime.getSession and Runtime.getSession() + local seen = {} + for _, store in ipairs({ session, session.store or false, Space and Space.store or false, + live or false, live and live.store or false }) do + if store and not seen[store] then + seen[store] = true + Flags.setFlag(store, nil, road, false) + if store.flags then store.flags[roadName] = nil end + if scene then + Flags.setVar(store, nil, scene, 0) + if store.vars then + store.vars[tostring(scene)] = nil + store.vars[string.format("0x%X", scene)] = nil + store.vars.VAR_MAP_SCENE_ROUTE16 = nil + end + end + end + end + Player.biking, Player.bikeType = false, nil + Player.surfing, Player.surfHopping = false, false + for _, s in ipairs({ session, live or false }) do + if s then s.biking, s.bikeType = false, nil end + end + local save = Field._game and Field._game.save + if save then + save.biking, save.bikeType = false, nil + if save.position then save.position.biking = false end + end +end + -- pokefirered/src/field_effect.c:1065 ReturnToFieldFromFlyMapSelect function Field.flyTo(section, mon, info) local dest = (info and info.dest) @@ -1716,6 +1760,7 @@ function Field.flyTo(section, mon, info) local FieldEffects = require("src.core.game3.field_effects") local function land() local Map = require("src.core.game3.map") + resetCyclingRoadAfterTravel() Map.load(Field._mod, Field._game, dest.map, { x = dest.x, y = dest.y, facing = "down", depth1Connections = true, }) @@ -1895,6 +1940,7 @@ end function Field.respawnAtHeal(opts) local session = Field._session if not session then return end + resetCyclingRoadAfterTravel() local HealLocations = require("src.core.game3.heal_locations") HealLocations.normalizeSession(session) local whiteOut = not (opts and opts.fieldMove) diff --git a/src/core/game3/ghosts.lua b/src/core/game3/ghosts.lua index 7243d137..63e81928 100644 --- a/src/core/game3/ghosts.lua +++ b/src/core/game3/ghosts.lua @@ -146,7 +146,7 @@ function Ghosts.capture(mapId) local pool = { byId = {}, order = {}, bounds = snap.bounds } for _, lid in ipairs(snap.order or {}) do local eo = snap.byId[lid] - if eo then + if eo and eo.foreignMap == nil then pool.byId[lid] = eo pool.order[#pool.order + 1] = lid eo.scriptBusy = false diff --git a/src/core/game3/objects.lua b/src/core/game3/objects.lua index f8966994..6f432e2e 100644 --- a/src/core/game3/objects.lua +++ b/src/core/game3/objects.lua @@ -804,7 +804,7 @@ function Objects.forDraw() local eo = Objects._byId[lid] -- src/event_object_movement.c:8014 if eo and eo.visible and not eo.hidden and not eo.invisible - and not offMap(Objects._bounds, eo) then + and (eo.foreignMap ~= nil or not offMap(Objects._bounds, eo)) then n = n + 1 list[n] = eo end @@ -1622,10 +1622,22 @@ local function idleTick(eo, game, ctx) end end +local trackIds, trackRefs, trackActors = {}, {}, {} + function Objects.update(game) - -- Advance script tracks then motion + idle. + local count = 0 for lid, tr in pairs(Objects._tracks) do - advanceTrack(lid, tr, game) + count = count + 1 + trackIds[count], trackRefs[count], trackActors[count] = lid, tr, Objects.find(lid) + end + -- pokeemerald/src/event_object_movement.c:2167 + for i = 1, count do + local tr, eo = trackRefs[i], trackActors[i] + local lid = (eo and eo.localId) or trackIds[i] + trackIds[i], trackRefs[i], trackActors[i] = nil, nil, nil + if Objects._tracks[lid] == tr and (not eo or Objects.find(lid) == eo) then + advanceTrack(lid, tr, game) + end end for _, lid in ipairs(Objects._order) do local eo = Objects._byId[lid] diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 934f8abe..b4117110 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -1547,6 +1547,7 @@ end -- one visited, else Red. Kept as a function so the mods/find/skins panels -- still answer "for which game" without a game tab being open. local function gameTabs(imp) + SecretGames.update() local launcher = type(imp) ~= "table" or imp.launcher and true or false local c = imp and imp._gameTabs if c and c.rev == SecretGames.rev and c.launcher == launcher then diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 048c6f0e..bcfa96d5 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1961,6 +1961,11 @@ function RomImporter:_versionForSha1(hash) end function RomImporter:_logoTap() + if SecretGames.update() then + self._secretPopup = nil + self._logoTaps = nil + return + end if self._secretPopup or not SecretGames.anyLocked() then return end self._logoTaps = (self._logoTaps or 0) + 1 if self._logoTaps >= SecretGames.TAPS then @@ -3452,6 +3457,12 @@ function RomImporter:_pollPickedFiles(dt) end function RomImporter:update(dt) + local released, changed = SecretGames.update() + if released then + self._secretPopup = nil + self._logoTaps = nil + if changed then self._headerChrome = nil end + end self.pulse = self.pulse + dt if self._importerJob then self:_stepImporter() end if not Transition.armed then diff --git a/src/import/SecretGames.lua b/src/import/SecretGames.lua index 8770e3dc..172d696d 100644 --- a/src/import/SecretGames.lua +++ b/src/import/SecretGames.lua @@ -4,6 +4,7 @@ local SecretGames = {} SecretGames.GAMES = { emerald = "secretEmerald" } SecretGames.TAPS = 20 +SecretGames.EMERALD_RELEASE_AT = 1790856000 SecretGames.rev = 0 local unlocked @@ -25,7 +26,7 @@ end function SecretGames.visible(id) if not SecretGames.GAMES[id] then return true end - if not unlocked then load() end + SecretGames.update() return unlocked[id] == true end @@ -60,4 +61,12 @@ function SecretGames.unlock() return saved end +function SecretGames.update(now) + if not unlocked then load() end + local due = (now or os.time()) >= SecretGames.EMERALD_RELEASE_AT + local changed = due and unlocked.emerald ~= true + if changed then SecretGames.unlock() end + return due, changed +end + return SecretGames diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua index 1740d8a2..24072afc 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -167,6 +167,7 @@ function Message.show(text, opts) end -- Prefer session options text speed when not overridden. + local instant = tonumber(opts.speed) == 0 local speed = opts.speed if speed == nil then local ok, Options = pcall(require, "src.core.game3.options") @@ -182,10 +183,6 @@ function Message.show(text, opts) Message._speedIdx = tonumber(speed) or 1 if Message._speedIdx < 0 then Message._speedIdx = 0 end if Message._speedIdx > 2 then Message._speedIdx = 2 end - if tonumber(speed) == 0 then - -- Instant print (Oak repeat question) - Message._speedIdx = 0 - end local _, _, dlgW = Chrome.dialogueWindow() local maxW = (opts.frame == "battle" or opts.battle) and 212 or dlgW * Display.TILE @@ -204,7 +201,7 @@ function Message.show(text, opts) Message._pages = split_pages(plain) Message._page = 1 beginPage() - if tonumber(speed) == 0 then + if instant then Message.skipReveal() end return Message diff --git a/src/ui/game3/start_menu.lua b/src/ui/game3/start_menu.lua index 479f804d..5a7fabf3 100644 --- a/src/ui/game3/start_menu.lua +++ b/src/ui/game3/start_menu.lua @@ -253,7 +253,7 @@ function StartMenu.confirm() or (Runtime and Runtime._game) or StartMenu._game if game and game.returnToTitle then - game:returnToTitle() + game:returnToTitle({ skipIntro = true }) end else -- NO StartMenu._confirmExit = false diff --git a/tests/drivers/em_briney_boat.lua b/tests/drivers/em_briney_boat.lua index 4629b2fb..5695cb8e 100644 --- a/tests/drivers/em_briney_boat.lua +++ b/tests/drivers/em_briney_boat.lua @@ -5,37 +5,143 @@ local d = X.new("em_briney_boat", "/tmp/em_briney_boat") return function(game) if not X.newGame(d, game, 0) then return d.finish() end + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Ghosts = require("src.core.game3.ghosts") + local Space = require("src.core.game3.scripting.space") + local Message = require("src.ui.game3.message") + local Fade = require("src.ui.game3.fade") + local state + local function monitor() + if not state then return end + local s = X.session() + if s and not state.maps[s.map] then + state.maps[s.map] = true + d.note(state.name .. " map " .. s.map .. " VM " .. X.vmWhere()) + end + local boat, lid + for id, eo in pairs(Objects._byId) do + if eo.originMapId == state.origin and eo.originLocalId == state.localId then + boat, lid = eo, id + break + end + end + if not boat then return end + for _, pool in pairs(Ghosts._pools) do + for _, ghost in pairs(pool.byId) do + if ghost == boat then state.aliases = state.aliases + 1 end + end + end + local bt = Objects._tracks[lid] + local pt = Objects._tracks[Objects.PLAYER_LOCAL_ID] + if Player.visible == false and bt and pt and not bt.done and not pt.done then + local dx, dy = boat.px - Player.px, boat.py - Player.py + if state.dx == nil then state.dx, state.dy = dx, dy end + state.samples = state.samples + 1 + if dx ~= state.dx or dy ~= state.dy then + state.drift = state.drift + 1 + if state.drift == 1 then + d.note(string.format("%s drift at %s: expected %s,%s actual %s,%s", + state.name, s.map, state.dx, state.dy, dx, dy)) + end + end + end + end + local function waitFor(pred, frames) + for i = 1, frames do + if pred() then return true end + if i % 6 == 0 then table.insert(game.input.pressQueue, "a") end + U.wait(1) + game.input.state.a = false + monitor() + end + return pred() + end + local function begin(name, origin, localId) + state = { name = name, origin = origin, localId = localId, maps = {}, + samples = 0, drift = 0, aliases = 0 } + end + local function moment(label, file, pred, frames) + if not d.check(waitFor(pred, frames or 18000), label .. " (VM " .. X.vmWhere() .. ")") then + return false + end + d.check(d.still(game, file), label .. " screenshot") + return true + end + local function finishTrip(mapId, file) + moment(state.name .. " landing", file, function() + return not X.scriptRunning() and X.session().map == mapId and Player.visible ~= false + and not Fade.isActive() and not Fade.lockInput and (Fade.t or 0) <= 0 + end) + d.check(state.samples > 0, state.name .. " samples sailing every simulation frame (" .. state.samples .. ")") + d.check(state.drift == 0, state.name .. " boat/player relative pixels remain constant (" .. state.drift .. ")") + d.check(state.aliases == 0, state.name .. " carried boat absent from every ghost pool (" .. state.aliases .. ")") + d.check(Player.visible ~= false, state.name .. " player visible after landing") + state = nil + end + local function start(label, localId, facing) + local key = Space.bundle.labels[label] + return d.check(key and Space.startScript(key, localId, facing), "cached " .. label .. " starts") + end X.setFlag("FLAG_MR_BRINEY_SAILING_INTRO", true) - X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", true) + X.setFlag("FLAG_ENABLE_NORMAN_MATCH_CALL", false) X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_MR_BRINEY", false) X.setFlag("FLAG_HIDE_BRINEYS_HOUSE_PEEKO", false) X.setVar("VAR_BRINEY_HOUSE_STATE", 0) d.check(X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", 5, 6, "up"), "Mr. Briney's house loads") - local briney = require("src.core.game3.objects").find(1) + local briney = Objects.find(1) local bx, by = briney and briney.cellX or 5, briney and briney.cellY or 3 X.goTo(d, game, "EM_ROUTE104_MR_BRINEYS_HOUSE", bx, by + 1, "up") - local seen = {} - local finished = X.mash(game, function() - local s = X.session() - if s and s.map and not seen[s.map] then - seen[s.map] = true - d.note("map " .. s.map .. " VM " .. X.vmWhere()) - end - return X.flag("FLAG_HIDE_ROUTE_104_MR_BRINEY_BOAT") and X.var("VAR_BOARD_BRINEY_BOAT_STATE") == 0 - and not X.scriptRunning() - end, 6000, "a", 4) - d.check(seen.EM_ROUTE104, "SailToDewford warps to Route 104 and ON_FRAME boards the boat") - d.check(finished, "the sailing script runs every boat/player movement to the landing (VM " .. X.vmWhere() .. ")") + begin("Route104 Dad call", "EM_ROUTE104", 7) + table.insert(game.input.pressQueue, "a") + moment("Route104 boarded", "90002_route104_boarded.png", function() + return X.session().map == "EM_ROUTE104" and Player.visible == false + end) + moment("Route105 first seam", "90002_route105_first_seam.png", function() + return X.session().map == "EM_ROUTE105" + end) + moment("Dad call fully revealed", "90002_dad_call.png", function() + return Player.visible == false and Message.isOpen() and not Message.isTyping() + end) + moment("Route106 second seam", "90002_route106_second_seam.png", function() + return X.session().map == "EM_ROUTE106" + end) + finishTrip("EM_DEWFORD_TOWN", "90002_dewford_landed.png") + d.check(X.flag("FLAG_ENABLE_NORMAN_MATCH_CALL"), "first sailing trip enables Dad match call") d.check(X.flag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN") == false, "Dewford Briney shown after landing") - local s = X.session() - local Player = require("src.core.game3.player") - if s and s.map == "EM_DEWFORD_TOWN" then - d.check(true, "player ends in Dewford Town") - else - d.note(string.format("NOTE scripted movement crossed the Route 104 -> Dewford connection but the map did not switch " .. - "(still %s at %s,%s); connection transition during applymovement is a field/connections owner item", - tostring(s and s.map), tostring(Player.cellX), tostring(Player.cellY))) + for _, trip in ipairs({ + { name = "Dewford Petalburg", label = "DewfordTown_EventScript_SailToPetalburg", + seam = "EM_ROUTE106", landing = "EM_ROUTE104_MR_BRINEYS_HOUSE", prefix = "90002_petalburg" }, + { name = "Dewford Slateport", label = "DewfordTown_EventScript_SailToSlateport", + seam = "EM_ROUTE107", landing = "EM_ROUTE109", prefix = "90002_slateport" }, + }) do + X.setFlag("FLAG_HIDE_MR_BRINEY_DEWFORD_TOWN", false) + X.setFlag("FLAG_HIDE_MR_BRINEY_BOAT_DEWFORD_TOWN", false) + d.check(X.goTo(d, game, "EM_DEWFORD_TOWN", 11, 9, "right"), trip.name .. " departure loads") + begin(trip.name, "EM_DEWFORD_TOWN", 4) + if start(trip.label, 2, 4) then + moment(trip.name .. " first seam", trip.prefix .. "_first_seam.png", function() + return X.session().map == trip.seam and Player.visible == false + end) + finishTrip(trip.landing, trip.prefix .. "_landed.png") + end + end + for _, approach in ipairs({ + { name = "south", x = 21, y = 23, facing = "down", dir = 1 }, + { name = "east", x = 20, y = 24, facing = "right", dir = 4 }, + { name = "west", x = 22, y = 24, facing = "left", dir = 3 }, + }) do + X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY", false) + X.setFlag("FLAG_HIDE_ROUTE_109_MR_BRINEY_BOAT", false) + d.check(X.goTo(d, game, "EM_ROUTE109", approach.x, approach.y, approach.facing), + "Route109 " .. approach.name .. " departure loads") + begin("Route109 " .. approach.name, "EM_ROUTE109", 1) + if start("Route109_EventScript_StartDepartForDewford", 2, approach.dir) then + moment(state.name .. " first seam", "90002_return_" .. approach.name .. "_first_seam.png", function() + return X.session().map == "EM_ROUTE108" and Player.visible == false + end) + finishTrip("EM_DEWFORD_TOWN", "90002_return_" .. approach.name .. "_landed.png") + end end - d.shot(game, "01_landed_in_dewford.png") d.finish() end diff --git a/tests/drivers/em_start_menu_exit_90001.lua b/tests/drivers/em_start_menu_exit_90001.lua new file mode 100644 index 00000000..66f0b4c4 --- /dev/null +++ b/tests/drivers/em_start_menu_exit_90001.lua @@ -0,0 +1,113 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/em_start_menu_exit_90001" + +return function(game) + local failures = 0 + local overallDeadline = love.timer.getTime() + 25 + local SaveData, originalSave + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + local function waitUntil(pred, seconds) + local deadline = math.min(overallDeadline, love.timer.getTime() + seconds) + while love.timer.getTime() < deadline do + if pred() then return true end + U.wait(1) + end + return pred() + end + local ok, err = xpcall(function() + local identity = os.getenv("POKEPORT_IDENTITY") or "" + if not check(identity:match("^bsa0930%-") ~= nil, "isolated campaign identity") then return end + if not check(waitUntil(function() return game.boot ~= nil end, 5), "Emerald boot ready") then return end + local Boot = require("src.ui.game3.boot") + local StartMenu = require("src.ui.game3.start_menu") + local Runtime = require("src.core.game3.runtime") + SaveData = require("src.core.SaveData") + if not check(not SaveData.isPortable(), "save fixtures use isolated identity storage") then return end + originalSave = SaveData.save + local saves = 0 + SaveData.save = function(...) saves = saves + 1; return originalSave(...) end + local Message = require("src.ui.game3.message") + local Field = require("src.core.game3.field") + local Fade = require("src.ui.game3.fade") + local Hud = require("src.ui.game3.hud") + local Space = require("src.core.game3.scripting.space") + local Warp = require("src.core.game3.warp") + local Forced = require("src.core.game3.forced_movement") + local Player = require("src.core.game3.player") + local function idle() + if Message.isOpen() then U.tap(game, "b") end + local running = Space.vm and Space.vm.isRunning and Space.vm:isRunning() + return game.phase == "field" and not Hud.busy() and not Fade.isActive() and not Fade.lockInput + and not Field.locked and not running and not Warp.isBusy() and not Forced.isForced() + and not Player.moving and Player.boulderPush == nil + end + for caseIndex, saved in ipairs({ false, true }) do + local prefix = saved and "saved" or "unsaved" + local slot = SaveData.createSlot("emerald") + if not check(slot and SaveData.setActiveSlot("emerald", slot) == slot, prefix .. " fresh save slot selected") then return end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", + start = { map = "EM_LITTLEROOT_TOWN", x = 5, y = 9, facing = "down" } }) + print("INFO " .. prefix .. " initial fade=" .. tostring(Fade.isActive()) .. " hudBusy=" .. tostring(Hud.busy())) + if not check(waitUntil(idle, 4), prefix .. " field ready after fade and script completion") then return end + if saved then + if not check(game:saveGame(), "saved fixture writes a real Emerald save") then return end + end + if not check(game:_hasContinueSave() == saved, prefix .. " expected continue availability before EXIT") then return end + local session, saveCount = game.session, saves + local rawBefore = SaveData.readSlotSource("emerald", slot) + U.tap(game, "start") + if not check(StartMenu.isOpen(), prefix .. " START opens Emerald menu") then return end + local exitIndex + for i, entry in ipairs(StartMenu.ENTRIES) do if entry.id == "exit" then exitIndex = i end end + if not check(exitIndex ~= nil, prefix .. " Emerald EXIT present") then return end + for _ = 1, #StartMenu.ENTRIES do + if StartMenu.cursor == exitIndex then break end + U.tap(game, "down") + end + U.tap(game, "a") + check(StartMenu._confirmExit and StartMenu._confirmCursor == 2, prefix .. " EXIT confirmation defaults NO") + check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 1) .. "_" .. prefix .. "_exit_default_no.png"), prefix .. " default NO screenshot") + U.tap(game, "b") + check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT B preserves field session") + U.tap(game, "a") + U.tap(game, "a") + check(StartMenu.isOpen() and game.session == session, prefix .. " EXIT NO preserves field session") + U.tap(game, "a") + U.tap(game, "up") + U.tap(game, "a") + local direct = game.boot and game.boot.phase == Boot.PHASE.TITLE and game.boot.custom.title + and not game.boot.custom.intro + if not check(direct, prefix .. " EXIT YES enters title directly") then return end + check(game.session == nil and not Runtime.isActive(), prefix .. " EXIT YES stops field runtime") + check(saves == saveCount and SaveData.readSlotSource("emerald", slot) == rawBefore, prefix .. " EXIT does not implicitly save") + check(game.boot.hasContinue == saved, prefix .. " EXIT preserves existing continue availability") + check(game.boot.custom.coldBoot == false, prefix .. " EXIT consumes cold boot") + if not check(waitUntil(function() + local title = game.boot.custom.title + if not title or title.phase ~= "phase3" then return false end + for _, sprite in pairs(title.m.ppu.sprites.sprites) do + if sprite.inUse and sprite.y == 108 and sprite.data[0] == 1 and not sprite.invisible then return true end + end + return false + end, 5), prefix .. " Emerald title settled") then return end + check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 2) .. "_" .. prefix .. "_exit_title.png"), prefix .. " settled title screenshot") + U.tap(game, "start") + if not check(waitUntil(function() + local menu = game.boot.custom.menu + return game.boot.phase == Boot.PHASE.MENU and menu and menu.state == "input" + end, 4), prefix .. " title START reaches settled main menu") then return end + local menu = game.boot.custom.menu + check(menu.hasContinue == saved and menu.items[1] == (saved and "CONTINUE" or "NEW_GAME"), prefix .. " main menu offers correct first action") + if saved then check(menu.info and menu.info.name == "BRENDAN", "saved main menu retains trainer metadata") end + check(U.still(game, DIR .. "/" .. string.format("%02d", (caseIndex - 1) * 3 + 3) .. "_" .. prefix .. "_main_menu.png"), prefix .. " main menu screenshot") + end + end, debug.traceback) + if SaveData and originalSave then SaveData.save = originalSave end + if not ok then check(false, tostring(err)) end + print((failures == 0 and "PASS" or "FAIL") .. " em_start_menu_exit_90001 failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_collision_npc_edge.lua b/tests/drivers/game3_collision_npc_edge.lua index 5b2f1b6e..309ee114 100644 --- a/tests/drivers/game3_collision_npc_edge.lua +++ b/tests/drivers/game3_collision_npc_edge.lua @@ -90,7 +90,7 @@ return function(game) "Naomi wanders up and down from the sealed band at (14,5)") U.wait(20) - U.shot(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png") + U.still(game, DIR .. "/collision_npc_edge_01_naomi_on_the_band.png") local visited = {} local shotSouth = false @@ -99,7 +99,7 @@ return function(game) visited[naomi.cellX .. "," .. naomi.cellY] = true visited[naomi.targetX .. "," .. naomi.targetY] = true if not shotSouth and naomi.cellY == 6 and not naomi.moving then - U.shot(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png") + U.still(game, DIR .. "/collision_npc_edge_02_naomi_stepped_south.png") shotSouth = true end end diff --git a/tests/drivers/game3_cycling_fly_bug2564.lua b/tests/drivers/game3_cycling_fly_bug2564.lua new file mode 100644 index 00000000..d9f942f4 --- /dev/null +++ b/tests/drivers/game3_cycling_fly_bug2564.lua @@ -0,0 +1,122 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_cycling_fly_bug2564" +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + print((failures == 0 and "PASS " or "FAIL ") .. "cycling_fly_bug2564") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not check(game.boot ~= nil, "cycling Fly boot reached") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local session = Runtime.getSession() + if not check(session ~= nil, "cycling Fly field reached") then return finish() end + local Map = require("src.core.game3.map") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Flags = require("src.core.game3.scripting.flags") + local Space = require("src.core.game3.scripting.space") + local Message = require("src.ui.game3.message") + local Fade = require("src.ui.game3.fade") + local ItemUse = require("src.core.game3.item_use") + local Bag = require("src.core.game3.bag") + local Party = require("src.core.game3.party") + local C = require("src.core.game3.constants").of(session.version) + local rse = session.version == "emerald" + local defs = Flags.forVersion(session.version) + local road = assert(defs.IDS[rse and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD"]) + local scene = not rse and assert(defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16) + local bike = C:require("items", rse and "ITEM_MACH_BIKE" or "ITEM_BICYCLE") + Bag.add(session.bag, bike, 1) + if not rse then Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_GOT_BICYCLE), true) end + local function waitFor(pred, limit) + for _ = 1, limit do + if pred() then return true end + U.wait(1) + end + return pred() + end + local function settle() + return waitFor(function() + if Message.isOpen() then + if Message.isTyping() then Message.skipReveal() end + table.insert(game.input.pressQueue, "a") + end + return not Field.locked and not Fade.active and not Player.moving + and not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) + end, 600) + end + local function holdUntil(dir, pred, limit) + local reached = false + for _ = 1, limit do + if pred() then reached = true break end + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + U.wait(1) + end + game.input.state[dir] = false + return reached or pred() + end + local gate = rse and "EM_ROUTE110_SEASIDE_CYCLING_ROAD_NORTH_ENTRANCE" + or "FR_ROUTE_16_NORTH_ENTRANCE_1F" + Map.load(nil, game, gate, { x = rse and 6 or 3, y = rse and 4 or 12, + facing = rse and "right" or "left" }) + if not check(settle(), "cycling gate scripts settle") then return finish() end + check(game.data.maps[gate].bikingAllowed == 1, "cycling gate permits riding indoors") + check(ItemUse.useBike(session, bike) and Player.biking, "cycling gate bike mounts") + U.still(game, DIR .. "/cycling_gate_bike.png") + if rse then + if not check(holdUntil("right", function() + return Player.targetX == 12 and Player.targetY == 4 + end, 240), "Emerald gate crosses BikeCheck and reaches east exit column") then return finish() end + end + if not check(holdUntil(rse and "down" or "left", function() return session.map ~= gate end, 600), + "cycling gate enters road through coordinate script and warp") then return finish() end + if not check(settle(), "cycling road arrival settles") then return finish() end + check(Flags.getFlag(Space.store, nil, road), "cycling road flag armed by real gate scripts") + if scene then check(Flags.getVar(Space.store, nil, scene) == 1, "Route 16 scene armed") end + check(Player.biking, "cycling road requires bike before Fly") + for _, eo in ipairs(require("src.core.game3.objects").forDraw()) do eo.sight = 0 end + session.party = {} + Party.giveMon(session, C:require("species", rse and "SPECIES_SWELLOW" or "SPECIES_CHARIZARD"), 40) + local section = rse and "MAPSEC_MAUVILLE_CITY" or "MAPSEC_CELADON_CITY" + local dest = assert(Field.flyDestination(section)) + check(Field.flyTo(section, session.party[1]), "cycling road Fly starts") + if not check(waitFor(function() + return session.map == dest.map and not Field._flyLanding and not Field.locked and not Fade.active + end, 2400), "cycling Fly landing finishes") then return finish() end + check(not Flags.getFlag(Space.store, nil, road), "Fly clears cycling flag") + if scene then check(Flags.getVar(Space.store, nil, scene) == 0, "Fly clears Route 16 scene") end + check(not Player.biking and not session.biking and not game.save.biking, "Fly lands on foot in every state mirror") + U.still(game, DIR .. "/fly_city_on_foot.png") + local center = rse and "EM_MAUVILLE_CITY_POKEMON_CENTER_1F" or "FR_CELADON_CITY_POKEMON_CENTER_1F" + if not check(holdUntil("up", function() return session.map == center end, 300), + "walking north enters real Center door") then return finish() end + if not check(settle(), "Center warp finishes") then return finish() end + check(game.data.maps[center].bikingAllowed == 0 and not Player.biking and not session.biking, + "Center remains on foot after cycling road Fly") + U.still(game, DIR .. "/center_on_foot.png") + if not check(holdUntil("down", function() return session.map == dest.map end, 300), + "Center exit returns to city") then return finish() end + if not check(settle(), "Center exit settles") then return finish() end + check(ItemUse.useBike(session, bike) and Player.biking, "city bike mounts after cycling road Fly") + U.still(game, DIR .. "/city_bike_toggle.png") + check(ItemUse.useBike(session, bike) and not Player.biking, "city bike dismounts after cycling road Fly") + if rse then + local acro = C:require("items", "ITEM_ACRO_BIKE") + check(ItemUse.useBike(session, acro) and Player.biking and Player.bikeType == "acro", "city Acro Bike mounts") + check(ItemUse.useBike(session, acro) and not Player.biking, "city Acro Bike dismounts") + end + finish() +end diff --git a/tests/drivers/game3_message_speed_2570.lua b/tests/drivers/game3_message_speed_2570.lua new file mode 100644 index 00000000..950436b8 --- /dev/null +++ b/tests/drivers/game3_message_speed_2570.lua @@ -0,0 +1,109 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_message_speed_2570" +local failures = 0 +local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok +end +local function finish() + print((failures == 0 and "PASS " or "FAIL ") .. "message_speed_2570") + love.event.quit(failures == 0 and 0 or 1) +end + +return function(game) + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + if not check(game.boot ~= nil, "message speed boot reached") then return finish() end + game:_handleBootAction({ action = "new_game", name = "RED", gender = 0 }) + U.wait(240) + local Runtime = require("src.core.game3.runtime") + local session = Runtime.getSession() + if not check(session ~= nil, "message speed field reached") then return finish() end + local Options = require("src.core.game3.options") + local Message = require("src.ui.game3.message") + local Objects = require("src.core.game3.objects") + local Player = require("src.core.game3.player") + local Field = require("src.core.game3.field") + local Space = require("src.core.game3.scripting.space") + local Map = require("src.core.game3.map") + local Flags = require("src.core.game3.scripting.flags") + local Fade = require("src.ui.game3.fade") + local rse = session.version == "emerald" + local defs = Flags.forVersion(session.version) + local mapId = rse and "EM_OLDALE_TOWN_POKEMON_CENTER_1F" or "FR_VIRIDIAN_CITY_POKEMON_CENTER_1F" + local function waitFor(pred, limit) + for _ = 1, limit do + if pred() then return true end + U.wait(1) + end + return pred() + end + local realShow = Message.show + local observed + Message.show = function(text, opts) + local result = realShow(text, opts) + observed = { first = Message._pages[1], count = #Message._pages, typing = Message.isTyping() } + return result + end + local cases = rse and { + { id = 2, name = "oldale_gentleman" }, + { id = 3, name = "oldale_boy" }, + { id = 4, name = "oldale_girl_unavailable", dex = false }, + { id = 4, name = "oldale_girl_available", dex = true }, + } or { + { id = 2, name = "viridian_gentleman" }, + { id = 3, name = "viridian_boy" }, + } + for _, case in ipairs(cases) do + for speed = 0, 2 do + Options.set(session, "textSpeed", speed) + if rse and case.dex ~= nil then + Flags.setFlag(Space.store, nil, assert(defs.IDS.FLAG_SYS_POKEDEX_GET), case.dex) + end + Map.load(nil, game, mapId, { x = 7, y = 7, facing = "up" }) + if not check(waitFor(function() + return not Field.locked and not Fade.active and not (Space.vm and Space.vm:isRunning()) + end, 600), case.name .. " map settles") then Message.show = realShow return finish() end + local eo = Objects.find(case.id) + if not check(eo ~= nil, case.name .. " NPC exists") then Message.show = realShow return finish() end + eo.frozen = true + Player.reset(eo.cellX, eo.cellY + 1, "up") + Player.syncToHost(game) + observed = nil + U.tap(game, "a") + if not check(waitFor(function() return observed ~= nil and Message.isOpen() end, 120), + case.name .. " opens from one A") then Message.show = realShow return finish() end + check(observed.count >= 2, case.name .. " cached ROM text contains multiple paragraphs") + check(observed.typing, case.name .. " option " .. speed .. " starts by typing") + check(Message._page == 1 and Message.currentPage() == observed.first, + case.name .. " option " .. speed .. " preserves first paragraph after opening A") + if not check(waitFor(Message.isWaiting, 1600), case.name .. " first paragraph prints") then + Message.show = realShow return finish() + end + if speed == 0 then U.still(game, DIR .. "/" .. case.name .. "_first.png") end + for page = 2, observed.count do + U.tap(game, "a") + check(Message._page == page, case.name .. " fresh A advances to paragraph " .. page) + if not check(waitFor(Message.isWaiting, 1600), case.name .. " paragraph " .. page .. " prints") then + Message.show = realShow return finish() + end + end + if speed == 0 then U.still(game, DIR .. "/" .. case.name .. "_last.png") end + for _ = 1, 120 do + if not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) and not Field.locked then break end + U.tap(game, "a") + U.wait(2) + end + check(not Message.isOpen() and not (Space.vm and Space.vm:isRunning()) and not Field.locked, + case.name .. " script terminates and unlocks after final paragraph") + end + end + Message.show = realShow + Message.show("Instant", { speed = 0 }) + check(Message.isWaiting(), "explicit speed zero remains instant in live field printer") + Message.close() + finish() +end diff --git a/tests/drivers/game3_music_transition.lua b/tests/drivers/game3_music_transition.lua new file mode 100644 index 00000000..f91bb843 --- /dev/null +++ b/tests/drivers/game3_music_transition.lua @@ -0,0 +1,207 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_music_transition" + +local function le16(n) + return string.char(n % 256, math.floor(n / 256) % 256) +end +local function le32(n) + return le16(n % 65536) .. le16(math.floor(n / 65536)) +end +local function writeWav(path, sd) + local data, rate, channels = sd:getString(), sd:getSampleRate(), sd:getChannelCount() + local file = assert(io.open(path, "wb")) + file:write("RIFF", le32(36 + #data), "WAVEfmt ", le32(16), le16(1), le16(channels), + le32(rate), le32(rate * channels * 2), le16(channels * 2), le16(16), "data", le32(#data), data) + file:close() +end +local function peak(sd) + local value = 0 + for i = 0, sd:getSampleCount() - 1 do + for ch = 1, sd:getChannelCount() do value = math.max(value, math.abs(sd:getSample(i, ch))) end + end + return value +end + +return function(game) + local failures = 0 + local deadline = love.timer.getTime() + 25 + local Audio, realOut, realCmd, realSource + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + local function waitUntil(pred, seconds) + local untilTime = math.min(deadline, love.timer.getTime() + seconds) + while love.timer.getTime() < untilTime do + if pred() then return true end + U.wait(1) + end + return pred() + end + local ok, err = xpcall(function() + if not check((os.getenv("POKEPORT_IDENTITY") or ""):match("^bsa0930%-") ~= nil, + "isolated campaign identity") then return end + game.driverSpeed = 1 + game.speedOverride = 1 + if not check(waitUntil(function() return game.boot ~= nil end, 4), "Gen3 boot ready") then return end + local Version = require("src.core.GameVersion") + local version = Version.get() + local emerald = version == "emerald" + local mapA, mapB = emerald and "EM_LITTLEROOT_TOWN" or "FR_PALLET_TOWN", + emerald and "EM_ROUTE101" or "FR_ROUTE_1" + local x, y = emerald and 5 or 10, emerald and 9 or 8 + game:_handleBootAction({ action = "new_game", name = "RED", + start = { map = mapA, x = x, y = y, facing = "down" } }) + Audio = require("src.core.game3.audio") + local Map = require("src.core.game3.map") + local Runtime = require("src.core.game3.runtime") + local Message = require("src.ui.game3.message") + local Field = require("src.core.game3.field") + local Hud = require("src.ui.game3.hud") + local Fade = require("src.ui.game3.fade") + local adapter = require("src.core.game3.scripting.adapters").host(nil, game, nil) + if not check(waitUntil(function() + if Message.isOpen() then U.tap(game, "b") end + return not Field.locked and not Message.isOpen() and not Hud.busy() and not Fade.active + end, 3), "Gen3 field settled") then return end + print(string.format("AUDIO ready=%s root=%s pack=%s worker=%s", tostring(Audio._ready), + tostring(Audio._root), tostring(Audio._pack ~= nil), tostring(Audio._worker ~= nil and Audio._worker ~= false))) + if not check(waitUntil(Audio.isReady, 3), "real audio pack ready") then return end + local defs = game.data and game.data.maps + local rawA, rawB = defs and defs[mapA] and defs[mapA].music, defs and defs[mapB] and defs[mapB].music + local a, b = Audio.resolveSong(rawA), Audio.resolveSong(rawB) + print(string.format("MAP SONGS A=%s raw=%s id=%s B=%s raw=%s id=%s", mapA, tostring(rawA), + tostring(a), mapB, tostring(rawB), tostring(b))) + if not check(type(a) == "number" and type(b) == "number" and a ~= b and Audio.songInfo(a) + and Audio.songInfo(b), "cached neighboring maps resolve distinct BGM") then return end + local ff = Audio.songs().MUS_LEVEL_UP + if not check(ff and waitUntil(function() return Audio._fanfareSd[ff] ~= nil end, 4), + "real fanfare PCM baked") then return end + + local captured, commands = {}, {} + local messages = setmetatable({}, { __mode = "k" }) + realOut, realCmd, realSource = Audio._outCh, Audio._cmdCh, Audio._bgmSource + if not check(realOut and realCmd and realSource, "real worker and queueable source ready") then return end + Audio._outCh = setmetatable({}, { __index = function(_, key) + if key == "pop" then return function() + local msg = realOut:pop() + if type(msg) == "table" and msg.data then messages[msg.data] = msg end + return msg + end end + return function(_, ...) return realOut[key](realOut, ...) end + end }) + Audio._cmdCh = setmetatable({}, { __index = function(_, key) + if key == "push" then return function(_, msg) + commands[#commands + 1] = msg + return realCmd:push(msg) + end end + return function(_, ...) return realCmd[key](realCmd, ...) end + end }) + Audio._bgmSource = setmetatable({}, { __index = function(_, key) + if key == "queue" then return function(_, sd) + local queued = realSource:queue(sd) + local msg = messages[sd] + if queued ~= false and msg then + captured[#captured + 1] = { gen = msg.gen, epoch = msg.epoch, at = msg.at, sd = sd, peak = peak(sd) } + end + return queued + end end + local value = realSource[key] + if type(value) ~= "function" then return value end + return function(_, ...) return value(realSource, ...) end + end }) + local function go(map) + Map.load(nil, game, map, { x = x, y = y, facing = "down" }) + return Runtime.getSession().map == map + end + local function pcm(label, from) + local packet + local found = waitUntil(function() + for i = (from or 0) + 1, #captured do + local c = captured[i] + if c.gen == a and c.epoch == Audio._bgmEpoch and c.peak > 0 then packet = c; return true end + end + return false + end, 3) + check(found and Audio._bgmGen == a and Audio._bgmSource:isPlaying(), label .. " queues audible latest A PCM") + if packet then + writeWav(DIR .. "/" .. label .. ".wav", packet.sd) + print(string.format("PCM %s gen=%s epoch=%s at=%s samples=%d peak=%.6f", + label, tostring(packet.gen), tostring(packet.epoch), tostring(packet.at), packet.sd:getSampleCount(), packet.peak)) + end + return found + end + check(U.still(game, DIR .. "/01_initial_map.png"), "initial map screenshot") + for _, speed in ipairs({ 1, 200 }) do + game.speedOverride = speed + local prefix = version .. "_" .. speed .. "x" + check(game:logicSpeed() == speed, prefix .. " uses requested simulation speed") + check(go(mapA), prefix .. " starts on map A") + Audio.playSong(a, { restart = true }) + if not pcm(prefix .. "_before", #captured) then return end + local epoch, count = Audio._bgmEpoch, #commands + Audio.playSong(a) + check(Audio._bgmEpoch == epoch and #commands == count, prefix .. " stable same-song avoids restart") + + local from = #captured + Audio.fadeOutAndPlay(b, 1) + if not emerald then + Audio.fadeDefaultBgm(1) + check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " default map replaces pending B fade") + end + check(go(mapA), prefix .. " reenters A during pending fade to B") + if not pcm(prefix .. "_fade_return", from) then return end + check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " newer A owns interrupted fade") + + from = #captured + Audio.playFanfare(ff) + check(Audio._fanfareActive and Audio._fanfareSource and Audio._fanfareSource:isPlaying(), prefix .. " real fanfare starts") + if not emerald then + Audio.changeMusicTo(b) + Audio.changeMusicTo(a) + check(Audio._fanfareDeferred == a, prefix .. " changeMusicTo replaces deferred B with A") + Audio.bikeMusic(true, true) + Audio.bikeMusic(false) + check(Audio._fanfareDeferred == a, prefix .. " bike dismount replaces deferred cycling with A") + Audio.changeMusicTo(b) + Audio.fadeDefaultBgm(1) + check(Audio._fanfareDeferred == a and Audio._fanfareActive and Audio._bgmPaused, + prefix .. " default map replaces deferred B while preserving fanfare") + end + check(go(mapB) and go(mapA), prefix .. " crosses B and returns A during fanfare") + check(Audio._fanfareDeferred == a, prefix .. " latest map A replaces deferred B") + Audio.fadeOutAndPlay(b, 1) + check(Audio.currentMapMusic() == b, prefix .. " script fixture defers older B during fanfare") + epoch, count = Audio._bgmEpoch, #commands + adapter.playBgm(a) + check(Audio.currentMapMusic() == a and Audio._fanfareDeferred == nil, + prefix .. " actual script playBgm(A) supersedes fanfare-deferred B") + check(Audio._fanfareActive and Audio._bgmPaused and Audio._bgmEpoch == epoch and #commands == count, + prefix .. " actual script same-A preserves fanfare and physical continuation") + if not check(waitUntil(function() return not Audio._fanfareActive end, 4), prefix .. " fanfare completes") then return end + check(Audio.currentMapMusic() == a, prefix .. " actual script fanfare finishes on latest A") + if not pcm(prefix .. "_fanfare_return", from) then return end + check(U.still(game, DIR .. "/" .. prefix .. "_returned_map.png"), prefix .. " returned map screenshot") + + from = #captured + Audio.fadeOutBgm(1) + Audio.playSong(a) + if not pcm(prefix .. "_standalone_fade", from) then return end + check(Audio._fadeOut == nil and Audio._currentSong.id == a, prefix .. " same-song cancels standalone fade") + + Audio.playFanfare(ff) + Audio.playSong(0) + count = #commands + if not check(waitUntil(function() return not Audio._fanfareActive end, 4), prefix .. " stopped-BGM fanfare completes") then return end + local revived = false + for i = count + 1, #commands do if commands[i].cmd == "play" then revived = true end end + check(Audio._bgmGen == nil and Audio._currentSong == nil and not Audio._bgmSource:isPlaying() + and not revived, prefix .. " explicit stop stays silent without revived play") + end + end, debug.traceback) + if Audio and realOut then Audio._outCh, Audio._cmdCh, Audio._bgmSource = realOut, realCmd, realSource end + if not ok then check(false, tostring(err)) end + print((failures == 0 and "PASS" or "FAIL") .. " game3_music_transition failures=" .. failures) + love.event.quit(failures == 0 and 0 or 1) +end diff --git a/tests/drivers/game3_surf_connections.lua b/tests/drivers/game3_surf_connections.lua index 87018211..c0f11318 100644 --- a/tests/drivers/game3_surf_connections.lua +++ b/tests/drivers/game3_surf_connections.lua @@ -61,7 +61,7 @@ return function(game) and session.x == x and session.y == y, label) end local function shot(name) - return result(U.shot(game, DIR .. "/2394_" .. name .. ".png"), "shot_" .. name) + return result(U.still(game, DIR .. "/2394_" .. name .. ".png"), "shot_" .. name) end local function phases(name) U.wait(90) diff --git a/tests/drivers/launch_update_mods_bug2037.lua b/tests/drivers/launch_update_mods_bug2037.lua index 59f6124a..3a4a4e81 100644 --- a/tests/drivers/launch_update_mods_bug2037.lua +++ b/tests/drivers/launch_update_mods_bug2037.lua @@ -84,12 +84,15 @@ return function(game) end local imp - local pending = nil + local drawSerial = 0 + local Transition = require("src.ui.kit.Transition") + local realDraw = love.draw love.draw = function() if imp then imp:draw() end - if pending then - local path = pending - pending = nil + drawSerial = drawSerial + 1 + if game.capturePath then + local path = game.capturePath + game.capturePath = nil love.graphics.captureScreenshot(function(imagedata) local f = io.open(path, "wb") if f then f:write(imagedata:encode("png"):getString()) f:close() end @@ -107,15 +110,10 @@ return function(game) end end local function shot(name) - pending = dir .. "/" .. name - for _ = 1, 30 do - if not pending then break end - step(1) - end - step(2) - local f = io.open(dir .. "/" .. name, "rb") - U.log(f and "shot" or "FAIL shot", name) - if f then f:close() end + local before, deadline = drawSerial, love.timer.getTime() + 3 + while love.timer.getTime() < deadline and (drawSerial == before or Transition.active()) do U.wait(1) end + expect(drawSerial > before and not Transition.active(), "shot draw settled " .. name) + expect(U.still(game, dir .. "/" .. name), "shot " .. name) end imp = newLauncher() @@ -182,6 +180,7 @@ return function(game) LauncherMods.checkDependencies = realDeps Platform.canFetchRemote = realRemote imp = nil + love.draw = realDraw U.log("done") love.event.quit(failed and 1 or 0) end diff --git a/tests/drivers/launcher_emerald_release_90005.lua b/tests/drivers/launcher_emerald_release_90005.lua new file mode 100644 index 00000000..6a5fa705 --- /dev/null +++ b/tests/drivers/launcher_emerald_release_90005.lua @@ -0,0 +1,134 @@ +return function(game) + local U = require("tests.drivers.util") + local identity = os.getenv("POKEPORT_IDENTITY") + if not identity or identity:lower():find("pokemon-love2d", 1, true) then + print("FAIL launcher_emerald_release_90005 requires an isolated identity") + love.event.quit(1) + return + end + + local SaveData = require("src.core.SaveData") + local SecretGames = require("src.import.SecretGames") + local RomImporter = require("src.import.RomImporter") + local LauncherView = require("src.import.LauncherView") + local GameVersion = require("src.core.GameVersion") + local Kit = require("src.ui.kit.Kit") + local dir = assert(os.getenv("POKEPORT_SHOT_DIR"), "POKEPORT_SHOT_DIR is required") + local originalSecret = SaveData.loadOptions().secretEmerald == true + local realTime, now = os.time, 1790855999 + local realSaveOptions = SaveData.saveOptions + local originalDraw, originalUpdate = rawget(game, "draw"), rawget(game, "update") + local failures, drawn, writes = 0, 0, 0 + local imp + + local function check(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then failures = failures + 1 end + return ok + end + + local function finish() + os.time = realTime + SaveData.saveOptions = realSaveOptions + SaveData.saveOptions({ secretEmerald = originalSecret }) + game.draw, game.update = originalDraw, originalUpdate + print((failures == 0 and "PASS " or "FAIL ") .. "launcher_emerald_release_90005") + love.event.quit(failures == 0 and 0 or 1) + end + + os.time = function(t) + if t then return realTime(t) end + return now + end + SaveData.saveOptions({ secretEmerald = false }) + SecretGames.reload() + imp = RomImporter.new(function() end, { launcher = true, reduceMotion = true }) + if not check(imp.ready.emerald == true, "existing Emerald cache is ready") then + finish() + return + end + + local flags = { resizable = true, highdpi = true, fullscreen = false } + if os.getenv("POKEPORT_BACKGROUND") == "1" then + flags.x, flags.y, flags.borderless = -30000, -30000, true + end + love.window.setMode(1280, 720, flags) + game.draw = function() + imp:draw() + drawn = drawn + 1 + end + game.update = function(_, dt) imp:update(dt) end + + local function drawFrames(n) + for _ = 1, n do + local seen = drawn + for _ = 1, 4000 do + U.wait(1) + if drawn > seen then break end + end + if not check(drawn > seen, "launcher frame reached the renderer") then return false end + end + return true + end + + local function navHas(id) + for i = 1, Kit._navPrevN or 0 do + local slot = Kit._nav[i] + if slot and slot.id == id then return true end + end + return false + end + + local function shot(name) + if not drawFrames(2) then return false end + return check(U.still(game, dir .. "/" .. name .. ".png"), "capture " .. name) + end + + imp._gamePopup = true + local lockedTabs = LauncherView.gameTabs(imp) + check(#lockedTabs == 8 and not SecretGames.visible("emerald"), "before deadline Emerald remains hidden") + if not shot("90005_before_deadline_choose_game") then finish() return end + check(not navHas("gamepop-emerald"), "before deadline Choose game has no Emerald row") + + SaveData.saveOptions = function(...) + writes = writes + 1 + return realSaveOptions(...) + end + now = 1790856000 + imp:update(1 / 60) + local releasedTabs = LauncherView.gameTabs(imp) + check(#releasedTabs == 9 and releasedTabs ~= lockedTabs, "deadline refreshes the same launcher's cached tabs") + check(SaveData.loadOptions().secretEmerald == true and writes == 1, "deadline persists once") + check(imp._secretPopup == nil and imp._logoTaps == nil, "deadline opens no BLITZ popup") + if not shot("90005_deadline_choose_game_emerald") then finish() return end + check(navHas("gamepop-emerald") and not navHas("secret-ok"), "deadline Choose game includes Emerald without confirmation") + for _ = 1, 20 do imp:update(1 / 60) imp:_logoTap() end + check(writes == 1 and imp._secretPopup == nil, "repeated released updates and taps remain silent") + check(imp:_versionForSha1(GameVersion.info("emerald").sha1) == "emerald", "deadline recognizes Emerald ROM") + + SaveData.saveOptions = realSaveOptions + now = 1790855999 + SaveData.saveOptions({ secretEmerald = false }) + SecretGames.reload() + imp = RomImporter.new(function() end, { launcher = true, reduceMotion = true }) + for _ = 1, SecretGames.TAPS do imp:_logoTap() end + check(imp._secretPopup == true, "pre-deadline manual BLITZ mechanism is preserved") + if not shot("90005_before_deadline_manual_blitz") then finish() return end + check(navHas("secret-ok"), "manual BLITZ confirmation is rendered before release") + + now = 1790856000 + LauncherView.gameTabs(imp) + imp:update(1 / 60) + check(imp._secretPopup == nil and imp._logoTaps == nil, "deadline clears an already-open BLITZ even after another reader unlocks") + if not shot("90005_deadline_manual_blitz_dismissed") then finish() return end + check(not navHas("secret-ok"), "scheduled release leaves no BLITZ confirmation control") + + imp:_switchTab("emerald") + check(imp.tab == "emerald", "released launcher selects Emerald normally") + if not shot("90005_deadline_emerald_panel") then finish() return end + check(navHas("play-emerald"), "released Emerald panel has its Play control") + now = 1790855999 + SecretGames.reload() + check(SecretGames.visible("emerald"), "persisted release survives clock rollback") + finish() +end diff --git a/tests/engine/game3_audio_transition_test.lua b/tests/engine/game3_audio_transition_test.lua new file mode 100644 index 00000000..516f880e --- /dev/null +++ b/tests/engine/game3_audio_transition_test.lua @@ -0,0 +1,204 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = require("tests.love_stub") +local Version = require("src.core.GameVersion") +local Audio = require("src.core.game3.audio") +local Song = require("src.core.game3.song_ids") +local Player = require("src.core.game3.m4a_player") +local Adapters = require("src.core.game3.scripting.adapters") + +local function channel() + local ch = { items = {} } + function ch:push(v) self.items[#self.items + 1] = v end + function ch:pop() return table.remove(self.items, 1) end + function ch:clear() self.items = {} end + return ch +end + +local function source() + local src = { playing = true, volume = 1, queued = {}, stops = 0 } + function src:stop() self.playing = false; self.stops = self.stops + 1; self.queued = {} end + function src:play() self.playing = true end + function src:isPlaying() return self.playing end + function src:setVolume(v) self.volume = v end + function src:getFreeBufferCount() return Player.BUFFER_COUNT end + function src:queue(data) self.queued[#self.queued + 1] = data; return true end + return src +end + +local function setup(version) + Version.set(version) + local songs = Song.forVersion(version) + local a = songs.MUS_LITTLEROOT or songs.MUS_PALLET + local b = songs.MUS_OLDALE or songs.MUS_VIRIDIAN_FOREST + assert(type(a) == "number" and type(b) == "number" and a ~= b) + Audio._ready, Audio._worker = true, true + Audio._pack = { index = { songs = {} } } + Audio._meta, Audio._fanfareCh, Audio._statusCh = nil, nil, nil + Audio._cmdCh, Audio._outCh = channel(), channel() + Audio._bgmSource, Audio._fanfareSource = source(), source() + Audio._currentSong, Audio._bgmGen = { id = a }, a + Audio._bgmEpoch, Audio._bgmQueuedAt, Audio._bgmBaseAt = 3, {}, 0 + Audio._bgmLocal, Audio._pendingBgm, Audio._bgmPaused = nil, nil, false + Audio._fadeOut, Audio._fadeIn = nil, nil + Audio._fanfareActive, Audio._fanfareFrames = false, 0 + Audio._fanfareRestore, Audio._fanfareDeferred, Audio._fanfarePending = nil, nil, nil + Audio._waitFanfareCb, Audio._suspended, Audio._mapSong = nil, false, a + return a, b +end + +local function fanfare(a) + Audio.pauseBgm() + Audio._fanfareActive, Audio._fanfareFrames, Audio._fanfareRestore = true, 1, a +end + +local function lastPlay() + for i = #Audio._cmdCh.items, 1, -1 do + local m = Audio._cmdCh.items[i] + if m.cmd == "play" then return m end + end +end + +for _, version in ipairs({ "emerald", "firered", "leafgreen" }) do + local a, b = setup(version) + Audio.playSong(a) + eq(Audio._bgmEpoch, 3, version .. " stable same-song keeps epoch") + eq(#Audio._cmdCh.items, 0, version .. " stable same-song sends no restart") + eq(Audio._bgmSource.stops, 0, version .. " stable same-song preserves source") + + a, b = setup(version) + fanfare(a) + local adapter = Adapters.host(nil, { data = { maps = {} } }, nil) + local epoch, count, callback = Audio._bgmEpoch, #Audio._cmdCh.items, 0 + local sourceStops = Audio._bgmSource.stops + Audio._waitFanfareCb = function() callback = callback + 1 end + adapter.playBgm(a) + eq(Audio._bgmEpoch, epoch, version .. " stable script same-A during fanfare preserves epoch") + eq(#Audio._cmdCh.items, count, version .. " stable script same-A during fanfare sends no restart") + Audio.fadeOutAndPlay(b, 1) + eq(Audio.currentMapMusic(), b, version .. " actual script fixture has fanfare-deferred B") + adapter.playBgm(a) + eq(Audio.currentMapMusic(), a, version .. " actual script playBgm(A) supersedes fanfare-deferred B") + check(Audio._fanfareDeferred == nil, version .. " actual script A clears obsolete deferred B") + check(Audio._fanfareActive and Audio._bgmPaused and Audio._fanfareSource:isPlaying(), + version .. " actual script same-A preserves fanfare lifetime and BGM pause") + eq(Audio._bgmEpoch, epoch, version .. " actual script same-A preserves physical epoch") + eq(#Audio._cmdCh.items, count, version .. " actual script same-A sends no restart") + eq(Audio._bgmSource.stops, sourceStops, version .. " actual script same-A preserves physical source") + Audio.update(1) + eq(Audio.currentMapMusic(), a, version .. " actual script fanfare resumes latest A") + eq(lastPlay(), nil, version .. " actual script fanfare never starts obsolete B") + check(Audio._bgmSource:isPlaying() and not Audio._bgmPaused, + version .. " actual script fanfare resumes audible A") + eq(callback, 1, version .. " actual script same-A completes fanfare wait once") + + a, b = setup(version) + Audio.fadeOutAndPlay(b, 1) + Audio.playSong(a) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " newer A cancels pending B") + eq(lastPlay() and lastPlay().id, a, version .. " worker plays latest A") + check(Audio._fadeOut == nil, version .. " interrupted fade removed") + local stops = Audio._bgmSource.stops + Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "A PCM", at = 0, n = 16 }) + Audio.update(1) + check(Audio._bgmSource:isPlaying() and Audio._bgmSource.volume > 0, version .. " replacement source remains audible") + eq(Audio._bgmSource.stops, stops, version .. " old fade cannot stop replacement") + + a, b = setup(version) + Audio.fadeOutBgm(1) + eq(Audio.currentMapMusic(), 0, version .. " fade-to-stop reports logical silence") + Audio.playSong(a) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " explicit same A cancels standalone fade") + Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "resumed PCM", at = 0, n = 16 }) + Audio.update(1) + check(Audio._bgmSource:isPlaying() and Audio._bgmSource.volume > 0, version .. " standalone fade replacement is audible") + + a, b = setup(version) + fanfare(a) + local callback = 0 + Audio._waitFanfareCb = function() callback = callback + 1 end + Audio._fanfareDeferred = b + Audio.playSong(0) + check(Audio._fanfareActive and Audio._fanfareSource:isPlaying(), version .. " BGM stop preserves fanfare lifetime") + Audio.update(1) + eq(Audio._currentSong, nil, version .. " stop during fanfare stays silent") + eq(Audio._bgmGen, nil, version .. " stop during fanfare clears generation") + check(not Audio._bgmSource:isPlaying(), version .. " stop during fanfare stops source") + eq(lastPlay(), nil, version .. " fanfare completion sends no revived play") + eq(callback, 1, version .. " stopped BGM still completes fanfare wait") + + a, b = setup(version) + Audio.fadeOutAndPlay(b, 1) + Audio.playSong(0) + Audio._outCh:push({ gen = a, epoch = Audio._bgmEpoch, data = "stale PCM", at = 0, n = 16 }) + Audio.update(1) + eq(Audio._currentSong, nil, version .. " stop during fade stays silent") + check(not Audio._bgmSource:isPlaying() and #Audio._bgmSource.queued == 0, version .. " explicit stop rejects old queued PCM") + + a, b = setup(version) + Audio._outCh:push({ gen = a, epoch = 2, data = "old epoch", at = 0, n = 16 }) + Audio._outCh:push({ gen = b, epoch = 3, data = "old song", at = 0, n = 16 }) + Audio._outCh:push({ gen = a, epoch = 3, data = "current PCM", at = 16, n = 16 }) + Audio.pumpBgm() + eq(#Audio._bgmSource.queued, 1, version .. " pump rejects stale generation and epoch") + eq(Audio._bgmSource.queued[1], "current PCM", version .. " pump queues current PCM") + if version ~= "emerald" then + a, b = setup(version) + fanfare(a) + Audio.changeMusicTo(b) + Audio.changeMusicTo(a) + eq(Audio._fanfareDeferred, a, version .. " changeMusicTo replaces deferred B with A") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " changeMusicTo fanfare resumes latest A") + + a, b = setup(version) + fanfare(a) + Audio.bikeMusic(true, true) + Audio.bikeMusic(false) + eq(Audio._fanfareDeferred, a, version .. " bike dismount replaces deferred cycling with A") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " dismount fanfare resumes map A") + + a, b = setup(version) + Audio.fadeOutAndPlay(b, 1) + Audio.fadeDefaultBgm(1) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " fadeDefaultBgm replaces pending B with map A") + + a, b = setup(version) + fanfare(a) + Audio.changeMusicTo(b) + Audio.fadeDefaultBgm(1) + eq(Audio._fanfareDeferred, a, version .. " fadeDefaultBgm replaces fanfare-deferred B with map A") + check(Audio._fanfareActive and Audio._bgmPaused, version .. " default map request preserves active fanfare pause") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, version .. " default map request fanfare resumes A") + end +end + +do + local a, b = setup("emerald") + local session = { version = "emerald", map = "EM_LITTLEROOT_TOWN", x = 5, y = 5 } + package.loaded["src.core.game3.runtime"] = { getSession = function() return session end, + _game = { data = { maps = { EM_LITTLEROOT_TOWN = { music = a }, EM_OLDALE_TOWN = { music = b } } } } } + Audio.fadeOutAndPlay(b, 1) + Audio.mapLoadMusic({ mapId = session.map }) + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, "Emerald actual map policy replaces pending fade B with A") + a, b = setup("emerald") + fanfare(a) + session.map = "EM_OLDALE_TOWN" + Audio.mapLoadMusic({ mapId = session.map }) + eq(Audio.currentMapMusic(), b, "Emerald logical music reflects fanfare-deferred B") + session.map = "EM_LITTLEROOT_TOWN" + Audio.mapLoadMusic({ mapId = session.map }) + eq(Audio._fanfareDeferred, a, "Emerald return map replaces deferred B with A") + Audio.update(1) + eq(Audio._currentSong and Audio._currentSong.id, a, "Emerald fanfare resumes latest map A") + check(Audio._bgmSource:isPlaying() and not Audio._bgmPaused, "Emerald fanfare restores audible A") +end + +T.finish("game3_audio_transition_test") diff --git a/tests/engine/launcher_secret_emerald_test.lua b/tests/engine/launcher_secret_emerald_test.lua index 7d74f7a6..f7014ea5 100644 --- a/tests/engine/launcher_secret_emerald_test.lua +++ b/tests/engine/launcher_secret_emerald_test.lua @@ -4,6 +4,12 @@ local T = require("tests.harness") local check = T.check love = love or require("tests.love_stub") +local realTime, now = os.time, 1790855999 +os.time = function(t) + if t then return realTime(t) end + return now +end + love.graphics.setLineJoin = love.graphics.setLineJoin or function() end love.graphics.newShader = love.graphics.newShader or function() return {} end @@ -121,4 +127,83 @@ check(ids(LauncherView.gameTabs(fresh)):find(" emerald ", 1, true) ~= nil, "a new launcher lists emerald after unlock") check(fresh._logoTaps == nil, "the tap counter starts over with each launcher") +T.eq(SecretGames.EMERALD_RELEASE_AT, 1790856000, "release is October 1 at 12 UTC") + +SaveData.saveOptions({ secretEmerald = false, lastVersion = "blue" }) +SecretGames.reload() +local timed = newLauncher() +local lockedTabs = LauncherView.gameTabs(timed) +local lockedRev = SecretGames.rev +check(not ids(lockedTabs):find(" emerald ", 1, true), "one second early stays locked") +for _ = 1, SecretGames.TAPS do timed:_logoTap() end +check(timed._secretPopup == true, "a pre-release BLITZ popup can be open at the deadline") + +local realSaveOptions, writes = SaveData.saveOptions, 0 +SaveData.saveOptions = function(...) + writes = writes + 1 + return realSaveOptions(...) +end +now = 1790856000 +local releasedTabs = LauncherView.gameTabs(timed) +check(releasedTabs ~= lockedTabs, "deadline invalidates the already-cached game list") +T.eq(#releasedTabs, 9, "deadline exposes all nine games") +check(ids(releasedTabs):find(" emerald ", 1, true) ~= nil, "deadline includes Emerald") +T.eq(SecretGames.rev, lockedRev + 1, "scheduled unlock advances the revision once") +T.eq(writes, 1, "scheduled unlock persists once") +local releasedOptions = SaveData.loadOptions() +check(releasedOptions.secretEmerald == true, "scheduled unlock persists secretEmerald") +T.eq(releasedOptions.lastVersion, "blue", "unlock preserves unrelated launcher options") + +timed:update(1 / 60) +check(timed._secretPopup == nil and timed._logoTaps == nil, + "active launcher silently clears an already-open BLITZ popup and tap counter") +timed._gamePopup = true +rects = drawRects(timed) +check(rects["gamepop-emerald"] ~= nil and rects["secret-ok"] == nil, + "the active launcher draws Emerald without a BLITZ confirmation") +timed._gamePopup = nil +check(timed:_versionForSha1(emeraldSha) == "emerald", "release recognizes Emerald immediately") +for _ = 1, 10 do + timed:update(1 / 60) + timed:_logoTap() + T.eq(LauncherView.gameTabs(timed), releasedTabs, "released game list remains cached") +end +T.eq(writes, 1, "repeated frames and logo taps do not repeat persistence") +T.eq(SecretGames.rev, lockedRev + 1, "repeated frames do not repeat cache invalidation") +check(timed._secretPopup == nil and timed._logoTaps == nil, "released logo taps stay silent") + +now = 1790856001 +local releaseBoot = RomImporter.new(function() end, { launcher = true, initialTab = "emerald" }) +T.eq(releaseBoot.tab, "emerald", "a launcher opened after release accepts its initial Emerald tab") +SecretGames.reload() +check(SecretGames.visible("emerald"), "scheduled unlock survives a settings reload") +T.eq(writes, 1, "reloading the persisted unlock does not rewrite options") +now = 1790855999 +SecretGames.reload() +check(SecretGames.visible("emerald"), "a clock rollback cannot relock a persisted unlock") +T.eq(writes, 1, "clock rollback does not rewrite options") +check(ids(SecretGames.order({ launcher = false })):find(" emerald ", 1, true) ~= nil, + "scheduled availability preserves nonlauncher import recognition") + +SaveData.saveOptions = realSaveOptions +SaveData.saveOptions({ secretEmerald = false }) +SecretGames.reload() +local polling = newLauncher() +LauncherView.gameTabs(polling) +now = 1790856000 +polling:update(1 / 60) +check(SaveData.loadOptions().secretEmerald == true, + "an already-open launcher update alone applies the scheduled unlock") +check(ids(LauncherView.gameTabs(polling)):find(" emerald ", 1, true) ~= nil, + "an already-open launcher needs no restart to expose Emerald") + +now = 1790855999 +SaveData.saveOptions({ secretEmerald = false }) +SecretGames.reload() +now = 1790856000 +local initial = RomImporter.new(function() end, { launcher = true, initialTab = "emerald" }) +T.eq(initial.tab, "emerald", "a fresh deadline boot unlocks before initial tab validation") +check(SaveData.loadOptions().secretEmerald == true, "a fresh deadline boot persists the unlock") + +os.time = realTime T.finish("launcher secret emerald") diff --git a/tests/engine/launcher_skins_options_reads_bug2038.lua b/tests/engine/launcher_skins_options_reads_bug2038.lua index f43be47c..e2cc8f5a 100644 --- a/tests/engine/launcher_skins_options_reads_bug2038.lua +++ b/tests/engine/launcher_skins_options_reads_bug2038.lua @@ -21,8 +21,8 @@ SaveData.loadOptions = function() end SaveData.saveOptions = function(opts) local tc = type(opts) == "table" and type(opts.touchControls) == "table" - and opts.touchControls or {} - stored.enabled, stored.skin = tc.enabled, tc.skin + and opts.touchControls + if tc then stored.enabled, stored.skin = tc.enabled, tc.skin end return true end diff --git a/tests/game3_carried_actor_ghost_test.lua b/tests/game3_carried_actor_ghost_test.lua new file mode 100644 index 00000000..8c824c2b --- /dev/null +++ b/tests/game3_carried_actor_ghost_test.lua @@ -0,0 +1,225 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local Objects = require("src.core.game3.objects") +local Ghosts = require("src.core.game3.ghosts") +local Player = require("src.core.game3.player") +local Map = require("src.core.game3.map") +local Runtime = require("src.core.game3.runtime") +local Space = require("src.core.game3.scripting.space") +Space.bundle = { events = {} } +local game = { data = { maps = {} } } +Runtime.session = { version = "firered", map = "TEST_A", flags = {}, vars = {} } +Runtime._game = game +local layout = { width = 64, height = 64, collAt = function() return 0 end } +local first = { midLayout = layout, objects = { + { localId = 7, x = 10, y = 10 }, + { localId = 8, x = 12, y = 10 }, + { localId = 9, x = 15, y = 10 }, +} } +local empty = { midLayout = layout, objects = {} } +Objects.clear() +Ghosts.clear() +Objects.loadMap(game, "TEST_A", first) +Player.reset(10, 9, "down") +local boat, actor, npc = Objects.find(7), Objects.find(8), Objects.find(9) +local callbacks = { boat = 0, actor = 0, player = 0 } +local actions = { + { kind = "step", dir = "down", fast = true }, + { kind = "step", dir = "down", fast = true }, + { kind = "step", dir = "down", fast = true }, +} +Objects.startTrack(7, actions, function() callbacks.boat = callbacks.boat + 1 end) +Objects.startTrack(8, actions, function() callbacks.actor = callbacks.actor + 1 end) +Objects.startTrack(255, actions, function() callbacks.player = callbacks.player + 1 end) +npc.moving, npc.targetX, npc.targetY = true, npc.cellX, npc.cellY + 1 +npc.progress, npc.animClock, npc.stepFrames = 0, 0, 16 +npc.scriptBusy, npc.frozen = true, true + +local function shiftPlayer(dx, dy) + Player.cellX, Player.cellY = Player.cellX + dx, Player.cellY + dy + Player.targetX, Player.targetY = Player.targetX + dx, Player.targetY + dy + Player.px, Player.py = Player.px + dx * 16, Player.py + dy * 16 +end +local function transfer(from, to, dx, dy) + local carry = Objects.carryOut(dx, dy) + shiftPlayer(dx, dy) + Ghosts.capture(from) + local pool = Ghosts._pools[from] + for _, eo in pairs(pool.byId) do + T.check(eo ~= boat and eo ~= actor, "carried actors absent from departing ghost pool " .. from) + end + T.check(boat.scriptBusy and boat.frozen and actor.scriptBusy and actor.frozen, + "capture preserves held movement state " .. from) + Objects.loadMap(game, to, empty) + Objects.carryIn(carry) + Runtime.session.map = to + Map.world = { { id = "TEST_A", def = first, ox = 0, oy = 0 } } + if from ~= "TEST_A" then Map.world[#Map.world + 1] = { id = from, def = empty, ox = 0, oy = 0 } end + T.eq(Objects.find(Objects.foreignKey("TEST_A", 7)), boat, "foreign boat lookup survives " .. to) + T.eq(Objects.find(Objects.foreignKey("TEST_A", 8)), actor, "generic foreign lookup survives " .. to) +end +transfer("TEST_A", "TEST_B", 2, 0) +T.eq(Ghosts._pools.TEST_A.byId[9], npc, "ordinary departing NPC remains captured") +T.check(not npc.scriptBusy and not npc.frozen, "ordinary departing NPC is released") +local firstNpcY = npc.py +local function frame() + local before = boat.py + Objects.update(game) + local afterLive = boat.py + Ghosts.update(game) + T.eq(boat.py, afterLive, "ghost pass never moves live carried boat twice") + Player.tick(game) + T.eq(boat.py - Player.py, 16, "boat and player retain relative sailing pixels") + return boat.py - before +end +frame() +T.check(npc.py > firstNpcY, "ordinary ghost movement still advances") +transfer("TEST_B", "TEST_C", 3, 0) +for _ = 1, 30 do frame() end +T.eq(callbacks.boat, 1, "boat completion callback fires once") +T.eq(callbacks.actor, 1, "generic actor completion callback fires once") +T.eq(callbacks.player, 1, "player completion callback fires once") +T.check(Objects._tracks[Objects.foreignKey("TEST_A", 7)].done, "foreign boat track completes") +Ghosts.clear() +Objects.clear() + +local Collision = require("src.core.game3.collision") +local realConnection, realPairs = Collision.scriptConnection, pairs +Runtime.session.version = "emerald" +Collision._grid, Collision._widthCells, Collision._heightCells = { 0 }, 4, 8 +local seamLayout = { width = 4, height = 8, collAt = function() return 0 end } +local function contains(list, actor) + for _, eo in ipairs(list) do if eo == actor then return true end end + return false +end +for _, playerFirst in ipairs({ true, false }) do + Objects.clear() + Ghosts.clear() + local source = { midLayout = seamLayout, objects = { + { localId = 7, x = 3, y = 2 }, { localId = 9, x = -1, y = 4 }, + } } + local destination = { midLayout = seamLayout, objects = { { localId = 9, x = -1, y = 4 } } } + local from, seams = "SEAM_A", 0 + Runtime.session.map = from + Objects.loadMap(game, from, source) + Player.reset(3, 1, "right") + local vessel, outside = Objects.find(7), Objects.find(9) + T.check(not contains(Objects.forDraw(), outside), "ordinary off-map EO remains filtered") + local movement = { { kind = "sleep", frames = 1 } } + for _ = 1, 9 do movement[#movement + 1] = { kind = "step", dir = "right", fast = true } end + local done = { boat = 0, player = 0 } + Objects.startTrack(7, movement, function() done.boat = done.boat + 1 end) + Objects.startTrack(255, movement, function() done.player = done.player + 1 end) + local boatTrack, playerTrack = Objects._tracks[7], Objects._tracks[255] + pairs = function(t) + if t ~= Objects._tracks then return realPairs(t) end + local order = playerFirst and { 255, vessel.localId } or { vessel.localId, 255 } + local index = 0 + return function() + index = index + 1 + local id = order[index] + if id then return id, t[id] end + end + end + Collision.scriptConnection = function(_, fx, fy, dir) + T.eq(dir, "right", "script seam uses current movement direction") + seams = seams + 1 + local to = "SEAM_" .. seams + local carry = Objects.carryOut(-4, 0) + Ghosts.capture(from) + Objects.loadMap(game, to, destination) + Objects.carryIn(carry) + Runtime.session.map, from = to, to + Player.cellX, Player.cellY = fx - 4, fy + Player.px, Player.py = Player.cellX * 16, Player.cellY * 16 + Player.targetX, Player.targetY = Player.cellX, Player.cellY + return 0, fy + end + for frame = 1, 80 do + Objects.update(game) + Player.tick(game) + T.eq(vessel.px - Player.px, 0, "mid-track seam keeps horizontal boat/player pixels") + T.eq(vessel.py - Player.py, 16, "mid-track seam keeps vertical boat/player pixels") + if frame == 1 then + T.check(vessel.cellX == -1 and contains(Objects.forDraw(), vessel), + "visible foreign EO draws outside host map bounds") + T.check(not contains(Objects.forDraw(), Objects.find(9)), "ordinary destination off-map EO remains filtered") + vessel.hidden = true + T.check(not contains(Objects.forDraw(), vessel), "hidden foreign EO remains filtered") + vessel.hidden, vessel.invisible = false, true + T.check(not contains(Objects.forDraw(), vessel), "invisible foreign EO remains filtered") + vessel.invisible = false + T.eq(boatTrack.i, playerTrack.i, "seam advances each original track once in either order") + end + end + T.eq(seams, 3, "script track crosses three repeated seams") + T.eq(done.boat, 1, "seam boat callback fires once") + T.eq(done.player, 1, "seam player callback fires once") + T.check(boatTrack.done and playerTrack.done, "seam keeps both held tracks to completion") + pairs = realPairs +end +for _, mode in ipairs({ "removed", "track_replaced", "actor_replaced" }) do + Objects.clear() + Ghosts.clear() + Objects.loadMap(game, "GUARD_A", { midLayout = seamLayout, objects = { + { localId = 7, x = 3, y = 2 }, { localId = 8, x = 3, y = 4 }, + } }) + Runtime.session.map = "GUARD_A" + Player.reset(3, 1, "right") + local vessel, other = Objects.find(7), Objects.find(8) + local movement = { { kind = "sleep", frames = 1 }, { kind = "step", dir = "right", fast = true } } + local called = 0 + Objects.startTrack(7, movement) + Objects.startTrack(8, movement, function() called = called + 1 end) + Objects.startTrack(255, movement) + local original = Objects._tracks[8] + local replacement + pairs = function(t) + if t ~= Objects._tracks then return realPairs(t) end + local order, index = { 255, vessel.localId, other.localId }, 0 + return function() + index = index + 1 + local id = order[index] + if id then return id, t[id] end + end + end + Collision.scriptConnection = function(_, fx, fy) + local carry = Objects.carryOut(-4, 0) + Ghosts.capture("GUARD_A") + Objects.loadMap(game, "GUARD_B", { midLayout = seamLayout, objects = {} }) + Objects.carryIn(carry) + Runtime.session.map = "GUARD_B" + Player.cellX, Player.cellY = fx - 4, fy + Player.px, Player.py = Player.cellX * 16, Player.cellY * 16 + Player.targetX, Player.targetY = Player.cellX, Player.cellY + if mode == "removed" then + Objects.removeObject(other.localId) + elseif mode == "track_replaced" then + Objects.startTrack(other.localId, { { kind = "sleep", frames = 3 } }) + replacement = Objects._tracks[other.localId] + else + local copy = {} + for key, value in realPairs(other) do copy[key] = value end + Objects._byId[other.localId] = copy + end + return 0, fy + end + Objects.update(game) + Player.tick(game) + T.eq(original.i, 2, mode .. " snapshot never advances obsolete actor track") + T.eq(original.sleep, 1, mode .. " snapshot never consumes obsolete actor sleep") + T.eq(called, 0, mode .. " snapshot never fires obsolete actor callback") + if replacement then T.eq(replacement.sleep, 3, "replacement track waits until next frame") end + pairs = realPairs +end +Objects.clear() +Objects.startTrack(31, { { kind = "sleep", frames = 1 }, { kind = "sleep", frames = 1 } }, + function() callbacks.actor = callbacks.actor + 1 end) +Objects.update(game) +Objects.update(game) +T.eq(callbacks.actor, 2, "initially missing optional EO track still completes") +pairs, Collision.scriptConnection = realPairs, realConnection +Ghosts.clear() +Objects.clear() +T.finish("game3_carried_actor_ghost") diff --git a/tests/game3_cycling_road_bike_test.lua b/tests/game3_cycling_road_bike_test.lua index 0d5838ed..edc0b4f2 100644 --- a/tests/game3_cycling_road_bike_test.lua +++ b/tests/game3_cycling_road_bike_test.lua @@ -221,7 +221,28 @@ local dismountAction = Gen2Bike.tryBike({ }) check(dismountAction == "dismount", "Gen 2 Bike.tryBike allows dismount in INDOOR environment") +local Field = require("src.core.game3.field") +session = Runtime.getSession() or session +session.version = "firered" +session.flags = session.flags or {} +session.vars = session.vars or {} +session.store = session.store or { flags = {}, vars = {} } +Space.store = session.store +Field._game, Field._session = game, session +local defs = Flags.forVersion("firered") +local road, scene = defs.IDS.FLAG_SYS_ON_CYCLING_ROAD, defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16 +Flags.setFlag(session.store, nil, road, true) +Flags.setFlag(session, nil, road, true) +Flags.setVar(session.store, nil, scene, 1) +Player.biking, session.biking, game.save.biking = true, true, true +Field.flyTo(nil, nil, { dest = { map = "FR_SAFFRON_CITY", x = 18, y = 22 } }) +check(not Flags.getFlag(session.store, nil, road) and not Flags.getFlag(session, nil, road), + "Cycling Road Fly clears every road flag store") +check(Flags.getVar(session.store, nil, scene) == 0, "Cycling Road Fly resets Route 16 scene") +Map.load(nil, game, "FR_SILPH_CO_1F", { x = 18, y = 21, facing = "up" }) +check(not Player.biking and not session.biking and not game.save.biking, + "indoor entry after Cycling Road Fly keeps avatar session and save on foot") + print(string.format("=== RESULTS: %d passed, %d failed ===", passed, failed)) if failed > 0 then os.exit(1) end - diff --git a/tests/game3_fly_cycling_state_test.lua b/tests/game3_fly_cycling_state_test.lua new file mode 100644 index 00000000..a9ebf646 --- /dev/null +++ b/tests/game3_fly_cycling_state_test.lua @@ -0,0 +1,72 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local GameVersion = require("src.core.GameVersion") +local Field = require("src.core.game3.field") +local Player = require("src.core.game3.player") +local Flags = require("src.core.game3.scripting.flags") +local Runtime = require("src.core.game3.runtime") +local Space = require("src.core.game3.scripting.space") +local Map = require("src.core.game3.map") +local realLoad = Map.load +local realHeal = package.loaded["src.core.game3.heal_locations"] +package.loaded["src.core.game3.heal_locations"] = { + normalizeSession = function() end, + model = function() return "heal_row" end, +} +Space.runImmediately = function() end + +for _, version in ipairs({ "firered", "leafgreen", "emerald" }) do + GameVersion.set(version) + local defs = Flags.forVersion(version) + local roadName = version == "emerald" and "FLAG_SYS_CYCLING_ROAD" or "FLAG_SYS_ON_CYCLING_ROAD" + local road, scene = assert(defs.IDS[roadName]), defs.VAR_IDS.VAR_MAP_SCENE_ROUTE16 + for _, mode in ipairs({ "fly", "field_move", "whiteout" }) do + local s = { version = version, flags = {}, vars = {}, party = {}, + store = { flags = {}, vars = {} }, biking = true, bikeType = "mach" } + local live = { version = version, flags = {}, vars = {}, + store = { flags = {}, vars = {} }, biking = true, bikeType = "acro" } + local script = { flags = {}, vars = {} } + local g = { session = s, save = { biking = true, bikeType = "mach", position = { biking = true } } } + Field._session, Field._game, Runtime.session, Runtime._game, Space.store = s, g, live, g, script + for _, store in ipairs({ s, s.store, script, live, live.store }) do + Flags.setFlag(store, nil, road, true) + store.flags[roadName] = true + if scene then + Flags.setVar(store, nil, scene, 1) + store.vars[tostring(scene)], store.vars.VAR_MAP_SCENE_ROUTE16 = 1, 1 + end + end + Player.biking, Player.bikeType, Player.surfing = true, "mach", true + local loads = 0 + Map.load = function(_, _, mapId) + loads = loads + 1 + for _, store in ipairs({ s, s.store, script, live, live.store }) do + T.check(not Flags.getFlag(store, nil, road) and store.flags[roadName] == nil, + version .. " " .. mode .. " clears road flag before map load") + if scene then + T.eq(Flags.getVar(store, nil, scene), 0, version .. " " .. mode .. " resets scene before map load") + T.eq(store.vars[tostring(scene)], nil, "scene decimal alias cleared") + T.eq(store.vars.VAR_MAP_SCENE_ROUTE16, nil, "scene symbolic alias cleared") + end + end + T.check(not Player.biking and not s.biking and not live.biking and not g.save.biking + and not g.save.position.biking, version .. " " .. mode .. " on foot at map load") + T.check(Player.bikeType == nil and s.bikeType == nil and live.bikeType == nil + and g.save.bikeType == nil, version .. " " .. mode .. " clears bike type") + s.map, live.map = mapId, mapId + end + local dest = { map = version == "emerald" and "EM_MAUVILLE_CITY" or "FR_CELADON_CITY", x = 1, y = 1 } + if mode == "fly" then + Field.flyTo(nil, nil, { dest = dest }) + else + Field.respawnAtHeal({ fieldMove = mode == "field_move", warp = dest }) + end + T.eq(loads, 1, version .. " " .. mode .. " loads one destination") + T.check(not Player.biking and not live.biking and not g.save.biking, + version .. " " .. mode .. " remains on foot after landing") + end +end +Map.load = realLoad +package.loaded["src.core.game3.heal_locations"] = realHeal +T.finish("game3_fly_cycling_state") diff --git a/tests/game3_message_speed_test.lua b/tests/game3_message_speed_test.lua new file mode 100644 index 00000000..ec32f36e --- /dev/null +++ b/tests/game3_message_speed_test.lua @@ -0,0 +1,75 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local GameVersion = require("src.core.GameVersion") +local Runtime = require("src.core.game3.runtime") +local Options = require("src.core.game3.options") +local Message = require("src.ui.game3.message") +local Hud = require("src.ui.game3.hud") +local TextIR = require("src.core.game3.scripting.text_ir") +local input = { + wasPressed = function(_, key) return key == "a" end, + isDown = function() return false end, +} +local game = { input = input } +local ir = TextIR.fromAscii("First paragraph.\\pSecond paragraph.") + +local function exercise(version, speed, text) + GameVersion.set(version) + local session = { version = version } + Runtime.session = session + Options.set(session, "textSpeed", speed) + Message.showStay(text) + T.check(Message.isTyping() and Message._page == 1 and Message._revealed == 0, + version .. " implicit option " .. speed .. " starts typing first paragraph") + local first = Message.currentPage() + Message.tick() + Hud.update(game, 1 / 60) + T.eq(Message._page, 1, version .. " opening A preserves first paragraph at option " .. speed) + T.eq(Message.currentPage(), first, "opening A keeps first paragraph text") + T.check(Message.isWaiting(), "opening A finishes first paragraph reveal") + Message.advance() + T.eq(Message._page, 2, "fresh A advances exactly once") + T.check(Message.isTyping(), "second paragraph starts typing") + Message.close() +end + +for _, version in ipairs({ "firered", "leafgreen", "emerald" }) do + for speed = 0, 2 do + exercise(version, speed, ir) + Message.showStay("ABC") + Message.tick() + T.eq(Message._revealed, 1, "first glyph prints on first tick") + local delay = ({ 8, 4, 1 })[speed + 1] + for _ = 1, delay - 1 do Message.tick() end + T.eq(Message._revealed, 1, version .. " no-input glyph interval " .. delay) + Message.tick() + T.eq(Message._revealed, 2, version .. " next glyph after " .. delay .. " ticks") + Message.showStay(ir, { speed = 0 }) + T.check(Message.isWaiting() and Message._revealed == Message._total, + version .. " explicit speed zero stays instant at option " .. speed) + Message.close() + end +end + +GameVersion.set("firered") +local bundle = require("tests.game3_cache").bundle("scripts/events.lua") +if bundle then + local event = assert(bundle.events.FR_VIRIDIAN_CITY_POKEMON_CENTER_1F) + for _, npc in ipairs({ { id = 2, name = "Viridian gentleman" }, { id = 3, name = "Viridian boy" } }) do + local scriptKey + for _, object in ipairs(event.objects) do + if object.localId == npc.id then scriptKey = object.scriptKey break end + end + local script = assert(bundle.scripts[assert(scriptKey, npc.name .. " event script")]) + local command = assert(script[1]) + assert(command.op == "loadword" and command.dest == 0, npc.name .. " cached message pointer") + local text = assert(bundle.text[command.value], npc.name .. " cached text IR") + T.eq(#TextIR.splitPages(TextIR.toTextBox(text, { maxWidth = 208 })), 2, + npc.name .. " cached literal text has two paragraphs") + exercise("firered", 0, text) + end +else + print("[skip] cached ROM message acceptance: " .. tostring(require("tests.game3_cache").reason)) +end +T.finish("game3_message_speed") diff --git a/tests/game3_pause_and_main_menu_exit_test.lua b/tests/game3_pause_and_main_menu_exit_test.lua index 68a7c501..eabfbfce 100644 --- a/tests/game3_pause_and_main_menu_exit_test.lua +++ b/tests/game3_pause_and_main_menu_exit_test.lua @@ -24,9 +24,11 @@ local Game3 = require("src.core.Game3") -- Test 1: StartMenu EXIT flow and confirmation do local returnToTitleCalled = false + local returnToTitleOptions local dummyGame = { - returnToTitle = function() + returnToTitle = function(_, opts) returnToTitleCalled = true + returnToTitleOptions = opts end, } local session = { name = "RED", game = dummyGame } @@ -85,6 +87,7 @@ do check(not StartMenu._confirmExit, "Exited confirm mode on YES") check(not StartMenu.isOpen(), "StartMenu closed on YES") check(returnToTitleCalled, "returnToTitle was called on YES") + check(returnToTitleOptions and returnToTitleOptions.skipIntro, "EXIT YES requests direct title") end -- Test 2: Game3:returnToTitle cleans up and sets boot to title phase @@ -271,4 +274,61 @@ do Runtime.stop(nil, game) end +do + local Version = require("src.core.GameVersion") + local Runtime = require("src.core.game3.runtime") + local oldVersion = Version.get() + local titlePath = "src.ui.game3.rse.title_rse" + local introPath = "src.ui.game3.rse.intro_emerald" + local oldTitle, oldIntro = package.loaded[titlePath], package.loaded[introPath] + local titleCalls, introCalls, introColdBoot = 0, 0, nil + package.loaded[titlePath] = { new = function() + titleCalls = titleCalls + 1 + return { update = function(self) return self.result end } + end } + package.loaded[introPath] = { new = function(_, opts) + introCalls, introColdBoot = introCalls + 1, opts.coldBoot + return { update = function() end } + end } + Version.set("emerald") + local game = Game3.new() + game.phase = "field" + game.session = { version = "emerald", map = "EM_LITTLEROOT_TOWN", x = 5, y = 5, game = game } + Runtime.start(nil, game, game.session, { alreadyOnMap = true }) + StartMenu.show({ session = game.session, game = game }) + for i, entry in ipairs(StartMenu.ENTRIES) do + if entry.id == "exit" then StartMenu.cursor = i break end + end + StartMenu.confirm() + eq(StartMenu._confirmCursor, 2, "Emerald EXIT defaults to NO") + StartMenu.cancel() + eq(game.phase, "field", "Emerald EXIT B preserves field") + StartMenu.confirm() + StartMenu.confirm() + eq(game.phase, "field", "Emerald EXIT NO preserves field") + StartMenu.confirm() + StartMenu.move(1) + StartMenu.confirm() + eq(game.phase, "boot", "Emerald EXIT tears down field") + eq(game.session, nil, "Emerald EXIT clears session") + check(not Runtime.isActive(), "Emerald EXIT stops runtime") + eq(Stack.depth(), 0, "Emerald EXIT clears screen stack") + eq(game.boot.phase, Boot.PHASE.TITLE, "Emerald EXIT enters TITLE directly") + eq(titleCalls, 1, "Emerald EXIT constructs title") + eq(introCalls, 0, "Emerald EXIT bypasses intro constructor") + eq(game.boot.custom.coldBoot, false, "Emerald EXIT consumes cold boot") + if game.boot.custom.title then + game.boot.custom.title.result = "copyright" + Boot.update(game.boot, {}, 1 / 60) + eq(introColdBoot, false, "Title idle copyright uses noncold intro") + end + game:returnToTitle() + eq(game.boot.phase, Boot.PHASE.INTRO, "Actual soft reset retains Emerald intro") + eq(game.boot.custom.coldBoot, true, "Actual soft reset retains cold boot") + Boot.update(game.boot, {}, 1 / 60) + eq(introColdBoot, true, "Actual soft reset constructs cold intro") + package.loaded[titlePath], package.loaded[introPath] = oldTitle, oldIntro + Version.set(oldVersion) +end + T.finish("game3_pause_and_main_menu_exit_test")