diff --git a/data/scripts/oaks_lab_yellow.lua b/data/scripts/oaks_lab_yellow.lua index 8f4ac856..c56f6091 100644 --- a/data/scripts/oaks_lab_yellow.lua +++ b/data/scripts/oaks_lab_yellow.lua @@ -45,6 +45,8 @@ return { { "stop_music" }, { "play_music", "Music_MeetRival" }, { "show_text", "_OaksLabRivalGrampsText" }, + -- callfar OaksLabPikachuMovementScript, before ShowObject (#1021) + { "pikachu_make_way" }, { "show_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, { "place_npc", RIVAL, 4, 7, "up" }, { "move_npc_to", RIVAL, 4, 3 }, @@ -192,12 +194,13 @@ return { end rows[#rows + 1] = { "face_player_dir", "up" } rows[#rows + 1] = { "face_object", OAK1, "down" } - -- OaksLabPlayerReceivedMonText: no nickname prompt -- the starter - -- Pikachu keeps its species name + -- OaksLabPlayerReceivedMonText clears wMonDataLocation, so AskName runs (#1013) rows[#rows + 1] = { "show_text", "_OaksLabOakGivesText" } rows[#rows + 1] = { "play_sound", "Get_Key_Item" } rows[#rows + 1] = { "show_text", "_OaksLabReceivedText", { RAM = "PIKACHU" } } - rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5, true } + rows[#rows + 1] = { "give_pokemon", "PIKACHU", 5 } + -- DisablePikachuOverworldSpriteDrawing keeps it in the ball (#1009) + rows[#rows + 1] = { "set_field", "pikachuInBall", true } rows[#rows + 1] = { "set_flag", "EVENT_GOT_STARTER" } rows[#rows + 1] = { "set_flag", "EVENT_CHOSE_PIKACHU" } ow.runner:run(rows, { npc = npc, onDone = done }) @@ -296,9 +299,9 @@ return { table.insert(rows, { "move_npc_to", RIVAL, 4, 11 }) table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }) table.insert(rows, { "play_music", "Music_OaksLab" }) - -- OaksLabPikachuEscapesPokeballScript: Pikachu hates its ball. - -- The overworld follower itself is still an open port - -- (docs/yellow-version.md runtime backlog); the story beat plays. + -- OaksLabPikachuEscapesPokeballScript: the follower reaches the map (#1009) + table.insert(rows, { "face_player_dir", "up" }) + table.insert(rows, { "set_field", "pikachuInBall", false }) table.insert(rows, { "play_cry", "PIKACHU" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText1" }) table.insert(rows, { "show_text", "_OaksLabPikachuDislikesPokeballsText2" }) diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index f38b0af6..b7e40f3d 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -400,15 +400,24 @@ M.POKEMON_FAN_CLUB = { TEXT_POKEMONFANCLUB_CHAIRMAN = { { "face_player" }, -- 1 { "check_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 2 - { "jump_if_true", 9 }, -- 3 - { "show_text", "_PokemonFanClubChairmanIntroText" }, -- 4 - { "show_text", "_PokemonFanClubChairmanStoryText" }, -- 5 + { "jump_if_true", "nothing_left" }, -- 3 + -- YesNoChoice (scripts/PokemonFanClub.asm): NO forfeits the voucher (#1050) + { "ask", "_PokemonFanClubChairmanIntroText" }, -- 4 + { "jump_if_false", "no_story" }, -- 5 + { "show_text", "_PokemonFanClubChairmanStoryText" }, -- 6 -- give-then-print like scripts/PokemonFanClub.asm (GiveItem -- fills wStringBuffer; the received text reads it) - { "give_item", "BIKE_VOUCHER", 1, false }, -- 6 - { "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 7 - { "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 8 - { "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 9 + { "give_item", "BIKE_VOUCHER", 1, false }, -- 7 + { "show_text", "_PokemonFanClubReceivedBikeVoucherText" }, -- 8 + { "set_flag", "EVENT_RECEIVED_BIKE_VOUCHER" }, -- 9 + { "show_text", "_PokemonFanClubExplainBikeVoucherText" }, -- 10 + { "jump", "end" }, -- 11 + { "label", "no_story" }, -- 12 + { "show_text", "_PokemonFanClubNoStoryText" }, -- 13 + { "jump", "end" }, -- 14 + -- .nothingleft: the gift is done, he only reminisces now + { "label", "nothing_left" }, -- 15 + { "show_text", "_PokemonFanClubChairFinalText" }, -- 16 }, }, } diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 41b1281e..b4766406 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -27,11 +27,16 @@ local function ask(game, s, cb) game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end --- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER}) +-- fill text placeholders; key on the hram/wram symbol first, since one +-- string can carry two different NUM slots (#1006) local function fill(s, subs) s = s:gsub("{PLAYER}", subs.player or "") - s = s:gsub("{NUM:[^}]*}", function() return tostring(subs.num or "") end) - s = s:gsub("{RAM:[^}]*}", function() return subs.ram or "" end) + s = s:gsub("{NUM:([%w_]*)[^}]*}", function(name) + return tostring(subs[name] or subs.num or "") + end) + s = s:gsub("{RAM:([%w_]*)[^}]*}", function(name) + return subs[name] or subs.ram or "" + end) return s end @@ -82,9 +87,12 @@ local function oaksAide(threshold, itemId, repeatText) { ram = itemName, player = game.save.player.name }), done) end) else + -- .notEnoughOwnedMons prints owned then requirement, two counts push(game, fill(t._OaksAideUhOhText or "You have only\ncaught {NUM:}!", - { num = owned, ram = itemName }), done) + { num = owned, ram = itemName, + hOaksAideNumMonsOwned = owned, + hOaksAideRequirement = threshold }), done) end end) end @@ -245,27 +253,32 @@ M.FIGHTING_DOJO = { M.SILPH_CO_7F = { talk = { - TEXT_SILPHCO7F_SILPH_WORKER_M1 = function(game, ow, npc, done) - local t = text(game) - if game.save.flags.EVENT_GOT_LAPRAS then - push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText - or "How is LAPRAS\ndoing?", done) - return - end - push(game, t._SilphCo7FSilphWorkerM1ThankYouText - or "Thank you for\nsaving us!\fI want you to\nhave this LAPRAS!", - function() - game.save.flags.EVENT_GOT_LAPRAS = true - local Commands = require("src.script.Commands") - Commands.give_pokemon({ save = game.save, game = game, overworld = ow }, - "LAPRAS", 15) - push(game, ("%s got\nLAPRAS!"):format(game.save.player.name), - function() - push(game, t._SilphCo7FSilphWorkerM1LaprasDescriptionText - or "It's a good\nswimmer!", done) - end) - end) - end, + -- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1049) + TEXT_SILPHCO7F_SILPH_WORKER_M1 = { + { "face_player" }, + { "check_flag", "EVENT_GOT_LAPRAS" }, + { "jump_if_true", "has_lapras" }, + { "show_text", "_SilphCo7FSilphWorkerM1HaveThisPokemonText" }, + { "give_pokemon", "LAPRAS", 15 }, + { "jump_if_false", "box_full" }, + -- flag ahead of the jingle, like the Celadon EEVEE (#426) + { "set_flag", "EVENT_GOT_LAPRAS" }, + { "play_sound", "Get_Item1" }, + { "show_text", "_GotMonText", { RAM = "LAPRAS" } }, + { "show_text", "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, + { "jump", "end" }, + { "label", "box_full" }, + { "show_text", "_BoxIsFullText" }, + { "jump", "end" }, + -- SilphCo7F.asm .saved_silph gates the thanks on Giovanni + { "label", "has_lapras" }, + { "check_flag", "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + { "jump_if_true", "saved" }, + { "show_text", "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" }, + { "jump", "end" }, + { "label", "saved" }, + { "show_text", "_SilphCo7FSilphWorkerM1SavedText" }, + }, }, } diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9148583e..dba4844b 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -661,9 +661,8 @@ end -- AskName runs for party (AddPartyMon) and box (SendNewMonToBox) when a -- script runner is present; mods that pre-set gift.nickname skip it. -- Box deposits also print SentToBoxText (give_pokemon.asm:36-37). --- skipNickname suppresses the AskName prompt: Yellow's lab Pikachu is --- added straight through AddPartyMon (pokeyellow scripts/OaksLab.asm --- OaksLabPlayerReceivedMonText) -- the starter Pikachu keeps its name. +-- skipNickname suppresses AskName for callers that name the gift themselves; +-- no vanilla script uses it (pokeyellow scripts/OaksLab.asm, #1013) function Commands.give_pokemon(ctx, species, level, skipNickname) -- Native mods can transform a gift before the Pokémon object is created. -- This is intentionally an event rather than a special-case starter hook: @@ -1097,6 +1096,17 @@ function Commands.march_in_place(ctx, objIndex, on) ow.marchers[npc] = on and true or nil end +-- pikachu_make_way: callfar OaksLabPikachuMovementScript (pokeyellow +-- scripts/OaksLab_2.asm); a no-op without a Yellow follower (#1021) +function Commands.pikachu_make_way(ctx) + local ow = ctx.overworld + if not ow then return end + local runner = ctx.runner + local started = require("src.world.PikachuFollower") + .oaksLabMakeWay(ctx.game, ow, function() runner:resume() end) + if started then runner:yield() end +end + -- play_music [opts]: switch map music now; opts.keep marks it -- to survive the next warp (the story files' keepMusic idiom). -- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm @@ -1359,7 +1369,7 @@ for _, verb in ipairs({ "show_text", "ask", "choice", "start_battle", "warp", "old_man_demo", "static_battle", "rival_battle", "give_item", "give_pokemon", "wait", "wait_flag", "move_player", "move_npc", "move_npc_to", "walk_npc", - "emote", "fade", "pan_camera", "play_once" }) do + "emote", "fade", "pan_camera", "play_once", "pikachu_make_way" }) do local meta = Commands.meta[verb] or {} Commands.meta[verb] = meta meta.blocking = true diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 7fed06b7..81a6357d 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -207,6 +207,8 @@ local function changeBox(game) if not yes then return end game.save.currentBox = item.value if game.writeSave then game:writeSave() end + -- ChangeBox (engine/menus/save.asm) rings SFX_SAVE after SaveGameData (#1044) + require("src.core.Sound").play(game.data, "Save") list:close() end, })) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index c6aebd0c..d819b03e 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -2,8 +2,8 @@ -- flashes back and forth with the evolved form, speeding up, then the -- new form appears with its cry and the congratulations text. -- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the --- joypad during the flash: holding B aborts the evolution -- the mon keeps --- its species and _StoppedEvolvingText ("Huh? MON stopped evolving!") +-- joypad during the flash: a fresh B press aborts the evolution -- the mon +-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!") -- prints. Two kinds are exempt: trade evolutions, which evos_moves.asm -- routes past the poll entirely (wLinkState == LINK_STATE_TRADING, #213), -- and stone evolutions, where the B press is read but thrown away because @@ -47,6 +47,8 @@ function EvolutionState:sgbPalettes(game) end local FLASH_FRAMES = 220 +-- evolution.asm EvolveMon delays 80 frames before .animLoop, polling nothing (#968, #1031) +local CANCEL_GRACE_FRAMES = 80 local function frontSprite(game, species, mon) local path, trueColor = require("src.pokemon.Sprites").path( @@ -83,10 +85,10 @@ function EvolutionState:update(dt) self.t = self.t + 1 if self.done then return end local game = self.game - -- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for - -- a cancelable evolution, aborts when B is held -- the mon keeps its - -- species (Evolution.apply never runs) and _StoppedEvolvingText prints. - if self.cancelable and game.input:isDown("b") then + -- evolution.asm Evolution_CheckForCancel reads hJoy5, a fresh edge rather + -- than a hold, so B held from the level-up box must not cancel (#968, #1031) + if self.cancelable and self.t > CANCEL_GRACE_FRAMES + and game.input:wasPressed("b") then self.done = true self.canceled = true local TextBox = require("src.render.TextBox") diff --git a/src/ui/PlayerPC.lua b/src/ui/PlayerPC.lua index 09b9e60b..bc951f98 100644 --- a/src/ui/PlayerPC.lua +++ b/src/ui/PlayerPC.lua @@ -164,8 +164,14 @@ local function toss(game) })) end -function PlayerPC.new(game) +-- opts.direct marks the bedroom PC (OpenRedsPC), the one entry outside the main menu +function PlayerPC.new(game, opts) game.save.pcItems = game.save.pcItems or {} + -- ExitPlayerPC (players_pc.asm) rings SFX_TURN_OFF_PC only while + -- BIT_USING_GENERIC_PC is clear (#960) + local logOff = function() + if opts and opts.direct then Sound.play(game.data, "Turn_Off_PC") end + end return Menu.new(game, { -- keepOpen so B in the item lists returns here instead of dropping the -- whole PC session (players_pc.asm re-shows the PC menu); same pattern @@ -173,10 +179,10 @@ function PlayerPC.new(game) { label = Strings("WITHDRAW ITEM"), keepOpen = true, onSelect = function() withdraw(game) end }, { label = Strings("DEPOSIT ITEM"), keepOpen = true, onSelect = function() deposit(game) end }, { label = Strings("TOSS ITEM"), keepOpen = true, onSelect = function() toss(game) end }, - { label = Strings("LOG OFF") }, + { label = Strings("LOG OFF"), onSelect = logOff }, -- silent PC session (BIT_NO_MENU_BUTTON_SOUND); players_pc.asm -- PlayersPCMenu TextBoxBorder (0,0) b=8 c=14 → 16x10 - }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true }) + }, { tx = 0, ty = 0, tw = 16, th = 10, noSound = true, onCancel = logOff }) end return PlayerPC diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d0b48898..a345ba6c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1230,7 +1230,7 @@ function OverworldState:handleInput() if self:checkLedgeHop(dir) then return end if self:checkBoulderPush(dir) then return end end - local result, why = self.player:tryMove(dir, self.map, self.entities) + local result = self.player:tryMove(dir, self.map, self.entities) -- a collision while standing on a warp square fires the warp when the -- extra check passes (CheckWarpsCollision: route-gate doorways, dock -- entrances, ...), and only while BIT_STANDING_ON_WARP is set (issue @@ -1243,7 +1243,9 @@ function OverworldState:handleInput() return result end end - if result == "blocked" and why ~= "entity" then + -- CollisionCheckOnLand (home/overworld.asm): a sprite takes the same + -- .collision branch as an impassable tile (#960) + if result == "blocked" then if (self.bumpCooldown or 0) <= 0 then require("src.core.Sound").play(Game.data, "Collision") self.bumpCooldown = 16 @@ -2039,7 +2041,8 @@ function OverworldState:tryHiddenObject(fx, fy) -- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other -- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228) require("src.core.Sound").play(Game.data, "Turn_On_PC") - Screens.push(Game, "PlayerPC") + -- direct access: ExitPlayerPC rings SFX_TURN_OFF_PC (players_pc.asm, #960) + Screens.push(Game, "PlayerPC", { direct = true }) else self:openPC() end @@ -2745,6 +2748,8 @@ function OverworldState:openPC(onDone) label = (Game.save.player.name or "RED") .. "'s PC", keepOpen = true, onSelect = function() + -- pc.asm .playersPC plays SFX_ENTER_PC before the farcall (#960) + require("src.core.Sound").play(Game.data, "Enter_PC") Screens.push(Game, "PlayerPC") done() end, @@ -2756,6 +2761,8 @@ function OverworldState:openPC(onDone) label = Strings("PROF.OAK's PC"), keepOpen = true, onSelect = function() + -- pc.asm OaksPC plays SFX_ENTER_PC before the farcall (#960) + require("src.core.Sound").play(Game.data, "Enter_PC") self:openOaksPC(done) end, }) @@ -2939,17 +2946,29 @@ function OverworldState:nurseHeal(onDone, npc) -- line: it comes back on the counter facing the player Follower.setVisible(self, true) if npc then npc:facePlayer(self.player) end - self:finishNurseHeal(bye, onDone) + self:finishNurseHeal(bye, onDone, npc) end end)) end) end })) end -function OverworldState:finishNurseHeal(bye, onDone) +-- pokecenter.asm bows the nurse between the two PrintText calls (#995) +function OverworldState:finishNurseHeal(bye, onDone, npc) local t = Game.data.text local fit = t._PokemonFightingFitText or Strings("Your POKéMON are\nfighting fit!") - Game.stack:push(TextBox.new(Game, fit .. "\f" .. bye, onDone)) + Game.stack:push(TextBox.new(Game, fit, function() + local function farewell() + Game.stack:push(TextBox.new(Game, bye, function() + if npc then npc:facePlayer(self.player) end + if onDone then onDone() end + end)) + end + if not npc then farewell() return end + npc.facing = "up" + -- bubble = false is the silent world hold, this port's DelayFrames + self.emote = { npc = npc, frames = 20, bubble = false, onDone = farewell } + end)) end -- The Cable Club link receptionist (TX_SCRIPT_CABLE_CLUB_RECEPTIONIST -> @@ -4215,6 +4234,14 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.doorWarp = nil local arriveWarp = self.arriveWarp self.arriveWarp = nil + -- PlayMapChangeSound (home/overworld.asm) plays before the tail-called + -- GBFadeOutToBlack, so the SFX starts with the fade (#961) + if doorWarp then + local dest = Game.data.maps[mapId] + local outdoor = dest and Map.isOutdoor(dest) + require("src.core.Sound").play(Game.data, + outdoor and "Go_Outside" or "Go_Inside") + end Game.stack:push(Transition.new(Game, function() self:setMap(mapId, x, y, facing or "down", opts) -- the departure-side hide from flyAnim/teleportOut ends here, on the new @@ -4251,9 +4278,6 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) self.player.spinDrop = true end if doorWarp then - local outdoor = Map.isOutdoor(self.map.def) - require("src.core.Sound").play(Game.data, - outdoor and "Go_Outside" or "Go_Inside") -- PlayerStepOutFromDoor (engine/overworld/auto_movement.asm): any -- warp that lands on a door tile auto-steps south once, indoor or -- outdoor. Auto-walk leaves the mat, so the arrival disable diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index b7c517f7..9e59d45a 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -122,6 +122,13 @@ local function shouldSpawn(game, ow) if not GameVersion.isYellow() then return false end local save = game.save if not (save.flags and save.flags.EVENT_GOT_STARTER) then return false end + -- save.pikachuInBall mirrors DisablePikachuOverworldSpriteDrawing (pokeyellow + -- scripts/OaksLab.asm); nil falls back to the rival-fight flag (#1009) + if save.pikachuInBall == nil then + if not save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB then return false end + elseif save.pikachuInBall then + return false + end if save.onBike or (ow.player and ow.player.surfing) then return false end if not (game.data.sprites and game.data.sprites.SPRITE_PIKACHU) then return false @@ -885,6 +892,28 @@ function PikachuFollower.onBillExitedMachine(game, ow) billsHouseEmotion(game, ow, npc, "EXCLAMATION_BUBBLE") end +-- OaksLabPikachuMovementScript (pokeyellow scripts/OaksLab_2.asm): the +-- companion clears the cell the rival stops on (#1021) +function PikachuFollower.oaksLabMakeWay(game, ow, done) + if not GameVersion.isYellow() then return false end + local npc = findFollower(ow) + if not npc or not ow.player then return false end + local p = ow.player + local steps, facing + if p.cellY == 3 then -- .movement2, b = SPRITE_FACING_LEFT + if not (npc.cellY == p.cellY and npc.cellX < p.cellX) then return false end + steps, facing = { { "down", 1 }, { "right", 1 } }, "up" + else -- OaksLabPikachuMovementData1, b = SPRITE_FACING_DOWN + if npc.cellY <= p.cellY then return false end + steps, facing = { { "left", 1 }, { "up", 1 } }, "right" + end + movePikachu(ow, npc, steps, function() + npc.facing = facing -- PIKAMOVEMENT_LOOK_UP / _LOOK_RIGHT ends each table + if done then done() end + end) + return true +end + -- --------------------------------------------------------------------- -- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by -- engine/events/pokecenter.asm once the heal is accepted): the companion diff --git a/tests/drivers/evolution_black_bug279_test.lua b/tests/drivers/evolution_black_bug279_test.lua index 0d26a32b..8d9ca4e5 100644 --- a/tests/drivers/evolution_black_bug279_test.lua +++ b/tests/drivers/evolution_black_bug279_test.lua @@ -248,7 +248,7 @@ return function(game) -- ---- case 2: B-cancel settles on the OLD form's colours ----------------- local karp = startEvo("MAGIKARP", "GYARADOS") - U.wait(24) + U.wait(100) -- past the 80-frame pre-animLoop delay before the poll starts U.hold(game, "b", 20) -- evolution.asm Evolution_CheckForCancel if not waitFor(function() return findText("stopped evolving") ~= nil end, 240) then check(false, "holding B prints \"stopped evolving\"") diff --git a/tests/drivers/evolution_cancel_bug213_test.lua b/tests/drivers/evolution_cancel_bug213_test.lua index b80ca940..46dbc006 100644 --- a/tests/drivers/evolution_cancel_bug213_test.lua +++ b/tests/drivers/evolution_cancel_bug213_test.lua @@ -1,13 +1,14 @@ -- Driver: cancel an evolution with the B button (#213). -- --- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic --- flash: holding B aborts the evolution (the mon keeps its species and +-- pokered engine/movie/evolution.asm polls hJoy5 during the pic flash: a +-- fresh B press aborts the evolution (the mon keeps its species and -- _StoppedEvolvingText prints). Trade evolutions (wLinkState == -- LINK_STATE_TRADING) skip that poll and cannot be cancelled. -- --- Case 1 (level path, cancelable): open EvolutionState directly, wait a --- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and --- assert the mon stays CATERPIE with "stopped evolving" text on screen. +-- Case 1 (level path, cancelable): open EvolutionState directly, wait past +-- the 80-frame pre-animLoop delay (still well under FLASH_FRAMES=220), +-- press B, and assert the mon stays CATERPIE with the "stopped evolving" +-- text on screen. -- Case 1b: after cancel, checkParty with no level-ups must not re-offer; -- a subsequent level-up set must offer again (EvolveAfterBattle parity). -- Case 2 (control): let the flash run to completion with no input and @@ -80,11 +81,11 @@ return function(game) Evolution.evolve(game, mon, "METAPOD", function() done1 = true end) if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end - U.wait(20) -- into the flash, well under FLASH_FRAMES=220 + U.wait(100) -- past the 80-frame pre-animLoop delay, still under 220 U.log("case1 flash", "t=", top().t, "species=", mon.species) U.shot(game, DIR .. "/evo213_1_evolving.png") - U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel + U.hold(game, "b", 20) -- Gen1 hJoy5 B-cancel -- the flash aborts: EvolutionState is no longer the top (the stopped -- text overlays it and then pops it) @@ -118,7 +119,7 @@ return function(game) if not waitFor(evoTop, 300) then error("EvolutionState never opened after level-up re-offer") end - U.wait(20) + U.wait(100) -- past the same 80-frame pre-animLoop delay U.hold(game, "b", 20) -- cancel so case 2 stays independent if not waitFor(function() return not evoTop() end, 240) then error("level-up re-offer did not abort on B") diff --git a/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua b/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua new file mode 100644 index 00000000..71e60f48 --- /dev/null +++ b/tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua @@ -0,0 +1,484 @@ +-- Ear check for the PC, bump, door/stairs and battle menu SFX (#960, #961, #1044, #1045). +-- POKEPORT_DRIVER=tests/drivers/menu_sfx_bug960_bug961_bug1044_bug1045_test.lua POKEPORT_IDENTITY=sfx960 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local BattleState = require("src.battle.BattleState") + local Boxes = require("src.pokemon.Boxes") + local Menu = require("src.ui.Menu") + local Pokemon = require("src.pokemon.Pokemon") + local Sound = require("src.core.Sound") + local Strings = require("src.core.Strings") + local Timing = require("src.core.Timing") + + local FADE = Timing.WARP_FADE_OUT + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- every cue this run makes, forwarded so playback is untouched; the frame + local cues = {} + local realPlay = Sound.play + Sound.play = function(data, name) + cues[#cues + 1] = { name = name, frame = U.frame() } + return realPlay(data, name) + end + local function since(mark) + local names = {} + for i = mark + 1, #cues do names[#names + 1] = cues[i].name end + return #names > 0 and table.concat(names, ", ") or "nothing" + end + local function heard(mark, want) + for i = mark + 1, #cues do + if cues[i].name == want then return cues[i] end + end + return nil + end + local function countOf(mark, want) + local n = 0 + for i = mark + 1, #cues do if cues[i].name == want then n = n + 1 end end + return n + end + + -- ---- what the ear cannot check ----------------------------------------- + local opts = game.save.options or {} + local sfxVol, musicVol = opts.sfxVol or 0, opts.musicVol or 0 + if sfxVol == 0 then + U.log("FAIL SFX volume is 0. Every line below is about a sound that is or") + U.log(" is not there, and at 0 none of them are. Set SFX to 7 in OPTION") + U.log(" and start over, or this run proves nothing at all.") + end + check(("sfx volume %d/7, music volume %d/7"):format(sfxVol, musicVol), + sfxVol > 0) + + -- an unresolved key is silent in exactly the way these bugs were + local sfx = (game.data.audio or {}).sfx or {} + for _, key in ipairs({ "Turn_On_PC", "Turn_Off_PC", "Enter_PC", "Collision", + "Save", "Go_Inside", "Go_Outside", "Press_AB" }) do + check("sfx " .. key .. " is in the generated audio", sfx[key] ~= nil) + end + + -- positions come from data/events/hidden_events.asm and data/maps/objects/*.asm + local extras = game.data.field.hiddenExtras or {} + local pcTiles = extras.pcTiles or {} + local bedroomPC = (pcTiles.REDS_HOUSE_2F or {})[1] + local centerPC = (pcTiles.VIRIDIAN_POKECENTER or {})[1] + check("REDS_HOUSE_2F carries the OpenRedsPC tile", bedroomPC ~= nil) + check("VIRIDIAN_POKECENTER carries a PC tile", centerPC ~= nil) + + local function warpTo(mapId, destMap) + for _, w in ipairs((game.data.maps[mapId] or {}).warps or {}) do + if w.destMap == destMap then return w end + end + return nil + end + local houseDoor = warpTo("PALLET_TOWN", "REDS_HOUSE_1F") + local houseExit = warpTo("REDS_HOUSE_1F", "LAST_MAP") + local houseStairs = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F") + local centerDoor = warpTo("VIRIDIAN_CITY", "VIRIDIAN_POKECENTER") + check("PALLET_TOWN has the door into REDS_HOUSE_1F", houseDoor ~= nil) + check("REDS_HOUSE_1F has an exit mat and the stairs up", + houseExit ~= nil and houseStairs ~= nil) + check("VIRIDIAN_CITY has the door into its POKéMON CENTER", centerDoor ~= nil) + + -- ---- helpers ------------------------------------------------------------ + local DIRS = { up = { 0, -1 }, down = { 0, 1 }, + left = { -1, 0 }, right = { 1, 0 } } + local ORDER = { "down", "up", "left", "right" } + + local function pressStep(dir) + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + U.wait(1) + end + + local function stand(mapId, x, y, facing) + U.teleport(game, mapId, x, y, facing) + U.wait(15) + return game.overworld + end + + -- the cell you stand on to face (cx, cy) from `dir`, i.e. one step back + local function cellBehind(cx, cy, dir) + local d = DIRS[dir] + return cx - d[1], cy - d[2] + end + + -- first walkable free neighbour of (cx, cy), plus the facing that looks at + local function approach(ow, cx, cy) + for _, dir in ipairs(ORDER) do + local sx, sy = cellBehind(cx, cy, dir) + if ow.map:isWalkableCell(sx, sy) and not ow:npcAtCell(sx, sy) then + return sx, sy, dir + end + end + end + + -- any free walkable cell on the map with a solid one next to it, so the + local function findWall(ow) + local map = ow.map + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isWalkableCell(cx, cy) and not map:warpAtCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + for _, d in ipairs(ORDER) do + local dd = DIRS[d] + if not map:isWalkableCell(cx + dd[1], cy + dd[2]) then + return cx, cy, d + end + end + end + end + end + end + + local function npcNamed(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + end + + -- PlayMapChangeSound (home/overworld.asm:690) plays before GBFadeOutToBlack, + local function takeDoor(dir, want, label) + local mark = #cues + local from = game.overworld.map.id + local cue, switched + for _ = 1, 300 do + if cue or switched then + game.input.state[dir] = false + U.wait(1) + else + pressStep(dir) + end + cue = cue or heard(mark, want) + local ow = game.overworld + if not switched and ow and ow.map.id ~= from then switched = U.frame() end + if cue and switched then break end + end + game.input.state[dir] = false + U.wait(50) -- PlayerStepOutFromDoor walks off the mat before anything else + if not (cue and switched) then + check(("%s: %s played and the map changed"):format(label, want), false) + U.log(" cues on the way through:", since(mark)) + return + end + check(("%s: %s fired %d frames before the map switched (the fade is %d)") + :format(label, want, switched - cue.frame, FADE), + switched - cue.frame >= FADE - 8) + end + + -- CollisionCheckOnLand (home/overworld.asm): a sprite in the way takes the + local function bumpInto(dir, label) + local mark = #cues + for _ = 1, 10 do pressStep(dir) end + game.input.state[dir] = false + U.wait(24) -- past the 16-frame bumpCooldown, so the next bump is its own + check(label .. " rings Collision", heard(mark, "Collision") ~= nil) + end + + local function topIs(class) + local top = game.stack:top() + return top ~= nil and getmetatable(top) == class + end + local function rowIndex(menu, label) + for i, item in ipairs(menu.items or {}) do + if item.label == label then return i end + end + end + -- rows come and go with save state and the ui.pc.items hook, so pick each + local function choose(menu, label) + local i = rowIndex(menu, label) + if not i then + check("the menu has a " .. label .. " row", false) + return false + end + menu.index = i + menu:clampScroll() + U.wait(2) + U.tap(game, "a") + U.wait(26) + return true + end + local function mash(cond, tries) + for _ = 1, tries or 40 do + if cond() then return true end + U.tap(game, "a") + U.wait(6) + end + return cond() + end + + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 20) } + game.save.party[1].moves = { + { id = "TACKLE", pp = 35, maxPP = 35 }, + { id = "VINE_WHIP", pp = 10, maxPP = 10 }, + } + Boxes.ensure(game.save) + game.save.currentBox = 1 + + -- ---- the door into the house, from outside (#961) ----------------------- + if houseDoor then + local sx, sy = cellBehind(houseDoor.x, houseDoor.y, "up") + stand("PALLET_TOWN", sx, sy, "up") + takeDoor("up", "Go_Inside", "walking into RED's house") + end + + -- walking into MOM, and into a wall (#960); teleport is only a fallback + local ow = game.overworld + if ow.map.id ~= "REDS_HOUSE_1F" and houseExit then + ow = stand("REDS_HOUSE_1F", houseExit.x, houseExit.y, "up") + end + local mom = npcNamed(ow, "REDSHOUSE1F_MOM") + check("MOM is loaded on REDS_HOUSE_1F", mom ~= nil) + if mom then + local sx, sy, dir = approach(ow, mom.cellX, mom.cellY) + if sx then + ow = stand("REDS_HOUSE_1F", sx, sy, dir) + -- the teleport rebuilt the npc list, so pin her on the state we bump + local pinned = npcNamed(ow, "REDSHOUSE1F_MOM") + if pinned then pinned.frozen = true end + U.shot(game, DIR .. "/bug960_mom.png") + bumpInto(dir, "walking into MOM") + else + check("MOM has a free cell to be walked into from", false) + end + -- the control: a wall bump was audible before #960 too, so silence here + local wx, wy, wdir = findWall(game.overworld) + if wdir then + ow = stand("REDS_HOUSE_1F", wx, wy, wdir) + bumpInto(wdir, "walking into the wall to the " .. wdir) + else + check("REDS_HOUSE_1F has a wall to bump into", false) + end + end + + -- ---- the stairs up (#961) ----------------------------------------------- + if houseStairs then + local sx, sy = cellBehind(houseStairs.x, houseStairs.y, "up") + stand("REDS_HOUSE_1F", sx, sy, "up") + -- the destination decides the cue here, not the tile underfoot, so an + takeDoor("up", "Go_Inside", "taking the stairs up") + end + + -- ---- the bedroom PC (#960) --------------------------------------------- + if bedroomPC then + -- OpenRedsPC's hidden_event is gated on SPRITE_FACING_UP, so the cell + local sx, sy = cellBehind(bedroomPC.x, bedroomPC.y, "up") + ow = stand("REDS_HOUSE_2F", sx, sy, "up") + check("the cell below the bedroom PC is walkable", + ow.map:isWalkableCell(sx, sy)) + local mark = #cues + U.tap(game, "a") + U.wait(26) + check("A on the bedroom PC opens a menu", topIs(Menu)) + check("...and turns it on with Turn_On_PC", heard(mark, "Turn_On_PC") ~= nil) + U.shot(game, DIR .. "/bug960_bedroom_pc.png") + if topIs(Menu) then + mark = #cues + choose(game.stack:top(), Strings("LOG OFF")) + check("LOG OFF on the bedroom PC rings Turn_Off_PC (#960)", + heard(mark, "Turn_Off_PC") ~= nil) + U.log(" cues:", since(mark)) + check("...and the PC closed", game.stack:top() == game.overworld) + end + -- B out of the same menu is ExitPlayerPC's other entry and rings it too + U.tap(game, "a") + U.wait(26) + if topIs(Menu) then + local mark2 = #cues + U.tap(game, "b") + U.wait(26) + check("backing out of the bedroom PC with B rings it as well", + heard(mark2, "Turn_Off_PC") ~= nil) + end + end + + -- ---- back out of the house, to the street (#961) ------------------------ + if houseExit then + local sx, sy = cellBehind(houseExit.x, houseExit.y, "down") + ow = stand("REDS_HOUSE_1F", sx, sy, "down") + if not ow.map:isWalkableCell(sx, sy) then + U.log(("(%d, %d) is blocked; using the other half of the mat") + :format(sx, sy)) + ow = stand("REDS_HOUSE_1F", sx + 1, sy, "down") + end + takeDoor("down", "Go_Outside", "stepping out onto the street") + end + + -- ---- into the POKéMON CENTER, for the PC main menu ----------------------- + if centerDoor then + local sx, sy = cellBehind(centerDoor.x, centerDoor.y, "up") + stand("VIRIDIAN_CITY", sx, sy, "up") + takeDoor("up", "Go_Inside", "walking into the POKéMON CENTER") + end + + -- ---- the PC main menu: Enter_PC, and the silence under it (#960) -------- + if centerPC then + local sx, sy = cellBehind(centerPC.x, centerPC.y, "up") + ow = stand("VIRIDIAN_POKECENTER", sx, sy, "up") + for _, n in ipairs(ow.npcs or {}) do n.frozen = true end -- the GENTLEMAN walks + local mark = #cues + U.tap(game, "a") + U.wait(26) + check("A on the Center PC opens the PC main menu", topIs(Menu)) + check("...with Turn_On_PC", heard(mark, "Turn_On_PC") ~= nil) + + local mine = (game.save.player.name or "RED") .. "'s PC" + if topIs(Menu) then + mark = #cues + choose(game.stack:top(), mine) + check(mine .. " rings Enter_PC (#960)", heard(mark, "Enter_PC") ~= nil) + check("...and the item PC opened", topIs(Menu)) + -- BIT_USING_GENERIC_PC: reached this way, ExitPlayerPC is silent and + mark = #cues + U.tap(game, "b") + U.wait(26) + check("backing out of it again is silent, as the ROM is", + heard(mark, "Turn_Off_PC") == nil) + U.log(" cues:", since(mark)) + end + + -- ---- CHANGE BOX (#1044) ---------------------------------------------- + -- the box PC reads SOMEONE'S PC until EVENT_MET_BILL (pokemon_pc.asm) + local flags = game.save.flags or {} + local boxPC = (flags.EVENT_MET_BILL or flags.EVENT_GOT_SS_TICKET) + and "BILL'S PC" or Strings("SOMEONE'S PC") + if topIs(Menu) then + mark = #cues + choose(game.stack:top(), boxPC) + check(boxPC .. " rings Enter_PC too", heard(mark, "Enter_PC") ~= nil) + check("...and the box menu opened", topIs(Menu)) + end + if topIs(Menu) and rowIndex(game.stack:top(), Strings("CHANGE BOX")) then + choose(game.stack:top(), Strings("CHANGE BOX")) + U.shot(game, DIR .. "/bug1044_change_box.png") + local before = game.save.currentBox + U.tap(game, "down") -- BOX 1 is the current one, so move off it + U.wait(8) + mark = #cues + -- A picks the box, then the "data will be saved" prompt and its YES + U.tap(game, "a") + U.wait(20) + mash(function() return game.save.currentBox ~= before end, 40) + U.wait(60) -- the 15-frame answer hold, then the write + check(("CHANGE BOX switched box %s -> %s") + :format(tostring(before), tostring(game.save.currentBox)), + game.save.currentBox ~= before) + check("...and rang the SAVE jingle (#1044)", heard(mark, "Save") ~= nil) + U.log(" cues:", since(mark)) + end + + -- ---- LOG OFF from the main menu -------------------------------------- + for _ = 1, 6 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(20) + end + U.tap(game, "a") + U.wait(26) + if topIs(Menu) and rowIndex(game.stack:top(), Strings("LOG OFF")) then + local mark2 = #cues + choose(game.stack:top(), Strings("LOG OFF")) + check("LOG OFF on the PC main menu rings Turn_Off_PC", + heard(mark2, "Turn_Off_PC") ~= nil) + end + for _ = 1, 8 do + if game.stack:top() == game.overworld then break end + U.tap(game, "b") + U.wait(20) + end + end + + -- the battle menu click (#1045) already landed in HEAD 12c2677; confirmation only + ow = stand("ROUTE_1", 5, 5, "down") + if not ow.map:isWalkableCell(5, 5) then + local fx, fy + for cy = 0, ow.map.heightCells - 1 do + for cx = 0, ow.map.widthCells - 1 do + if ow.map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end + end + if fx then break end + end + if fx then ow = stand("ROUTE_1", fx, fy, "down") end + end + local battle = BattleState.newWild(game, "SNORLAX", 50) + battle.onFinish = function(result) ow:afterBattle(result, battle) end + -- SPLASH so the foe's turn can never end the run under the menu presses + battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } } + battle.enemy.curMoves = battle.enemy.mon.moves + ow:pushBattle(battle) + U.wait(220) -- the send-out intro runs before the menu is reachable + mash(function() return battle.phase == "menu" end, 60) + check("the wild SNORLAX battle reached its FIGHT menu", + battle.phase == "menu") + + -- one press, then long enough for the click to be its own sound + local function click(btn, label, want) + local mark = #cues + U.tap(game, btn) + U.wait(28) + local n = countOf(mark, "Press_AB") + check(("%s: %s gave %d click%s, expected %d") + :format(label, btn:upper(), n, n == 1 and "" or "s", want), + n == want) + end + if battle.phase == "menu" then + click("a", "A on FIGHT (#1045a)", 1) + check("...and the move list opened", battle.phase == "moveSelect") + U.shot(game, DIR .. "/bug1045_move_list.png") + click("b", "B out of the move list (#1045c)", 1) + check("...and the FIGHT menu came back", battle.phase == "menu") + click("a", "A on FIGHT again", 1) + click("a", "A on a move (#1045b)", 1) + mash(function() return battle.phase == "menu" end, 120) + if battle.phase == "menu" then + battle.menuIndex = 4 -- RUN shares the FIGHT/PKMN/ITEM call site + U.wait(4) + click("a", "A on RUN", 1) + end + end + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- over to you -------------------------------------------------------- + if centerPC then + local sx, sy = cellBehind(centerPC.x, centerPC.y, "up") + ow = stand("VIRIDIAN_POKECENTER", sx, sy, "up") + for _, n in ipairs(ow.npcs or {}) do n.frozen = true end + end + U.log("Everything above has been pressed once already; you are parked at the") + U.log("Viridian POKéMON CENTER PC to do it again by ear.") + U.log("A opens the PC main menu. RED's PC clicks in on the two-note ENTER PC") + U.log("chirp and LOG OFF closes with the descending power-down. B out of RED's") + U.log("PC is silent on purpose; a power-down there is the near miss, not a pass.") + U.log("SOMEONE'S PC, CHANGE BOX, any other box, YES: the SAVE jingle rings") + U.log("after the box has changed, the same jingle the SAVE menu plays. A") + U.log("jingle before the switch, or none at all, is #1044 back.") + U.log("The bedroom PC upstairs at home is the other half of #960: it beeps on,") + U.log("and LOG OFF or B rings the power-down there. That one is not silent.") + U.log("The COOLTRAINER at (4,3) and the NURSE at (3,1) are pinned; walk into") + U.log("either and it thuds like a wall. Walking into a wall is the control --") + U.log("it always thudded, so a silent wall means the device, not the fix.") + U.log(("Walk out over the exit mat at the bottom of the room: the door sound") + .. (" starts as the screen begins to darken, %d frames ahead of") + :format(FADE)) + U.log("Viridian City. One that lands on the new map, or after it, is #961.") + U.log("In any battle, A on FIGHT/PKMN/ITEM/RUN, A on a move and B out of the") + U.log("move list all click. Silence on any of the three is #1045.") + U.log("Shots: " .. DIR .. "/bug960_*.png, bug1044_*.png, bug1045_*.png") + + -- keeps naming cues with their frame after the hand-off, so a door sound + local reported = #cues + while true do + if #cues > reported then + for i = reported + 1, #cues do + U.log("cue", cues[i].name, "frame", cues[i].frame) + end + reported = #cues + end + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_lab_debut_bug1009_bug1021_test.lua b/tests/drivers/pikachu_lab_debut_bug1009_bug1021_test.lua new file mode 100644 index 00000000..963c311d --- /dev/null +++ b/tests/drivers/pikachu_lab_debut_bug1009_bug1021_test.lua @@ -0,0 +1,410 @@ +-- Pikachu stays in its ball until the rival fight (#1009) and clears (4,3) for the rival (#1021); the Route 15 leg only instruments #920. +-- pokeyellow scripts/OaksLab.asm, OaksLab_2.asm. +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local MapScripts = require("src.script.MapScripts") + local Commands = require("src.script.Commands") + local PF = require("src.world.PikachuFollower") + local Pokemon = require("src.pokemon.Pokemon") + local Bag = require("src.inventory.Bag") + local BattleState = require("src.battle.BattleState") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local LAB = "OAKS_LAB" + local RIVAL = 1 -- OAKSLAB_RIVAL, object index 1 + local GIFT = { x = 5, y = 3 } -- where OaksLabRLE_PlayerWalksToOak ends + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function idle() + while true do coroutine.yield() end + end + local function ow() return game.overworld end + local function follower() return PF.current(game.overworld) end + local function ctx() + return { game = game, save = game.save, overworld = game.overworld } + end + local function where(npc) + if not npc then return "gone" end + return "(" .. npc.cellX .. "," .. npc.cellY .. ") facing " + .. tostring(npc.facing) + end + + -- one player step; false when refused, so a caller can route around a map edit + local function stepOnce(dir) + local p = ow().player + local x0, y0 = p.cellX, p.cellY + for _ = 1, 60 do + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + p = ow().player + if p.cellX ~= x0 or p.cellY ~= y0 then break end + end + game.input.state[dir] = false + for _ = 1, 40 do + if not ow().player.moving then break end + U.wait(1) + end + U.wait(3) + p = ow().player + return p.cellX ~= x0 or p.cellY ~= y0 + end + + -- ---------------------------------------------------------------- checks + if not check("running the Yellow cache (POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) then + U.log("Red and Blue have no follower and no Yellow lab script, so every") + U.log("line below would fail for the wrong reason.") + idle() + end + check("SPRITE_PIKACHU resolves in the sprite table", + game.data.sprites ~= nil and game.data.sprites.SPRITE_PIKACHU ~= nil) + check("PikachuFollower.oaksLabMakeWay exists", + type(PF.oaksLabMakeWay) == "function") + check("the pikachu_make_way verb exists", + type(Commands.pikachu_make_way) == "function") + check("and it blocks the runner while the walk plays", + Commands.meta.pikachu_make_way ~= nil + and Commands.meta.pikachu_make_way.blocking == true) + + local lab = require("data.scripts.oaks_lab_yellow") + local oakRows = lab.talk.TEXT_OAKSLAB_OAK1 + local function rowIndex(pred) + for i, r in ipairs(oakRows) do + if pred(r) then return i end + end + return nil + end + local iGramps = rowIndex(function(r) + return r[1] == "show_text" and r[2] == "_OaksLabRivalGrampsText" + end) + local iMakeWay = rowIndex(function(r) return r[1] == "pikachu_make_way" end) + local iShow = rowIndex(function(r) + return r[1] == "show_object" and r[3] == "OAKSLAB_RIVAL" + end) + check("Oak's parcel branch has a make-way row", iMakeWay ~= nil) + -- the callfar sits after the GRAMPS text and before ShowObject; a row on + check("it sits between the GRAMPS text and the rival's ShowObject", + iGramps ~= nil and iMakeWay ~= nil and iShow ~= nil + and iGramps < iMakeWay and iMakeWay < iShow) + + local sfx = game.save.options and game.save.options.sfxVol + if sfx == 0 then + U.log("sfxVol is 0. Pikachu's cry as it bursts out of the ball will be") + U.log("silent and a mute run reads exactly like a missing cry -- turn") + U.log("the sound back up before judging the escape scene.") + end + + -- leg 1: in the ball. Level 30 only so the lab battle ends fast + game.save.flags = game.save.flags or {} + local flags = game.save.flags + flags.EVENT_GOT_STARTER = true + flags.EVENT_CHOSE_PIKACHU = true + flags.EVENT_FOLLOWED_OAK_INTO_LAB = true + flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true + flags.EVENT_GOT_POKEDEX = nil + flags.EVENT_OAK_GOT_PARCEL = nil + game.save.party = { Pokemon.new(game.data, "PIKACHU", 30) } + game.save.player.name = "bryan" + game.save.onBike = false + game.save.pikachuInBall = true + + U.teleport(game, LAB, GIFT.x, GIFT.y, "up") + U.wait(10) + Commands.show_object(ctx(), LAB, "OAKSLAB_OAK1") + U.wait(5) + + -- asked after the teleport: the registry only fills on first require + check("the Yellow lab module is the one bound to the map", + MapScripts.talkScript(LAB, "TEXT_OAKSLAB_OAK1") == oakRows) + + check("straight after the gift there is no follower on the map", + follower() == nil) + + -- save compat: nil pikachuInBall falls back to the rival-fight flag, not false + game.save.pikachuInBall = nil + U.wait(10) + check("a pre-#1009 save before the rival fight still has none", + follower() == nil) + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + U.wait(20) + check("a pre-#1009 save past the rival fight keeps its follower", + follower() ~= nil) + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil + game.save.pikachuInBall = true + U.wait(20) + check("and the in-ball byte puts it away again", follower() == nil) + + U.shot(game, SHOT_DIR .. "/bug1009_in_ball.png") + U.log("captured", SHOT_DIR .. "/bug1009_in_ball.png", + "- player alone at the gift spot") + + -- --------------------------------------------- leg 2: out of the ball + Commands.show_object(ctx(), LAB, "OAKSLAB_RIVAL") + U.wait(5) + + -- OaksLabRivalChallengesPlayerScript fires from y >= 6 with the starter held + local walkedClean = true + for _ = 1, 10 do + if ow().player.cellY >= 6 then break end + if follower() then walkedClean = false end + if not stepOnce("down") then + if not stepOnce("left") then break end + end + end + check("crossed the lab to the door row with no follower behind", + walkedClean and follower() == nil) + check("reached the row the rival challenges from", ow().player.cellY >= 6) + + -- the escape only spawns the companion once the overworld is back on top + local sawBattle, escapedAt, spawnCell = false, nil, nil + for _ = 1, 2500 do + local o = game.overworld + local top = game.stack:top() + if top ~= o then + if getmetatable(top) == BattleState then sawBattle = true end + U.tap(game, "a") + U.wait(3) + else + local npc = follower() + if npc and not escapedAt then + escapedAt = U.frame() + spawnCell = { x = npc.cellX, y = npc.cellY, facing = npc.facing } + U.shot(game, SHOT_DIR .. "/bug1009_escaped.png") + end + if escapedAt and not o.runner:isRunning() and #o.scriptMoves == 0 then + break + end + U.wait(2) + end + end + + check("the rival battle actually ran", sawBattle) + check("the escape scene put a follower on the map", escapedAt ~= nil) + check("EVENT_BATTLED_RIVAL_IN_OAKS_LAB is set", + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB == true) + check("the ball is open (save.pikachuInBall false, not nil)", + game.save.pikachuInBall == false) + if spawnCell then + local p = ow().player + U.log("it appeared at", spawnCell.x, spawnCell.y, "with the player at", + p.cellX, p.cellY, "facing", p.facing) + -- OaksLabPikachuEscapesPokeballScript faces the player up and uses spawn + check("it burst out on the cell behind the player, not beside him", + spawnCell.x == p.cellX and spawnCell.y == p.cellY + 1) + U.log("captured", SHOT_DIR .. "/bug1009_escaped.png") + end + + -- leg 3: Oak's .DeliverParcelText needs parcel held, no balls, no Pokedex + flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true + flags.EVENT_PALLET_AFTER_GETTING_POKEBALLS = nil + flags.EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = nil + flags.EVENT_GOT_POKEDEX = nil + game.save.pikachuInBall = false + game.save.inventory = game.save.inventory or {} + game.save.inventory.POKE_BALL = nil + if game.save.pokedex then game.save.pokedex.owned = {} end + Bag.add(game.save, "OAKS_PARCEL", 1, game.data) + + -- the SPRITE_FACING_LEFT case of TryApplyPikachuMovementData + U.teleport(game, LAB, 4, 3, "right") + U.wait(10) + Commands.show_object(ctx(), LAB, "OAKSLAB_OAK1") + -- he walked out after the lab battle; ShowObject brings him back mid-scene + Commands.hide_object(ctx(), LAB, "OAKSLAB_RIVAL") + U.wait(5) + check("the follower is back for the parcel scene", follower() ~= nil) + stepOnce("right") + -- Oak stands on (5,2) and blocks, so this only turns the player up + U.hold(game, "up", 10) + U.wait(10) + + local p = ow().player + local pika = follower() + U.log("player at", p.cellX, p.cellY, "facing", p.facing, + "| Pikachu at", where(pika)) + check("the player is below Oak on row 3", p.cellY == 3 and p.facing == "up") + local onRivalCell = pika ~= nil and pika.cellX == 4 and pika.cellY == 3 + check("Pikachu is standing on the rival's landing cell (4,3)", onRivalCell) + if not onRivalCell then + U.log("Without it on (4,3) the movement data does not apply and the") + U.log("scene below proves nothing about #1021.") + end + U.shot(game, SHOT_DIR .. "/bug1021_before.png") + + -- the rival's own walk sits in the same scriptMoves list, so ask only + local function stillWalking(o, npc) + if npc.moving then return true end + for _, mv in ipairs(o.scriptMoves) do + if mv.entity == npc then return true end + end + return false + end + + -- end pose snapshotted at walk stop; the idle roll turns it later (Func_fc803) + U.tap(game, "a") + local leftCell, rivalSeen, rivalArrived, midShot = nil, nil, nil, false + local settled + local trace, last = {}, nil + for f = 1, 1200 do + local o = game.overworld + local top = game.stack:top() + local npc = follower() + if npc then + local cell = npc.cellX .. "," .. npc.cellY + if cell ~= last then + last = cell + trace[#trace + 1] = "f" .. f .. " " .. cell + end + if not leftCell and not (npc.cellX == 4 and npc.cellY == 3) then + leftCell = f + end + if leftCell and not settled and not stillWalking(o, npc) then + settled = { x = npc.cellX, y = npc.cellY, facing = npc.facing } + end + end + local rival = o:npcByIndex(RIVAL) + if rival and not rivalSeen then rivalSeen = f end + if rival and not rivalArrived and rival.cellX == 4 and rival.cellY == 3 + and not rival.moving then + rivalArrived = f + end + if leftCell and rival and rival.moving and not midShot then + midShot = true + U.shot(game, SHOT_DIR .. "/bug1021_stepped_aside.png") + end + if rivalArrived and settled then break end + if top ~= o then U.tap(game, "a") end + U.wait(2) + end + + U.log("Pikachu trace:", table.concat(trace, " | ")) + check("Pikachu moved off (4,3)", leftCell ~= nil) + check("the rival came in and reached (4,3)", rivalArrived ~= nil) + if leftCell and rivalSeen then + -- the callfar runs before ShowObject: the cell is clear before the rival + check("it started clearing the cell before the rival appeared", + leftCell <= rivalSeen) + end + if settled then + U.log("the walk ended with Pikachu at", settled.x, settled.y, + "facing", settled.facing) + end + -- OaksLabPikachuMovementData2: STEP_DOWN, STEP_RIGHT, LOOK_UP + check("it ended one below the player looking up", + settled ~= nil and settled.x == 5 and settled.y == 4 + and settled.facing == "up") + U.shot(game, SHOT_DIR .. "/bug1021_rival_in_place.png") + + -- leg 4: nothing is fixed for #920; this only records ow.npcs and trail.ledgeHop + local eastMap = game.data.maps.FUCHSIA_CITY + and game.data.maps.FUCHSIA_CITY.connections + and game.data.maps.FUCHSIA_CITY.connections.east + eastMap = eastMap and eastMap.map or "ROUTE_15" + U.log("Fuchsia's east connection is", eastMap) + + U.teleport(game, "FUCHSIA_CITY", 10, 12, "down") + U.wait(10) + local city = ow().map + local row = nil + for y = 0, city.heightCells - 1 do + if city:isWalkableCell(city.widthCells - 1, y) + and city:isWalkableCell(city.widthCells - 2, y) then + row = row or y + end + end + if row then + U.log("crossing the east seam on row", row) + U.teleport(game, "FUCHSIA_CITY", city.widthCells - 2, row, "right") + U.wait(10) + for _ = 1, 6 do + if ow().map.id == eastMap then break end + if not stepOnce("right") then break end + end + end + -- the seam row is blocked (gate rebuild, mod): drop in on the route itself + local function dropOnRoute() + U.teleport(game, eastMap, 4, 8, "right") + U.wait(10) + local m = ow().map + if m:isWalkableCell(4, 8) then return end + for y = 0, m.heightCells - 1 do + for x = 0, 9 do + if m:isWalkableCell(x, y) then + U.teleport(game, eastMap, x, y, "right") + U.wait(10) + return + end + end + end + end + if ow().map.id ~= eastMap then + U.log("could not walk the seam; dropping straight on to", eastMap) + dropOnRoute() + end + check("standing on " .. eastMap, ow().map.id == eastMap) + + local worstGap, lostAt, steps = 0, nil, 0 + local back = { x = ow().player.cellX, y = ow().player.cellY } + for i = 1, 10 do + if not stepOnce("right") then + if not stepOnce("down") then break end + end + local o = game.overworld + if o.map.id ~= eastMap then + -- the route's gate is a warp, and an arrival respawn is not the stall + U.log("step", i, "walked into", o.map.id, "- stepping back out") + U.teleport(game, eastMap, back.x, back.y, "left") + U.wait(10) + break + end + steps = i + back.x, back.y = o.player.cellX, o.player.cellY + local npc = follower() + local p2 = o.player + local hop = o.pikachuTrail and o.pikachuTrail.ledgeHop + if npc then + local gap = math.abs(npc.cellX - p2.cellX) + + math.abs(npc.cellY - p2.cellY) + if gap > worstGap then worstGap = gap end + U.log("step", i, "player", p2.cellX, p2.cellY, "| Pikachu", where(npc), + "| gap", gap, "| ledgeHop", tostring(hop)) + else + lostAt = lostAt or i + U.log("step", i, "player", p2.cellX, p2.cellY, + "| Pikachu is not in ow.npcs at all | ledgeHop", tostring(hop)) + end + end + U.log("walked", steps, "steps east on", eastMap) + check("the follower stayed in ow.npcs the whole way", lostAt == nil) + check("it never fell more than two cells behind", worstGap <= 2) + if lostAt then + U.log("it dropped out of ow.npcs on step", lostAt, + "- that is the shape #920 would take") + end + -- an arrival parks the follower under the player (#863), so walk one step + stepOnce("left") + U.wait(20) + U.shot(game, SHOT_DIR .. "/bug920_east_of_fuchsia.png") + + U.log("Six shots, in story order. bug1009_in_ball: the player alone in the") + U.log("lab with the starter already in the party. bug1009_escaped: the cry,") + U.log("then Pikachu on the cell behind him. bug1021_stepped_aside: it walks") + U.log("down and right on the GRAMPS text while the rival is still off the") + U.log("map, ending below the player looking up. The near miss to watch for") + U.log("is the rival arriving first and Pikachu shuffling around him after,") + U.log("or the walk playing under the box instead of after it.") + U.log("Talk to Pikachu here for the control case: it answers with a bubble") + U.log("and a cry, which is the same sprite and the same audio path.") + U.log("#920 is unfixed. The pad is yours on Route 15; the step log above is") + U.log("what the triage wants from a stall -- whether it is still in ow.npcs") + U.log("and whether trail.ledgeHop stayed set after a ledge.") + + idle() +end diff --git a/tests/engine/evolution_hold_b_bug968_test.lua b/tests/engine/evolution_hold_b_bug968_test.lua new file mode 100644 index 00000000..78174bce --- /dev/null +++ b/tests/engine/evolution_hold_b_bug968_test.lua @@ -0,0 +1,153 @@ +-- A level-up evolution survives the B held from the level-up box (#968, #1031); a fresh press still cancels (#290, #213). +-- pokered engine/movie/evolution.asm EvolveMon, Evolution_CheckForCancel. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- TextBox and EvolutionState both require Sound inside update, so seeding +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, +} + +local Fixtures = require("tests.modkit.fixtures") +local Evolution = require("src.pokemon.Evolution") +local EvolutionState = require("src.ui.EvolutionState") +local Input = require("src.core.Input") +local Pokemon = require("src.pokemon.Pokemon") +local StateStack = require("src.core.StateStack") +local TextBox = require("src.render.TextBox") + +local Data = Fixtures.fresh() +require("src.render.Font").load(Data) + +-- FIXMON_A evolves into FIXMON_B at 16 (tests/fixture_data/pokemon.lua) +local EVO_LEVEL = 16 +-- x is the default keyboard B (src/core/Input.lua DEFAULT_BINDINGS) +local B_KEY = "x" + +local function newGame() + local game = { data = Data } + local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL) + game.save = { + party = { mon }, + player = { name = "RED", id = 1 }, + options = { textSpeed = 5 }, + flags = {}, + pokedex = { seen = {}, owned = {} }, + } + game.stack = setmetatable({}, { __index = StateStack }) + game.stack:init() + game.input = Input + Input:init() + return game, mon +end + +-- one fixed step, in Game:step's order: promote the queued edges, then +local function step(game) + game.input:step() + game.stack:update(1 / 60) +end + +-- the post-battle sequence: grew-to-level box, then Evolution.checkParty +local function levelUpBox(game, mon) + game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!", + function() Evolution.checkParty(game, nil, { [mon] = true }) end)) +end + +-- one B edge on the box, still held when the movie takes over: the bug's handoff +local function dismissWithB(game, mon) + levelUpBox(game, mon) + local box = game.stack:top() + for _ = 1, 900 do + if box.done then break end + step(game) + end + if not box.done then return nil, "the level-up text never finished typing" end + Input:keypressed(B_KEY) + step(game) + local top = game.stack:top() + if getmetatable(top) ~= EvolutionState then + return nil, "the evolution screen never opened" + end + return top +end + +local function textOf(box) + local out = {} + for _, page in ipairs(box.pages) do + for _, line in ipairs(page) do out[#out + 1] = line end + end + return table.concat(out, " ") +end + +-- B held out of the text box: the movie must run to the end and evolve. +do + local game, mon = newGame() + local evo, why = dismissWithB(game, mon) + if check(evo ~= nil, "the level-up box handed off to the movie: " .. tostring(why)) then + check(Input:isDown("b"), + "B is still physically down as the movie starts, which is what " + .. "the old isDown poll cancelled on") + eq(evo.cancelable, true, + "and this is a cancelable level-up evolution, so the movie really " + .. "is reading the button (#290)") + -- never released: no second edge ever reaches the movie + for _ = 1, 400 do + if evo.done then break end + step(game) + end + check(Input:isDown("b"), "B was held for the whole movie") + eq(evo.canceled, false, "the held B did not cancel the evolution") + eq(mon.species, "FIXMON_B", "the mon actually evolved") + eq(mon.stats.hp, require("src.pokemon.Stats") + .calc(Data.pokemon.FIXMON_B, EVO_LEVEL, mon.dvs, mon.statExp).hp, + "and Evolution.apply recalculated its stats on the new species") + local top = game.stack:top() + check(getmetatable(top) == TextBox and textOf(top):find("evolved into"), + "the congratulations text is what closes the movie") + end +end + +-- A deliberate fresh press after the 80-frame delay still cancels. +do + local game, mon = newGame() + local evo = assert(dismissWithB(game, mon)) + Input:keyreleased(B_KEY) + for _ = 1, 400 do + if evo.t > 80 then break end + step(game) + end + check(evo.t > 80 and not evo.done, + "the movie is past the DelayFrames window and still running") + Input:keypressed(B_KEY) + step(game) + eq(evo.canceled, true, "a fresh B press cancels the evolution (#213)") + eq(mon.species, "FIXMON_A", "and the mon keeps its species") + local top = game.stack:top() + check(getmetatable(top) == TextBox and textOf(top):find("stopped evolving"), + "_StoppedEvolvingText prints instead of the congratulations") +end + +-- A press inside the 80 frames is not polled at all, so the mon still +do + local game, mon = newGame() + local evo = assert(dismissWithB(game, mon)) + Input:keyreleased(B_KEY) + for _ = 1, 10 do step(game) end + Input:keypressed(B_KEY) + step(game) + Input:keyreleased(B_KEY) + check(evo.t <= 80, "the press landed inside the delay window") + eq(evo.canceled, false, "a B press during the delay is never polled") + for _ = 1, 400 do + if evo.done then break end + step(game) + end + eq(mon.species, "FIXMON_B", "so the evolution still completes") +end + +T.finish() diff --git a/tests/engine/nurse_bow_bug995.lua b/tests/engine/nurse_bow_bug995.lua new file mode 100644 index 00000000..d34c706e --- /dev/null +++ b/tests/engine/nurse_bow_bug995.lua @@ -0,0 +1,102 @@ +-- Nurse Joy bows between the two closing lines (#995). +-- pokered engine/events/pokecenter.asm. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +-- the ROM-extracted strings the fixture text table does not carry +Data.text._PokemonFightingFitText = "Thank you for\nwaiting.\fYour POKéMON are\nfighting fit!" +Data.text._PokemonCenterFarewellText = "We hope to see\nyou again!" + +local OW = require("src.world.OverworldController") + +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local stackStub = { push = function(_, item) pushed[#pushed + 1] = item end } +local textBoxStub = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} +local fakeGame = { data = Data, stack = stackStub } +T.check(setUpvalue(OW.finishNurseHeal, "TextBox", textBoxStub), + "TextBox upvalue on finishNurseHeal") +T.check(setUpvalue(OW.finishNurseHeal, "Game", fakeGame), + "Game upvalue on finishNurseHeal") + +local FIT = Data.text._PokemonFightingFitText +local BYE = Data.text._PokemonCenterFarewellText + +local player = { cellX = 3, cellY = 5 } +local faced +local function newNurse() + faced = 0 + return { + facing = "down", + facePlayer = function(self) faced = faced + 1; self.facing = "down" end, + } +end + +local fakeSelf +local function reset() + pushed = {} + fakeSelf = setmetatable({ player = player }, { __index = OW }) +end + +-- === with the nurse on the counter: fit line, bow, farewell +reset() +local nurse = newNurse() +local finished = 0 +fakeSelf:finishNurseHeal(BYE, function() finished = finished + 1 end, nurse) +T.eq(#pushed, 1, "the fighting-fit line goes up on its own") +T.eq(pushed[1].text, FIT, "first box is exactly the fighting-fit text") +T.check(pushed[1].text:find(BYE, 1, true) == nil, + "the farewell is no longer merged into it with a page break (#995)") + +pushed[1].onDone() +T.eq(#pushed, 1, "the farewell waits for the bow") +T.eq(nurse.facing, "up", "image index $14: the nurse bows") +T.check(fakeSelf.emote ~= nil, "the bow is a world hold, not a text pause") +local hold = fakeSelf.emote or {} +T.eq(hold.npc, nurse, "the hold is anchored on the nurse") +T.eq(hold.frames, 20, "DelayFrames $14 is 20 frames") +T.eq(hold.bubble, false, "no emotion bubble is drawn over her") +T.check(not hold.skippable, "the bow cannot be skipped with A/B") +T.eq(finished, 0, "the pokecenter is still busy during the bow") + +-- OverworldState:update counts emote.frames down and then calls onDone +if hold.onDone then hold.onDone() end +T.eq(#pushed, 2, "the farewell follows the bow") +local farewell = pushed[2] or {} +T.eq(farewell.text, BYE, "second box is the farewell text") +T.eq(nurse.facing, "up", "she is still bowed while the farewell prints") + +if farewell.onDone then farewell.onDone() end +T.eq(nurse.facing, "down", "the trailing UpdateSprites faces her back") +T.eq(faced, 1, "she is turned back exactly once") +T.eq(finished, 1, "control returns to the player once, after the farewell") + +-- === no nurse sprite (the Yellow/rest-stop callers): no bow, same text +reset() +finished = 0 +fakeSelf:finishNurseHeal(BYE, function() finished = finished + 1 end) +T.eq(pushed[1].text, FIT, "npc-less caller still opens with the fit line") +pushed[1].onDone() +T.check(fakeSelf.emote == nil, "nothing to bow, so no world hold") +T.eq(#pushed, 2, "the farewell follows immediately") +farewell = pushed[2] or {} +T.eq(farewell.text, BYE, "npc-less caller still closes with the farewell") +if farewell.onDone then farewell.onDone() end +T.eq(finished, 1, "npc-less caller returns control once") + +T.finish("nurse_bow_bug995") diff --git a/tests/engine/oaks_aide_requirement_bug1006.lua b/tests/engine/oaks_aide_requirement_bug1006.lua new file mode 100644 index 00000000..326f9a47 --- /dev/null +++ b/tests/engine/oaks_aide_requirement_bug1006.lua @@ -0,0 +1,149 @@ +-- Oak's aide quotes the REQUIREMENT, not your current count (#1006). +-- pokered engine/events/oaks_aide.asm .notEnoughOwnedMons. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +-- the ROM-extracted strings the fixture text table does not carry; labels +-- and wording match pokered data/text/text_1.asm +Data.text._OaksAideHiText = + "Hi! Remember me?\nI'm PROF.OAK's\vAIDE!\fIf you caught " .. + "{NUM:hOaksAideRequirement, 1, 3}\nkinds of POKéMON,\vI'm supposed to\v" .. + "give you an\v{RAM:wOaksAideRewardItemName}!\fSo, {PLAYER}! Have\n" .. + "you caught at\vleast {NUM:hOaksAideRequirement, 1, 3} kinds of\vPOKéMON?" +Data.text._OaksAideUhOhText = + "Let's see...\nUh-oh! You have\vcaught only " .. + "{NUM:hOaksAideNumMonsOwned, 1, 3}\vkinds of POKéMON!\fYou need " .. + "{NUM:hOaksAideRequirement, 1, 3} kinds\nif you want the\v" .. + "{RAM:wOaksAideRewardItemName}." +Data.text._OaksAideComeBackText = + "Oh. I see.\fWhen you get {NUM:hOaksAideRequirement, 1, 3}\nkinds, come " .. + "back\vfor {RAM:wOaksAideRewardItemName}." +Data.text._OaksAideHereYouGoText = + "Great! You have\ncaught {NUM:hOaksAideNumMonsOwned, 1, 3} kinds \v" .. + "of POKéMON!\vCongratulations!\fHere you go!" +Data.text._OaksAideGotItemText = + "{PLAYER} got the\n{RAM:wOaksAideRewardItemName}!" +-- the two rewards this suite drives (Route2Gate / Route11Gate2F pass them +Data.items.HM_FLASH = { id = "HM_FLASH", index = 196, name = "HM FLASH" } +Data.items.ITEMFINDER = { id = "ITEMFINDER", index = 6, name = "ITEMFINDER" } + +local SaveData = require("src.core.SaveData") + +local pushed = {} +local realTextBox = package.loaded["src.render.TextBox"] +-- story4's push/ask require TextBox lazily, so a package.loaded stub is +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} + +local story4 = dofile("data/scripts/story4.lua") +local ROUTE_11 = story4.ROUTE_11_GATE_2F.talk.TEXT_ROUTE11GATE2F_OAKS_AIDE +local ROUTE_2 = story4.ROUTE_2_GATE.talk.TEXT_ROUTE2GATE_OAKS_AIDE +T.check(type(ROUTE_11) == "function" and type(ROUTE_2) == "function", + "both aides are wired to the shared oaksAide handler") + +local game = { + data = Data, + save = SaveData.newGame(), + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, +} + +local function reset(ownedCount) + game.save = SaveData.newGame() + game.save.player.name = "RED" + local owned = {} + for i = 1, ownedCount do owned["SPECIES_" .. i] = true end + game.save.pokedex = { seen = {}, owned = owned } + pushed = {} +end +local function lastText() + return tostring(pushed[#pushed] and pushed[#pushed].text) +end +local function has(fragment) + return lastText():find(fragment, 1, true) ~= nil +end +local function held(id) + return game.save.inventory[id] or 0 +end +-- A press on the box that is up +local function dismiss() + local box = pushed[#pushed] + if box and box.onDone then box.onDone() end +end + +-- === the aide asks for his own threshold, whatever the player owns +reset(12) +local done = false +ROUTE_11(game, {}, {}, function() done = true end) +local offer = pushed[1] +T.check(offer.opts and offer.opts.choice ~= nil, + "the aide's opener is the YesNoChoice question") +T.check(has("least 30 kinds"), "opener asks for the aide's 30 kinds") +T.check(has("give you an\vITEMFINDER"), "opener names the reward item") +T.check(not has("{NUM"), "no placeholder survives into the opener") + +-- === YES with too few kinds: both decimals are filled, and differently +offer.opts.choice(true) +T.check(has("caught only 12"), "Uh-oh line reports the kinds actually owned") +T.check(has("You need 30 kinds"), "Uh-oh line then states the requirement") +T.check(not has("You need 12 kinds"), + "the requirement is not overwritten by the owned count (#1006)") +T.check(has("want the\vITEMFINDER"), "Uh-oh line still names the reward") +dismiss() +T.check(done, "the Uh-oh branch completes the talk") +T.eq(held("ITEMFINDER"), 0, "no reward below the threshold") +T.check(not game.save.flags.EVENT_GOT_ITEMFINDER, + "the aide can still be asked again") + +-- === the threshold tracks the aide, not a constant: Route 2 wants 10 +reset(3) +ROUTE_2(game, {}, {}, function() end) +pushed[1].opts.choice(true) +T.check(has("caught only 3"), "Route 2 Uh-oh reports 3 kinds owned") +T.check(has("You need 10 kinds"), "Route 2 states its own 10-kind threshold") +T.check(has("want the\vHM FLASH"), "Route 2 names the HM FLASH reward") + +-- === NO: ComeBackText quotes the requirement, nothing is given +reset(12) +done = false +ROUTE_11(game, {}, {}, function() done = true end) +pushed[1].opts.choice(false) +T.check(has("When you get 30"), "come-back line quotes the requirement") +T.check(has("back\vfor ITEMFINDER"), "come-back line names the reward") +dismiss() +T.check(done, "declining completes the talk") +T.eq(held("ITEMFINDER"), 0, "declining gives nothing") + +-- === YES at the threshold: HereYouGo carries the OWNED count, then the +reset(30) +done = false +ROUTE_11(game, {}, {}, function() done = true end) +pushed[1].opts.choice(true) +T.check(has("caught 30 kinds"), "congratulation line carries the owned count") +dismiss() +T.check(has("RED got the\nITEMFINDER!"), "the item line names player and item") +dismiss() +T.check(done, "the reward branch completes the talk") +T.eq(held("ITEMFINDER"), 1, "ITEMFINDER lands in the bag") +T.check(game.save.flags.EVENT_GOT_ITEMFINDER, "the aide's event flag is set") + +-- === repeat visit: the explanation text, no second ITEMFINDER +pushed = {} +done = false +ROUTE_11(game, {}, {}, function() done = true end) +T.eq(#pushed, 1, "a served player gets exactly one box") +T.check(pushed[1].opts == nil or pushed[1].opts.choice == nil, + "the repeat line is not a question") +T.eq(held("ITEMFINDER"), 1, "no second ITEMFINDER") + +if realTextBox ~= nil then + package.loaded["src.render.TextBox"] = realTextBox +else + package.loaded["src.render.TextBox"] = nil +end + +T.finish("oaks_aide_requirement_bug1006") diff --git a/tests/engine/oaks_lab_yellow_starter_bug1013.lua b/tests/engine/oaks_lab_yellow_starter_bug1013.lua new file mode 100644 index 00000000..33897aa4 --- /dev/null +++ b/tests/engine/oaks_lab_yellow_starter_bug1013.lua @@ -0,0 +1,113 @@ +-- Yellow's starter Pikachu gets the nickname prompt (#1013). +-- pokeyellow scripts/OaksLab.asm OaksLabPlayerReceivesPikachuScript. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.load() + +local pushed = {} +local realTextBox = package.loaded["src.render.TextBox"] +-- Commands requires TextBox at load time; stub it before the first require +package.loaded["src.render.TextBox"] = { + new = function(_, text, onDone, opts) + return { text = text, onDone = onDone, opts = opts } + end, +} +local Commands = require("src.script.Commands") +local SaveData = require("src.core.SaveData") + +-- === the lab scene: one PIKACHU gift row, and no skipNickname on it +local lab = dofile("data/scripts/oaks_lab_yellow.lua") +local ball = lab.talk.TEXT_OAKSLAB_EEVEE_POKE_BALL +T.check(type(ball) == "function", "the Eevee ball builds its rows per playthrough") + +local captured +local function buildScene(cellX, cellY) + captured = nil + local game = { data = Data, save = SaveData.newGame() } + game.save.flags.EVENT_OAK_ASKED_TO_CHOOSE_MON = true + local ow = { + player = { cellX = cellX, cellY = cellY }, + runner = { run = function(_, rows) captured = rows end }, + } + ball(game, ow, { def = {} }, function() end) + return captured or {} +end + +local function indexOf(rows, verb, arg) + for i, row in ipairs(rows) do + if row[1] == verb and (arg == nil or row[2] == arg) then return i end + end +end + +-- the shove branch (player on the table row, py == 4) and the plain +for _, spot in ipairs({ { 9, 4 }, { 5, 6 } }) do + local rows = buildScene(spot[1], spot[2]) + local where = ("from (%d,%d)"):format(spot[1], spot[2]) + local gives = 0 + for _, row in ipairs(rows) do + if row[1] == "give_pokemon" then + gives = gives + 1 + T.eq(row[2], "PIKACHU", "the gift species is PIKACHU " .. where) + T.eq(row[3], 5, "the gift is level 5 " .. where) + T.check(row[4] == nil, + "no skipNickname: AskName is left to run " .. where .. " (#1013)") + end + end + T.eq(gives, 1, "exactly one give_pokemon row " .. where) + + local give = indexOf(rows, "give_pokemon") + local received = indexOf(rows, "show_text", "_OaksLabReceivedText") + local got = indexOf(rows, "set_flag", "EVENT_GOT_STARTER") + T.check(received and give and received < give, + "the received line prints before the mon is added " .. where) + T.check(give and got and give < got, + "AddPartyMon runs before SetEvent EVENT_GOT_STARTER " .. where) +end + +-- === nothing in the Yellow lab names a Kanto starter (#1014): only a mod +local source = assert(io.open("data/scripts/oaks_lab_yellow.lua", "r")) +local text = source:read("*a") +source:close() +for _, species in ipairs({ "CHARMANDER", "SQUIRTLE", "BULBASAUR" }) do + T.check(not text:find(species, 1, true), + "the Yellow lab script never names " .. species) +end + +-- === give_pokemon offers AskName with a runner and no skipNickname +local function giveThrough(skipNickname) + pushed = {} + local game = { data = Data, save = SaveData.newGame(), + stack = { push = function(_, box) pushed[#pushed + 1] = box end } } + local runner = { + yield = function() return coroutine.yield() end, + resume = function(self, ...) coroutine.resume(self.co, ...) end, + } + local ctx = { game = game, save = game.save, runner = runner } + runner.co = coroutine.create(function() + Commands.give_pokemon(ctx, "FIXMON_A", 5, skipNickname) + end) + local ok, err = coroutine.resume(runner.co) + T.check(ok, "give_pokemon runs cleanly: " .. tostring(err)) + return game.save +end + +local save = giveThrough(nil) +T.eq(#pushed, 1, "a plain gift puts one box up") +T.check(tostring(pushed[1].text):find("nickname", 1, true) ~= nil, + "that box is AskName's question") +T.check(pushed[1].opts and pushed[1].opts.choice ~= nil, + "AskName is a YES/NO, not a plain box") +T.eq(#save.party, 1, "the gift joined the party") + +save = giveThrough(true) +T.eq(#pushed, 0, "skipNickname suppresses the prompt") +T.eq(#save.party, 1, "the gift still joined the party") + +if realTextBox ~= nil then + package.loaded["src.render.TextBox"] = realTextBox +else + package.loaded["src.render.TextBox"] = nil +end + +T.finish("oaks_lab_yellow_starter_bug1013") diff --git a/tests/engine/oaks_pc_flow.lua b/tests/engine/oaks_pc_flow.lua index ec5864d5..c786b9f4 100644 --- a/tests/engine/oaks_pc_flow.lua +++ b/tests/engine/oaks_pc_flow.lua @@ -118,8 +118,9 @@ for _, item in ipairs(menu.items) do if item.label == "PROF.OAK's PC" then oak = item end end T.check(oak ~= nil, "PROF.OAK's PC is offered once the Pokédex is had") -plays = {} -- drop the menu's Turn_On_PC; the session's jingle is what counts oak.onSelect() +-- drop the menu's Turn_On_PC and the row's Enter_PC +plays = {} T.eq(pushed[2].kind, "text", "selection opens the access text") T.check(tostring(pushed[2].text):find("Accessed", 1, true) ~= nil, "first session box is the access text") diff --git a/tests/parity_fan_club_chairman_bug1050.lua b/tests/parity_fan_club_chairman_bug1050.lua new file mode 100644 index 00000000..1743100e --- /dev/null +++ b/tests/parity_fan_club_chairman_bug1050.lua @@ -0,0 +1,121 @@ +-- Parity: the Fan Club Chairman asks before telling his story (#1050). +-- pokered scripts/PokemonFanClub.asm PokemonFanClubChairmanText. +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 Fan Club chairman") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local Commands = require("src.script.Commands") +local Flags = require("src.script.Flags") +local ChoiceBox = require("src.ui.ChoiceBox") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +local MAP, TEXT = "POKEMON_FAN_CLUB", "TEXT_POKEMONFANCLUB_CHAIRMAN" +local script = mapScripts.talkScript(MAP, TEXT) +check(type(script) == "table", "the chairman is a row list") +eq(#ScriptRunner.validate(script), 0, + "the rows validate cleanly (labels resolve after the renumbering)") + +-- === the intro is the question, not a plain box === +local asks = 0 +for _, row in ipairs(type(script) == "table" and script or {}) do + if row[1] == "ask" then + asks = asks + 1 + eq(row[2], "_PokemonFanClubChairmanIntroText", + "the YesNoChoice rides the intro text (#1050)") + end +end +eq(asks, 1, "exactly one question, right after the intro") + +-- === harness: run the talk script headless, recording show_text ids === +local shown = {} +local origShow = Commands.show_text +-- forward extraOpts: Commands.ask rides show_text's 4th argument +Commands.show_text = function(ctx, textId, subs, ...) + table.insert(shown, textId) + return origShow(ctx, textId, subs, ...) +end + +-- pressFn returns the Input.pressed table for this frame (default: A) +local function runScript(pressFn) + shown = {} + local ow = { map = { id = MAP, def = { label = MAP } }, + npcs = {}, entities = {} } + local r = ScriptRunner.new(Game, ow) + r:run(script, { npc = { def = {}, facePlayer = function() end }, + overworld = ow }) + local guard = 0 + while r:isRunning() and guard < 3000 do + guard = guard + 1 + Input.pressed = pressFn and pressFn() or { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +local function shownIs(want, msg) + eq(table.concat(shown, ","), table.concat(want, ","), msg) +end + +-- press B while the YES/NO box is up, A otherwise: the NO answer +local function declines() + if getmetatable(StateStack:top()) == ChoiceBox then return { b = true } end + return { a = true } +end + +local function held(id) return Game.save.inventory[id] or 0 end + +-- === 1) YES: story, voucher, received line, explanation === +Game.save = SaveData.newGame() +check(runScript(), "chairman script completes on YES") +shownIs({ "_PokemonFanClubChairmanIntroText", + "_PokemonFanClubChairmanStoryText", + "_PokemonFanClubReceivedBikeVoucherText", + "_PokemonFanClubExplainBikeVoucherText" }, + "YES hears the RAPIDASH story out and collects the voucher") +eq(held("BIKE_VOUCHER"), 1, "the BIKE VOUCHER is in the bag") +check(Flags.get(Game.save, "EVENT_RECEIVED_BIKE_VOUCHER"), + "EVENT_GOT_BIKE_VOUCHER is set") + +-- === 2) NO: the brush-off, and the voucher stays with the chairman === +Game.save = SaveData.newGame() +check(runScript(declines), "chairman script completes on NO") +shownIs({ "_PokemonFanClubChairmanIntroText", "_PokemonFanClubNoStoryText" }, + "NO skips the story and the gift (#1050)") +eq(held("BIKE_VOUCHER"), 0, "declining leaves the voucher unclaimed") +check(not Flags.get(Game.save, "EVENT_RECEIVED_BIKE_VOUCHER"), + "declining leaves the event clear, so he can be asked again") + +-- === 3) asking again after NO still works, and YES then pays out === +check(runScript(), "second visit completes") +shownIs({ "_PokemonFanClubChairmanIntroText", + "_PokemonFanClubChairmanStoryText", + "_PokemonFanClubReceivedBikeVoucherText", + "_PokemonFanClubExplainBikeVoucherText" }, + "a player who said NO can come back for the voucher") +eq(held("BIKE_VOUCHER"), 1, "the voucher arrives on the second visit") + +-- === 4) served player: .nothingleft, no question at all === +check(runScript(), "post-voucher script completes") +shownIs({ "_PokemonFanClubChairFinalText" }, + "with the voucher collected he only reminisces") +eq(held("BIKE_VOUCHER"), 1, "no second voucher") + +Commands.show_text = origShow +S.finish() diff --git a/tests/parity_silph_lapras_bug1049.lua b/tests/parity_silph_lapras_bug1049.lua new file mode 100644 index 00000000..4d2de480 --- /dev/null +++ b/tests/parity_silph_lapras_bug1049.lua @@ -0,0 +1,135 @@ +-- Parity: the Silph Co. 7F worker's LAPRAS gift offers the nickname prompt (#1049). +-- pokered scripts/SilphCo7F.asm SilphCo7FSilphWorkerM1Text. +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 Silph LAPRAS") +local check, eq = S.check, S.eq + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local SaveData = require("src.core.SaveData") +local ScriptRunner = require("src.script.ScriptRunner") +local Commands = require("src.script.Commands") +local Flags = require("src.script.Flags") +local Pokemon = require("src.pokemon.Pokemon") +local Boxes = require("src.pokemon.Boxes") +local mapScripts = require("data.scripts.init") + +Game.data = Data +Game.input = Input; Input:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +require("src.render.Font").load(Data) + +local MAP, TEXT = "SILPH_CO_7F", "TEXT_SILPHCO7F_SILPH_WORKER_M1" + +-- === 1) the worker is command rows, so the run carries a ScriptRunner === +local script = mapScripts.talkScript(MAP, TEXT) +check(type(script) == "table", + "the LAPRAS worker is a row list, not a bare callback (#1049)") +eq(#ScriptRunner.validate(script), 0, "the rows validate cleanly") +local gives = 0 +for _, row in ipairs(type(script) == "table" and script or {}) do + if row[1] == "give_pokemon" then + gives = gives + 1 + eq(row[2], "LAPRAS", "the gift species is LAPRAS") + eq(row[3], 15, "the gift is level 15 (lb bc, LAPRAS, 15)") + check(row[4] == nil, "no skipNickname: AskName is left to run") + end +end +eq(gives, 1, "exactly one give_pokemon row") + +-- === harness: run the talk script headless, A every frame, recording show_text ids +local shown = {} +local origShow = Commands.show_text +-- forward extraOpts: Commands.ask rides show_text's 4th argument +Commands.show_text = function(ctx, textId, subs, ...) + table.insert(shown, textId) + return origShow(ctx, textId, subs, ...) +end + +local function runScript() + shown = {} + local ow = { map = { id = MAP, def = { label = MAP } }, + npcs = {}, entities = {} } + local r = ScriptRunner.new(Game, ow) + r:run(script, { npc = { def = {}, facePlayer = function() end }, + overworld = ow }) + local guard = 0 + while r:isRunning() and guard < 3000 do + guard = guard + 1 + Input.pressed = { a = true } + StateStack:update(1 / 60) + r:update() + end + Input.pressed = {} + return not r:isRunning() +end + +local function shownIs(want, msg) + eq(table.concat(shown, ","), table.concat(want, ","), msg) +end + +-- === 2) the gift itself: thanks, nickname prompt, GotMonText, blurb === +Game.save = SaveData.newGame() +check(runScript(), "LAPRAS gift script completes") +shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText", + "_DoYouWantToNicknameText", "_GotMonText", + "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, + "thanks, nickname prompt, got-mon line, then the LAPRAS blurb") +eq(#Game.save.party, 1, "LAPRAS joins the party") +local lapras = Game.save.party[1] or {} +eq(lapras.species, "LAPRAS", "gift species is LAPRAS") +eq(lapras.level, 15, "LAPRAS is level 15") +eq(lapras.nickname, "AAAAAAAAAA", + "the nickname prompt reaches the NamingScreen (A-mash)") +check(Flags.get(Game.save, "EVENT_GOT_LAPRAS"), "BIT_GOT_LAPRAS is set") +check(Game.save.pokedex.owned.LAPRAS, "LAPRAS is registered owned") + +-- === 3) after the gift he worries about the PRESIDENT, and only after +check(runScript(), "post-gift script completes") +shownIs({ "_SilphCo7FSilphWorkerM1IsOurPresidentOkText" }, + "before Giovanni: the worried line, and no second LAPRAS") +eq(#Game.save.party, 1, "no second LAPRAS") + +Flags.set(Game.save, "EVENT_BEAT_SILPH_CO_GIOVANNI") +check(runScript(), "post-Giovanni script completes") +shownIs({ "_SilphCo7FSilphWorkerM1SavedText" }, + "after Giovanni: saved at last") + +-- === 4) party full, box has room: SendNewMonToBox still asks the name === +Game.save = SaveData.newGame() +for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end +check(runScript(), "full-party gift script completes") +shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText", + "_DoYouWantToNicknameText", "_SentToBoxText", "_GotMonText", + "_SilphCo7FSilphWorkerM1LaprasDescriptionText" }, + "full party: nickname, sent-to-box, got-mon line, blurb") +local boxed = false +for _, box in ipairs(Boxes.ensure(Game.save)) do + for _, m in ipairs(box) do + if m.species == "LAPRAS" then boxed = true end + end +end +check(boxed, "full-party LAPRAS lands in a box") +check(Flags.get(Game.save, "EVENT_GOT_LAPRAS"), "full-party gift sets the flag") + +-- === 5) party AND every box full: BoxIsFullText, no got-mon line, flag +Game.save = SaveData.newGame() +for i = 1, 6 do Game.save.party[i] = Pokemon.new(Data, "PIDGEY", 5) end +Boxes.ensure(Game.save) +for b = 1, Boxes.COUNT do + for s = 1, Boxes.CAPACITY do Game.save.boxes[b][s] = { species = "PIDGEY" } end +end +check(runScript(), "full-everything script completes") +shownIs({ "_SilphCo7FSilphWorkerM1HaveThisPokemonText", "_BoxIsFullText" }, + "no room: the box-full line, never a got-mon line for a mon you lack") +check(not Flags.get(Game.save, "EVENT_GOT_LAPRAS"), + "a failed give leaves BIT_GOT_LAPRAS clear, so the gift stays claimable") + +Commands.show_text = origShow +S.finish()