mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-01 15:41:29 -04:00
@@ -36,6 +36,7 @@ body:
|
||||
- Silver
|
||||
- Crystal
|
||||
- FireRed
|
||||
- Emerald
|
||||
- N/A
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -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),
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+14
-10
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user