-- Parity test for Yellow's Pallet Town intro (Oak stops the player, -- catches a wild Pikachu, walks them to the lab). Drives the real -- onStep closure in data/scripts/story2.lua through the real -- StateStack/OverworldState, with no mocked battle or overworld, up -- through the point Oak's Pikachu catch is armed: covers two of the -- four fixes on this branch -- Music_MuseumGuy staying off in -- Red/Blue, and the 2-frame hold before the Pikachu battle handing -- off to BattleTransition rather than a bare push. -- -- Deliberately stops there rather than also driving the demo battle -- to completion to assert Music_MuseumGuy fires in Yellow (the third -- fix): that would mean re-deriving frame budgets for the battle -- menu/bag/throw sequence tests/parity_J.lua already exercises, on -- top of everything already driven here, for one more assertion -- -- more coupling to unrelated timing than the fix is worth. That side -- is manually verified instead (see the PR description). -- -- Sources: scripts/PalletTown.asm, engine/overworld/auto_movement.asm, -- home/overworld.asm, engine/battle/core.asm (see the commits on this -- branch for the exact citations). -- luajit tests/parity_yellow_pallet_pikachu.lua package.path = "./?.lua;./?/init.lua;" .. package.path if not _G.love then _G.love = require("tests.love_stub") end local Data = require("src.core.Data") if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end local S = require("tests.harness").suite("parity Yellow Pallet Town Pikachu") local check, eq = S.check, S.eq local GameVersion = require("src.core.GameVersion") local SaveData = require("src.core.SaveData") local Game = require("src.core.Game") local StateStack = require("src.core.StateStack") local OverworldState = require("src.world.OverworldController") local BattleTransition = require("src.render.BattleTransition") local TextBox = require("src.render.TextBox") local Music = require("src.core.Music") local mapScripts = require("data.scripts.init") local pallet = mapScripts.get("PALLET_TOWN") check(pallet and pallet.onStep, "PALLET_TOWN exposes onStep") local oldVersion = GameVersion.get() local prevGame = { data = Game.data, save = Game.save, stack = Game.stack, input = Game.input, renderer = Game.renderer, overworld = Game.overworld } local function freshGame(mapX, mapY) Game.data = Data Game.save = SaveData.newGame(Data) Game.save.player.name = "RED" StateStack:init() Game.stack = StateStack local pressed = {} Game.input = { isDown = function() return false end, wasPressed = function(_, b) return pressed[b] or false end, step = function() end, state = {}, pressQueue = {}, } Game.renderer = { beginWorldPass = function() end, endWorldPass = function() end, beginUIPass = function() end, endUIPass = function() end, worldViewSize = function() return 160, 144 end, setSGBZones = function() end, } -- OverworldState is a singleton and :enter does not clear the movement -- latches, so a scenario that runs after a few thousand other checks can -- inherit a half-finished script and never walk. Reset them here: this -- suite asserts a frame budget, so it has to start from a known state. OverworldState.scriptMoves = {} OverworldState.pendingScripts = {} OverworldState.emote = nil OverworldState.engaging = false OverworldState.teleportOut = nil OverworldState.transitioning = nil OverworldState.wildEncounterGraceSteps = 0 StateStack:push(OverworldState, "PALLET_TOWN", mapX, mapY, "up") Game.overworld = OverworldState return pressed end -- mash "a" whenever anything but the overworld is on top (dismisses -- text boxes; scriptMove/hold/BattleTransition/BattleState's own -- scripted-menu phases all ignore it) local function pump(pressed, n) for _ = 1, n do pressed.a = Game.stack:top() ~= OverworldState Game.stack:update(1 / 60) end end local function mashUntil(pressed, cond, cap) for _ = 1, cap do if cond() then return true end pressed.a = Game.stack:top() ~= OverworldState Game.stack:update(1 / 60) end return false end -- Every scenario mutates the Game/GameVersion/StateStack singletons the -- rest of the aggregate tests/run_tests.lua run depends on; a genuine -- error partway through (plausible -- this drives real scriptMove, -- pathfinding, TextBox paging and BattleState code, not test doubles) -- must not skip the restore, or it cascades into every later suite in -- the same process. StateStack:init() leaves it empty rather than -- pointed at this scenario's pushed OverworldState/TextBox/ -- BattleTransition instances. local function scenario(fn) local ok, err = pcall(fn) GameVersion.set(oldVersion) for k, v in pairs(prevGame) do Game[k] = v end StateStack:init() if not ok then error(err, 0) end end -- ===================================================================== -- (A) Red/Blue: MUSIC_MUSEUM_GUY must never play. Only "red" is driven -- here -- story2.lua's onStep branches on GameVersion.isYellow() alone -- and never calls isBlue(), so Red and Blue share this exact code path -- and a separate Blue run would exercise nothing new. pokered's copy of -- PalletMovementScript_OakMoveLeft only sets BIT_NO_MAP_MUSIC and never -- calls PlayMusic -- MUSIC_MEET_PROF_OAK (started when Oak appears) -- keeps running straight into the lab. -- ===================================================================== scenario(function() -- The trigger tile is in Pallet's north grass, so the escort walk rolls for -- wild encounters as it goes. Pin the stream: run standalone this suite got -- one draw sequence and run inside tests/run_tests.lua another, and one of -- them dropped a battle on top of the escort and ate the frame budget. math.randomseed(require("tests.harness").SEED) GameVersion.set("red") local pressed = freshGame(8, 2) local played = {} local origPlay = Music.play Music.play = function(data, song, ...) table.insert(played, song) return origPlay(data, song, ...) end local ok, err = pcall(function() local ow = OverworldState check(pallet.onStep(Game, ow, 8, 1) == true, "Red: onStep claims the trigger tile") -- HeyWaitDontGoOutText (auto) -> "!" bubble hold(50) -> Oak -- approaches -> "It's unsafe!" text -> escortToLab -> the walk to -- the lab door. 3000 frames (50s of game time) covers it; the -- negative Music_MuseumGuy checks below only mean something once -- the escort has actually finished, not merely started. pump(pressed, 3000) check(Game.save.flags.EVENT_FOLLOWED_OAK_INTO_LAB == true, "Red: the escort actually reaches the lab within the frame budget") end) Music.play = origPlay if not ok then error(err, 0) end local sawMuseumGuy = false for _, s in ipairs(played) do if s == "Music_MuseumGuy" then sawMuseumGuy = true end end check(not sawMuseumGuy, "Red/Blue: Music_MuseumGuy never plays for this escort") check(played[1] == "Music_MeetProfOak", "Red/Blue: Music_MeetProfOak is still the only cutscene cue played") end) -- ===================================================================== -- (B) Yellow: the turn-then-battle timing and the transition wipe. -- ===================================================================== scenario(function() GameVersion.set("yellow") local pressed = freshGame(10, 1) local ok, err = pcall(function() local ow = OverworldState check(pallet.onStep(Game, ow, 10, 0) == true, "Yellow: onStep claims the north-exit tile") local heyWaitBox = Game.stack:top() check(getmetatable(heyWaitBox) == TextBox, "Yellow: onStep opens with a text box (HeyWaitDontGoOutText)") -- HeyWaitDontGoOutText (auto, ~20 frames) -> "!" bubble hold(50) -> -- Oak approaches (findPath(10,4,10,1): 3 steps) -> hold(6) -> the -- next real text box is ThatWasClose (button-dismissed, unlike the -- first). local sawThatWasClose = mashUntil(pressed, function() local top = Game.stack:top() return top ~= heyWaitBox and getmetatable(top) == TextBox end, 3000) check(sawThatWasClose, "Yellow: reaches the ThatWasClose text") local oak for _, n in ipairs(ow.npcs) do if n.def and n.def.name == "PALLETTOWN_OAK" then oak = n end end check(oak ~= nil, "Yellow: PALLETTOWN_OAK is spawned") -- dismiss ThatWasClose (a multi-page text: mash through the -- typewriter cadence and the page turn); oak.facing flips -- synchronously in the close callback (x == 10 -> "right"), then -- hold(2) starts local sawHold = mashUntil(pressed, function() return ow.emote ~= nil end, 2000) check(sawHold, "Yellow: ThatWasClose closes and the hold before battle arms") eq(oak and oak.facing, "right", "Oak turns to face the grass (x==10 -> right)") check(ow.emote ~= nil and ow.emote.frames == 2, "the hold before the battle is armed for exactly 2 frames") -- tick 1: 2 -> 1, battle not pushed yet Game.stack:update(1 / 60) check(getmetatable(Game.stack:top()) ~= BattleTransition, "1 frame in: the battle has not started yet") -- tick 2: 1 -> 0, the demo battle is pushed through the transition Game.stack:update(1 / 60) check(getmetatable(Game.stack:top()) == BattleTransition, "2 frames in: Oak's catch enters through BattleTransition, not a bare push") end) if not ok then error(err, 0) end end) S.finish()