From 5c041ea857a3cac3f8e085476123180ea20205b8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 3 Aug 2026 12:39:36 -0400 Subject: [PATCH 1/2] snip for scroll --- libs/flexlove/FlexLove.lua | 142 ++++++++++++++++++++---- main.lua | 38 ++----- src/import/LauncherView.lua | 50 ++++++++- src/import/RomImporter.lua | 37 ++++-- tests/rom_importer_double_pick_test.lua | 11 +- 5 files changed, 210 insertions(+), 68 deletions(-) diff --git a/libs/flexlove/FlexLove.lua b/libs/flexlove/FlexLove.lua index ef17edbf..2f72f720 100644 --- a/libs/flexlove/FlexLove.lua +++ b/libs/flexlove/FlexLove.lua @@ -189,6 +189,12 @@ flexlove._accumulatedDt = 0 ---@type table flexlove._touchOwners = {} +-- Touch-drag scroll tracking: survives immediate-mode element recreation. +-- Maps touch ID -> { id, lastX, lastY }. Scroll position is persisted into +-- StateManager on every move (same contract as wheelmoved). +---@type table +flexlove._touchScroll = {} + ---@type table flexlove._mouseButtonStates = {} @@ -1412,6 +1418,82 @@ function flexlove._getTouchElementAtPosition(x, y) return candidates[1] end +local function elementIsTouchScrollable(element) + if not element or not element._scrollManager then + return false + end + local overflowX = element.overflowX or element.overflow + local overflowY = element.overflowY or element.overflow + return overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" +end + +-- Walk parents from a hit target so a finger on a button/row still scrolls +-- the containing list. Falls back to the wheel path's scrollable lookup when +-- the press lands on empty space inside a scroller. +local function findTouchScrollTarget(x, y, startElement) + local el = startElement + while el do + if elementIsTouchScrollable(el) then + return el + end + el = el.parent + end + return Context.findScrollableAtPosition(x, y) +end + +local function findElementByStateId(stateId) + if not stateId or stateId == "" then + return nil + end + local function walk(element) + if element.id == stateId or element._stateId == stateId then + return element + end + for _, child in ipairs(element.children or {}) do + local found = walk(child) + if found then + return found + end + end + return nil + end + for _, element in ipairs(flexlove.topElements or {}) do + local found = walk(element) + if found then + return found + end + end + for _, element in ipairs(flexlove._currentFrameElements or {}) do + local found = walk(element) + if found then + return found + end + end + return nil +end + +local function persistTouchScroll(element) + if flexlove._immediateMode and element and element._stateId and element._scrollManager then + StateManager.updateState(element._stateId, { + scrollManager = element._scrollManager:getState(), + }) + end +end + +local function resolveTouchScrollElement(track) + if not track then + return nil + end + local element = findElementByStateId(track.id) + if elementIsTouchScrollable(element) then + return element + end + return nil +end + --- Handle touch press events from LÖVE's touch input system --- Routes touch to the topmost element at the touch position and assigns touch ownership --- Hook this to love.touchpressed() to enable touch interaction @@ -1452,15 +1534,22 @@ function flexlove.touchpressed(id, x, y, dx, dy, pressure) end end end + end - -- Route to scroll manager for scrollable elements - if element._scrollManager then - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - element._scrollManager:handleTouchPress(touchX, touchY) - end + -- Scroll target is the nearest scrollable ancestor (or the scroller under + -- empty space). Tracked by stable id so immediate-mode recreation can resume. + local scrollEl = findTouchScrollTarget(touchX, touchY, element) + if scrollEl and scrollEl._scrollManager then + local scrollId = scrollEl._stateId or scrollEl.id + if scrollId and scrollId ~= "" then + flexlove._touchScroll[touchId] = { + id = scrollId, + lastX = touchX, + lastY = touchY, + } end + scrollEl._scrollManager:handleTouchPress(touchX, touchY) + persistTouchScroll(scrollEl) end end @@ -1500,15 +1589,21 @@ function flexlove.touchmoved(id, x, y, dx, dy, pressure) end end end + end - -- Route to scroll manager for scrollable elements - if element._scrollManager then - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - element._scrollManager:handleTouchMove(touchX, touchY) - end + local track = flexlove._touchScroll[touchId] + local scrollEl = resolveTouchScrollElement(track) + if track and scrollEl then + local sm = scrollEl._scrollManager + -- Immediate mode recreates managers each frame; re-arm drag from the + -- last persisted touch point so a move after beginFrame still scrolls. + if not sm._touchScrolling then + sm:handleTouchPress(track.lastX, track.lastY) end + sm:handleTouchMove(touchX, touchY) + persistTouchScroll(scrollEl) + track.lastX = touchX + track.lastY = touchY end end @@ -1548,19 +1643,23 @@ function flexlove.touchreleased(id, x, y, dx, dy, pressure) end end end + end - -- Route to scroll manager for scrollable elements - if element._scrollManager then - local overflowX = element.overflowX or element.overflow - local overflowY = element.overflowY or element.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - element._scrollManager:handleTouchRelease() - end + local track = flexlove._touchScroll[touchId] + local scrollEl = resolveTouchScrollElement(track) + if track and scrollEl then + local sm = scrollEl._scrollManager + if not sm._touchScrolling then + sm:handleTouchPress(track.lastX, track.lastY) end + sm:handleTouchMove(touchX, touchY) + sm:handleTouchRelease() + persistTouchScroll(scrollEl) end -- Clean up touch ownership (touch is complete) flexlove._touchOwners[touchId] = nil + flexlove._touchScroll[touchId] = nil end --- Get the number of currently active touches being tracked @@ -1659,6 +1758,7 @@ function flexlove.destroy() -- Clean up touch state flexlove._touchOwners = {} + flexlove._touchScroll = {} flexlove._mouseButtonStates = {} if flexlove._gestureRecognizer then flexlove._gestureRecognizer:reset() diff --git a/main.lua b/main.lua index 20488a26..d5874db4 100644 --- a/main.lua +++ b/main.lua @@ -422,10 +422,10 @@ function love.touchpressed(id, x, y, dx, dy, pressure) return TouchEditor.touchpressed(id, x, y) end if Importer then - if love.system.getOS() == "iOS" then - return Importer:touchpressed(id, x, y) - end - return Importer:mousepressed(x, y, 1) + -- Both mobiles: FlexLove scroll needs the real touch stream. Clicks are + -- polled inside the view; the istouch filter on mousepressed still drops + -- Android's synthesized mouse twin so Import cannot double-fire (#553). + return Importer:touchpressed(id, x, y, dx, dy, pressure) end Game:touchpressed(id, x, y) end @@ -437,10 +437,7 @@ function love.touchmoved(id, x, y, dx, dy, pressure) return TouchEditor.touchmoved(id, x, y) end if Importer then - if love.system.getOS() == "iOS" then - return Importer:touchmoved(id, x, y) - end - return + return Importer:touchmoved(id, x, y, dx, dy, pressure) end Game:touchmoved(id, x, y) end @@ -452,10 +449,7 @@ function love.touchreleased(id, x, y, dx, dy, pressure) return TouchEditor.touchreleased(id, x, y) end if Importer then - if love.system.getOS() == "iOS" then - return Importer:touchreleased(id, x, y) - end - return + return Importer:touchreleased(id, x, y, dx, dy, pressure) end Game:touchreleased(id, x, y) end @@ -478,20 +472,12 @@ function love.mousepressed(x, y, button, istouch) return TouchEditor.mousepressed(x, y, button) end if Importer then - -- The same double-fire TouchEditor guards against, which the launcher was - -- missing: love.touchpressed above forwards the primary touch to the - -- Importer on Android, and LÖVE ALSO synthesizes a mouse press for that - -- same touch, so one tap ran every launcher button twice. On Import that - -- meant two choose() calls and two stacked SAF picker activities: the - -- player picked their ROM, the top picker closed, and the second was still - -- underneath asking for it again, which is the "import the file twice" - -- in #553. Filtering on istouch keeps a real mouse (DeX, a Chromebook, a - -- USB mouse) working, which an Android-wide return would have broken. - -- - -- ANDROID ONLY, and the OS test is load bearing: love.touchpressed above - -- returns early on iOS and never forwards, so there the synthesized mouse - -- press is the ONLY event the launcher gets. Filtering istouch on both - -- killed every tap on iOS outright. + -- love.touchpressed already forwards the primary touch into FlexLove for + -- scroll. LÖVE ALSO synthesizes a mouse press for that same touch; if both + -- reached a press handler, one tap ran every launcher button twice and + -- stacked two SAF pickers (#553). Clicks are polled inside FlexLove from + -- love.touch / mouse.isDown, so dropping the synthesized istouch press is + -- safe. A real mouse (DeX, Chromebook, USB) still reaches mousepressed. if istouch and (love.system.getOS() == "Android" or love.system.getOS() == "iOS") then return end return Importer:mousepressed(x, y, button) diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 0a076c5b..bb0a562c 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -146,11 +146,48 @@ function LauncherView.update(imp, dt) end end +-- One dedup window covers a touch release plus the mouse click SDL +-- synthesizes for the same tap. +local ACT_DEDUP = 0.35 +-- Finger travel past this (px) is a scroll drag, not a tap — so dragging a +-- list row does not also fire that row's button. +local TAP_SLOP2 = 16 * 16 + function LauncherView.wheelmoved(imp, dx, dy) if not imp._flex then return end pcall(FlexLove.wheelmoved, dx, dy) end +-- Touch drag scroll: FlexLove's ScrollManager only moves when these are +-- hooked. Clicks still come from EventHandler's love.touch / mouse polling; +-- the view's action dedupe covers a tap that also synthesizes a mouse click, +-- and a drag past TAP_SLOP suppresses the click that would otherwise fire +-- on the row under the finger. +function LauncherView.touchpressed(imp, id, x, y, dx, dy, pressure) + if not imp._flex then return end + imp._touchAt = imp._touchAt or {} + imp._touchAt[tostring(id)] = { x = x, y = y } + pcall(FlexLove.touchpressed, id, x, y, dx, dy, pressure) +end + +function LauncherView.touchmoved(imp, id, x, y, dx, dy, pressure) + if not imp._flex then return end + local start = imp._touchAt and imp._touchAt[tostring(id)] + if start then + local ddx, ddy = x - start.x, y - start.y + if ddx * ddx + ddy * ddy > TAP_SLOP2 then + imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP + end + end + pcall(FlexLove.touchmoved, id, x, y, dx, dy, pressure) +end + +function LauncherView.touchreleased(imp, id, x, y, dx, dy, pressure) + if not imp._flex then return end + if imp._touchAt then imp._touchAt[tostring(id)] = nil end + pcall(FlexLove.touchreleased, id, x, y, dx, dy, pressure) +end + -- Synthetic click for the gamepad virtual cursor: find the element under the -- pad pointer and run its handler with a click-shaped event. function LauncherView.clickAt(imp, x, y) @@ -166,10 +203,6 @@ end -- ------- shared widget helpers --- One dedup window covers a touch release plus the mouse click SDL --- synthesizes for the same tap. -local ACT_DEDUP = 0.35 - local function queueAction(imp, key, fn, keepArm) local now = love.timer.getTime() local last = imp._actAt[key] @@ -188,6 +221,15 @@ local function handler(imp, key, action, keepArm) elseif ev.type == "unhover" then imp._hot[key] = nil elseif action and (ev.type == "click" or ev.type == "touchrelease") then + if ev.type == "touchrelease" then + local dx, dy = ev.dx or 0, ev.dy or 0 + if dx * dx + dy * dy > TAP_SLOP2 then + imp._suppressClickUntil = love.timer.getTime() + ACT_DEDUP + return + end + end + local untilT = imp._suppressClickUntil + if untilT and love.timer.getTime() < untilT then return end queueAction(imp, key, action, keepArm) end end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index d1b6553d..b39458b4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -617,12 +617,9 @@ function RomImporter.new(onComplete, opts) -- boot armed and let the first poll tick consume it, rather than making the -- player tap Import a second time to trigger the scan by hand (#553). pickPending = android or nil, - -- Android drag: the launcher is handed no move events at all (main.lua - -- forwards neither touchmoved nor mousemoved while it is up), and its mouse - -- emulation is what "no reliable pointer polling" below refers to. - -- love.touch IS pollable, so where it exists a touch drag can be resolved - -- inside draw the same way the desktop mouse is. Where it does not, every - -- Android path stays exactly as it was: act on press, never arm. + -- Mobile drag-scroll goes through FlexLove.touch* (main.lua forwards the + -- full touch stream while the launcher is up). love.touch remains pollable + -- for click hit-testing inside EventHandler. touchPollable = android and love.touch ~= nil and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil, tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods" @@ -1703,13 +1700,29 @@ function RomImporter:pressDelete(kind, id, version, commit) return false end --- Pointer input is polled by the FlexLove view (mouse and touch alike), so --- the host-forwarded press events are inert. The methods stay because --- main.lua forwards to them unconditionally while the launcher is up. +-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded +-- mousepressed stays inert so Android's synthesized mouse path cannot +-- double-fire a tap (#553). Touch move/press/release must still reach +-- FlexLove.touch* or scroll containers never drag on phones. function RomImporter:mousepressed() end -function RomImporter:touchpressed() end -function RomImporter:touchmoved() end -function RomImporter:touchreleased() end + +function RomImporter:touchpressed(id, x, y, dx, dy, pressure) + if not self._flex then return end + require("src.import.LauncherView").touchpressed( + self, id, x, y, dx, dy, pressure) +end + +function RomImporter:touchmoved(id, x, y, dx, dy, pressure) + if not self._flex then return end + require("src.import.LauncherView").touchmoved( + self, id, x, y, dx, dy, pressure) +end + +function RomImporter:touchreleased(id, x, y, dx, dy, pressure) + if not self._flex then return end + require("src.import.LauncherView").touchreleased( + self, id, x, y, dx, dy, pressure) +end -- Switch the active tab (chips, shoulder buttons). The find search caret and -- the soft keyboard drop with the panel they belonged to; each tab's scroll diff --git a/tests/rom_importer_double_pick_test.lua b/tests/rom_importer_double_pick_test.lua index e8462e2d..e7eba41e 100644 --- a/tests/rom_importer_double_pick_test.lua +++ b/tests/rom_importer_double_pick_test.lua @@ -145,10 +145,9 @@ for os, forwards in pairs(touchForwardsToImporter) do os .. ": the synthesized mouse press is dropped only where touch already forwarded") end --- The FlexLove view polls love.touch itself and dedupes a tap's synthesized --- mouse click in its action layer (LauncherView queueAction), so the --- host-forwarded touch events are inert stubs: they must accept any id --- without capturing state or throwing. +-- Before the FlexLove view attaches (_flex), touch handlers are no-ops: they +-- must accept any id without capturing importer state or throwing. Once the +-- view is up they forward into FlexLove.touch* for list drag-scroll. local touch = importer("iOS") touch:touchpressed(101, 20, 20) touch:touchmoved(101, 22, 22) @@ -156,7 +155,9 @@ touch:touchreleased(202, 20, 20) touch:touchpressed(303, 20, 20) touch:touchreleased(303, 20, 20) check(touch._activeTouch == nil, - "touch events stay inert: the view's own polling owns touch input") + "touch events before the view attaches leave importer touch state alone") +check(not touch._flex, + "and do not attach the FlexLove view on their own") love.system.getOS = saved.getOS love.system.pickFile = saved.pickFile From 0f7261dd9210d4fea8e44959c06e3c8a8d513d0a Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Mon, 3 Aug 2026 13:05:57 -0400 Subject: [PATCH 2/2] CLOSES #623, CLOSES #624, CLOSES #636, CLOSES #637, CLOSES #639, CLOSES #650, CLOSES #697, CLOSES #704, CLOSES #722 --- data/scripts/story.lua | 91 ++++-- data/scripts/story3.lua | 286 +++++++++++++----- data/scripts/victories.lua | 12 +- data/scripts/yellow_viridian_old_man.lua | 10 +- src/battle/BattleState.lua | 27 +- src/core/Game.lua | 7 + src/import/LauncherView.lua | 37 ++- src/render/PaletteFX.lua | 17 ++ src/script/Commands.lua | 9 +- src/ui/HallOfFame.lua | 43 ++- src/ui/PokedexMenu.lua | 9 +- src/world/FieldDefaults.lua | 8 + src/world/OverworldController.lua | 8 + tests/drivers/game_corner_bug624_test.lua | 267 +++++++++++++++++ tests/drivers/hall_of_fame_bug704_test.lua | 294 +++++++++++++++++++ tests/drivers/oldman_yellow_bug636_test.lua | 238 +++++++++++++++ tests/drivers/silph_giovanni_bug722_test.lua | 279 ++++++++++++++++++ tests/engine/pokedex_counts_bug639.lua | 90 ++++++ tests/parity_game_corner_clerk_bug552.lua | 17 +- tests/parity_hideout_gate.lua | 12 + tests/parity_yellow_hideout_gate.lua | 152 ++++++++++ tests/parity_yellow_old_man.lua | 2 + 22 files changed, 1783 insertions(+), 132 deletions(-) create mode 100644 tests/drivers/game_corner_bug624_test.lua create mode 100644 tests/drivers/hall_of_fame_bug704_test.lua create mode 100644 tests/drivers/oldman_yellow_bug636_test.lua create mode 100644 tests/drivers/silph_giovanni_bug722_test.lua create mode 100644 tests/engine/pokedex_counts_bug639.lua create mode 100644 tests/parity_yellow_hideout_gate.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 99aecc4c..8bcabcd0 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -766,6 +766,28 @@ local function silphRocketsLeave(game, ow, onlyMap) end end +-- SilphCo11FGiovanniAfterBattleScript (scripts/SilphCo11F.asm) is the whole +-- aftermath: DisplayTextID TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS, +-- GBFadeOutToBlack, SilphCo11FTeamRocketLeavesScript, Delay3, +-- GBFadeInFromBlack, then SetEvent. The port had only the hide pass, so the +-- speech never played and every rocket blinked out in front of the player +-- (#722). Same hide list as silphRocketsLeave, spelled as script rows so the +-- fade can hold over it. +local function silphAftermathRows() + local rows = { + { "show_text", "_SilphCo11FGiovanniYouRuinedOurPlansText" }, + { "fade", "out" }, + } + for _, floor in ipairs(SILPH_ROCKET_OBJECTS) do + for _, name in ipairs(floor[2]) do + rows[#rows + 1] = { "hide_object", floor[1], name } + end + end + rows[#rows + 1] = { "wait", 3 } -- Delay3 + rows[#rows + 1] = { "fade", "in" } + return rows +end + M.SILPH_CO_11F = { -- Giovanni's battle is a COORDINATE TRIGGER, not a talk. -- SilphCo11FDefaultScript (scripts/SilphCo11F.asm) checks @@ -792,11 +814,15 @@ M.SILPH_CO_11F = { ow:scriptMove(gio, "down", 3, function() gio:facePlayer(ow.player) ow:engageTrainer(gio, function() - -- SilphCo11FTeamRocketLeavesScript: every Silph rocket leaves - -- after the loss (the street rockets are handled by - -- M.SAFFRON_CITY.onEnter in story4.lua). + -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech, + -- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph + -- rocket leaves off-screen (the street rockets are handled by + -- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the + -- battle's own callbacks are still unwinding, so queueScript starts + -- it on the first idle overworld frame -- after the end-battle + -- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722). if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then - silphRocketsLeave(game, ow) + ow:queueScript(silphAftermathRows()) end end) end) @@ -981,7 +1007,11 @@ M.VICTORY_ROAD_3F = { local championsRoomRivalScript = { { "face_player" }, -- 1 { "check_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 2 - { "jump_if_true", 26 }, -- 3 past end + -- "end" rather than a row number past the tail: this script grew by a row + -- when the follow-Oak walk landed (#704), which silently turned the old + -- numeric 26 into a jump ONTO the closing HALL_OF_FAME warp instead of past + -- it, so a returning champion warped straight into the induction. + { "jump_if_true", "end" }, -- 3 { "show_text", "_ChampionsRoomRivalIntroText" }, -- 4 -- ChampionsRoomRivalReadyToBattleScript plays MUSIC_FINAL_BATTLE after -- the intro text, before the battle itself (#706); pushBattle's wipe-time @@ -989,37 +1019,50 @@ local championsRoomRivalScript = { -- continuous into the fight { "play_music", "Music_FinalBattle" }, -- 5 { "rival_battle", "OPP_RIVAL3", 1 }, -- 6 - { "jump_if_false", 26 }, -- 6 past end - { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 7 - { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 8 + -- losing halts here; the numeric target this replaced pointed at the + -- closing warp, which inducted a player who had just lost the fight (#704) + { "jump_if_false", "end" }, -- 7 + { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN" }, -- 8 + { "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9 -- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL, -- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch = -- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText -- is the port's generic " defeated BLUE!" engine line instead). - { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 9 + { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10 -- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo -- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after -- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in - { "play_music", "Music_Cities1", { keep = true } }, -- 10 - { "show_text", "_ChampionsRoomOakText" }, -- 11 - { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 12 - { "move_npc", 2, "up", 5 }, -- 13 OakEntranceAfterVictoryMovement + { "play_music", "Music_Cities1", { keep = true } }, -- 11 + { "show_text", "_ChampionsRoomOakText" }, -- 12 + { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13 + { "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement -- OakCongratulatesPlayerScript: rival faces left, Oak faces down - { "face_object", 1, "left" }, -- 14 - { "face_object", 2, "down" }, -- 15 - { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 16 + { "face_object", 1, "left" }, -- 15 + { "face_object", 2, "down" }, -- 16 + { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17 -- OakDisappointedWithRivalScript: Oak turns to the rival (right) - { "face_object", 2, "right" }, -- 17 - { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 18 + { "face_object", 2, "right" }, -- 18 + { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19 -- OakComeWithMeScript: Oak faces down again, then exits up - { "face_object", 2, "down" }, -- 19 - { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 20 - { "move_npc", 2, "up", 2 }, -- 21 OakExitChampionsRoomMovement - { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 22 + { "face_object", 2, "down" }, -- 20 + { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21 + { "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement + { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23 + -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement + -- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the + -- screen just fading on the spot (#704). The entrance walk leaves the + -- player at (4,3) and both north-wall warps sit on row 0, so the original + -- only ever spends three of those simulated steps -- CheckWarpsNoCollision + -- takes the HALL_OF_FAME warp the moment the walk lands on (4,0) and the + -- trailing UP/LEFT are dropped. Scripted steps ignore collision here just + -- as they do in the original (CollisionCheckOnLand skips its checks while + -- wSimulatedJoypadStatesIndex is non-zero), so stepping through the + -- rival's cell at (4,2) is the ported behavior, not a clip. + { "move_player", "up", 3 }, -- 24 -- hand the induction off to the HALL_OF_FAME room (consumed by its -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) - { "set_field", "pendingHallOfFame", true }, -- 23 - { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 24 + { "set_field", "pendingHallOfFame", true }, -- 25 + { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26 } M.CHAMPIONS_ROOM = { diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 8e250c59..56567a50 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -549,21 +549,57 @@ M.GAME_CORNER = { -- handler is bound to both text ids just below (#552). TEXT_GAMECORNER_CLERK1 = function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") + local Font = require("src.render.Font") + local Strings = require("src.core.Strings") local t = game.data.text local function line(suffix, fallback) return t["_GameCornerClerk1" .. suffix] or t["_GameCornerClerk" .. suffix] or fallback end + -- GameCornerDrawCoinBox (scripts/GameCorner.asm; pokeyellow's copy is + -- identical): TextBoxBorder at hlcoord 11,0 with b=5 c=7, a 9x7-tile + -- window in the top right holding MONEY at (12,2) over the amount on + -- row 3 and COIN at (12,4) over the count on row 5. Both + -- PrintBCDNumber calls pass LEADING_ZEROES, whose bit 7 SUPPRESSES + -- leading zeroes (home/print_bcd.asm), and neither passes LEFT_ALIGN, + -- so both numbers read plain and right-aligned against the inner edge + -- at column 18. The asm draws the box before the offer and redraws it + -- after the purchase, so it stands for the whole exchange: a draw-only + -- state under the dialogue gets that lifetime, since StateStack draws + -- every state above the last opaque one and updates only the top + -- (src/core/StateStack.lua), and reading save each frame is the + -- redraw (#624). + local coinBox = { draw = function() + Font.drawBox(11, 0, 9, 7) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(Strings("MONEY"), 96, 16) + local money = ("¥%d"):format(game.save.money or 0) + Font.draw(money, 152 - Font.width(money), 24) + Font.draw(Strings("COIN"), 96, 32) + local coins = ("%d"):format(game.save.coins or 0) + Font.draw(coins, 152 - Font.width(coins), 40) + love.graphics.setColor(1, 1, 1, 1) + end } + game.stack:push(coinBox) + -- Every branch below finishes here. A TextBox pops itself before its + -- onDone runs, so the coin box is top of the stack again by then and + -- this pop takes it down, never someone else's state. + local function finish() + game.stack:pop() + done() + end + -- YesNoChoice is called with the offer still printed, so the prompt + -- has to ride the open text box (opts.choice) instead of being pushed + -- after it closes, which is what made the question vanish (#624). game.stack:push(TextBox.new(game, line("DoYouNeedSomeGameCoinsText", - "Do you need some\ngame coins?\f¥1000 for 50."), function() - game.stack:push(ChoiceBox.new(game, function(yes) + "Do you need some\ngame coins?\f¥1000 for 50."), + nil, { choice = function(yes) if not yes then game.stack:push(TextBox.new(game, line("PleaseComePlaySometimeText", - "No? Please come\nplay sometime!"), done)) + "No? Please come\nplay sometime!"), finish)) return end -- scripts/GameCorner.asm GameCornerClerk1Text: coins need @@ -571,29 +607,30 @@ M.GAME_CORNER = { if not game.save.inventory.COIN_CASE then game.stack:push(TextBox.new(game, line("DontHaveCoinCaseText", - "You don't have a\nCOIN CASE!"), done)) + "You don't have a\nCOIN CASE!"), finish)) return end if (game.save.coins or 0) >= 9990 then game.stack:push(TextBox.new(game, line("CoinCaseIsFullText", - "Oops! Your COIN\nCASE is full."), done)) + "Oops! Your COIN\nCASE is full."), finish)) return end if game.save.money < 1000 then game.stack:push(TextBox.new(game, line("CantAffordTheCoinsText", - "You can't afford\nthe coins!"), done)) + "You can't afford\nthe coins!"), finish)) return end game.save.money = game.save.money - 1000 game.save.coins = math.min(9999, (game.save.coins or 0) + 50) + -- the thanks text is the plain _GameCornerClerk1ThanksHereAre50- + -- CoinsText; the new count belongs in the coin box the asm + -- redraws here, not appended to the line (#624) game.stack:push(TextBox.new(game, line("ThanksHereAre50CoinsText", - "Thanks! Here are\nyour 50 coins!") - .. ("\fCOINS: %d"):format(game.save.coins), done)) - end)) - end)) + "Thanks! Here are\nyour 50 coins!"), finish)) + end })) end, }, } @@ -606,99 +643,188 @@ M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK = M.GAME_CORNER.talk.TEXT_GAMECORNER_CLERK1 -- Game Corner prize lists (data/events/prizes.asm, prize_mon_levels.asm). --- The six mon prizes differ between Red and Blue; the three TM prizes are --- identical, so they are shared and appended to each version's mon list. +-- Each counter owns ONE window of three prizes, not the whole catalogue: +-- GetPrizeMenuId (engine/events/prize_menu.asm) subtracts +-- TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 from hTextID and indexes +-- PrizeDifferentMenuPtrs with the result, so vendor 1 sells +-- PrizeMenuMon1Entries, vendor 2 PrizeMenuMon2Entries and vendor 3 +-- PrizeMenuTMsEntries (#623). The mon windows and their levels differ per +-- version; the TM window is identical in all three, so it is shared. local PRIZE_TMS = { { kind = "item", item = "TM_DRAGON_RAGE", cost = 3300 }, { kind = "item", item = "TM_HYPER_BEAM", cost = 5500 }, { kind = "item", item = "TM_SUBSTITUTE", cost = 7700 }, } -local RED_PRIZES = { - { kind = "mon", species = "ABRA", level = 9, cost = 180 }, - { kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 }, - { kind = "mon", species = "NIDORINA", level = 17, cost = 1200 }, - { kind = "mon", species = "DRATINI", level = 18, cost = 2800 }, - { kind = "mon", species = "SCYTHER", level = 25, cost = 5500 }, - { kind = "mon", species = "PORYGON", level = 26, cost = 9999 }, - PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3], +local RED_PRIZE_WINDOWS = { + { + { kind = "mon", species = "ABRA", level = 9, cost = 180 }, + { kind = "mon", species = "CLEFAIRY", level = 8, cost = 500 }, + { kind = "mon", species = "NIDORINA", level = 17, cost = 1200 }, + }, + { + { kind = "mon", species = "DRATINI", level = 18, cost = 2800 }, + { kind = "mon", species = "SCYTHER", level = 25, cost = 5500 }, + { kind = "mon", species = "PORYGON", level = 26, cost = 9999 }, + }, + PRIZE_TMS, } -local BLUE_PRIZES = { - { kind = "mon", species = "ABRA", level = 6, cost = 120 }, - { kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 }, - { kind = "mon", species = "NIDORINO", level = 17, cost = 1200 }, - { kind = "mon", species = "PINSIR", level = 20, cost = 2500 }, - { kind = "mon", species = "DRATINI", level = 24, cost = 4600 }, - { kind = "mon", species = "PORYGON", level = 18, cost = 6500 }, - PRIZE_TMS[1], PRIZE_TMS[2], PRIZE_TMS[3], +local BLUE_PRIZE_WINDOWS = { + { + { kind = "mon", species = "ABRA", level = 6, cost = 120 }, + { kind = "mon", species = "CLEFAIRY", level = 12, cost = 750 }, + { kind = "mon", species = "NIDORINO", level = 17, cost = 1200 }, + }, + { + { kind = "mon", species = "PINSIR", level = 20, cost = 2500 }, + { kind = "mon", species = "DRATINI", level = 24, cost = 4600 }, + { kind = "mon", species = "PORYGON", level = 18, cost = 6500 }, + }, + PRIZE_TMS, +} +-- Yellow keeps the three windows but restocks both mon counters +-- (pokeyellow/data/events/prizes.asm, prize_mon_levels.asm) +local YELLOW_PRIZE_WINDOWS = { + { + { kind = "mon", species = "ABRA", level = 15, cost = 230 }, + { kind = "mon", species = "VULPIX", level = 18, cost = 1000 }, + { kind = "mon", species = "WIGGLYTUFF", level = 22, cost = 2680 }, + }, + { + { kind = "mon", species = "SCYTHER", level = 30, cost = 6500 }, + { kind = "mon", species = "PINSIR", level = 30, cost = 6500 }, + { kind = "mon", species = "PORYGON", level = 26, cost = 9999 }, + }, + PRIZE_TMS, } -local function activePrizes() - return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES +local function prizeWindow(n) + local GameVersion = require("src.core.GameVersion") + local windows = RED_PRIZE_WINDOWS + if GameVersion.isBlue() then + windows = BLUE_PRIZE_WINDOWS + elseif GameVersion.isYellow() then + windows = YELLOW_PRIZE_WINDOWS + end + return windows[n] end --- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize +-- Prize counters (engine/events/prize_menu.asm CeladonPrizeMenu; the prize -- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates -- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and -- with no case prints RequireCoinCaseText and returns without ever opening a -- window; only with the case does it print ExchangeCoinsForPrizesText and then -- show the prizes. #194: the port used to open the window unconditionally and --- skip both text boxes. -local function prizeCounter(game, ow, npc, done) - local ListMenu = require("src.ui.ListMenu") - local Commands = require("src.script.Commands") - local TextBox = require("src.render.TextBox") - local t = game.data.text - -- IsItemInBag COIN_CASE: without the case, deny and open no window - -- (COIN_CASE is a numeric count in save.inventory, nil when absent). - if not game.save.inventory.COIN_CASE then +-- skip both text boxes. wMaxMenuItem is 3, i.e. this window's three prizes +-- plus the NO THANKS row, and HandlePrizeChoice confirms the pick with +-- SoYouWantPrizeText + YesNoChoice before any coins move; every branch then +-- rets out of CeladonPrizeMenu, so one transaction ends the conversation and +-- buying again means talking to the counter again (#623). +local function prizeCounter(window) + return function(game, ow, npc, done) + local ListMenu = require("src.ui.ListMenu") + local Commands = require("src.script.Commands") + local TextBox = require("src.render.TextBox") + local t = game.data.text + -- IsItemInBag COIN_CASE: without the case, deny and open no window + -- (COIN_CASE is a numeric count in save.inventory, nil when absent). + if not game.save.inventory.COIN_CASE then + game.stack:push(TextBox.new(game, + t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done)) + return + end + -- ExchangeCoinsForPrizesText plays before the prize window opens. game.stack:push(TextBox.new(game, - t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done)) - return - end - -- ExchangeCoinsForPrizesText plays before the prize window opens. - game.stack:push(TextBox.new(game, - t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.", - function() - local items = {} - for _, p in ipairs(activePrizes()) do - local label - if p.kind == "mon" then - label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) - else - label = game.data.items[p.item].name + t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.", + function() + local items = {} + for _, p in ipairs(prizeWindow(window)) do + local label + if p.kind == "mon" then + label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level) + else + label = game.data.items[p.item].name + end + table.insert(items, + { label = label, right = tostring(p.cost), value = p }) end - table.insert(items, - { label = label, right = tostring(p.cost), value = p }) - end - local list - list = ListMenu.new(game, "PRIZES (COINS)", items, { - footer = ("COINS %d"):format(game.save.coins or 0), - onChoose = function(item) - local p = item.value + -- NoThanksText (data/events/prizes.asm) sits under the three prizes + table.insert(items, { label = "NO THANKS" }) + local list + -- close the window first: every ending in HandlePrizeChoice leaves + -- the menu for good, and the closing line belongs over the map + local function finish(msg) + list:close() + game.stack:push(TextBox.new(game, msg, done)) + end + local function buy(p) if (game.save.coins or 0) < p.cost then - list.footer = "Not enough coins!" + finish(t._SorryNeedMoreCoinsText or "Sorry, you need\nmore coins.") + return + end + -- HasEnoughCoins passed, so hand the prize over first and only + -- subtract once it landed: the asm rets before .subtractCoins when + -- the bag is full, or when both the party and every box are full + local roomless = t._OopsYouDontHaveEnoughRoomText + or "Oops! You don't\nhave enough room." + if p.kind == "mon" then + -- no runner here, so give_pokemon reports through ctx.lastCheck + -- and skips the AskName prompt (Commands.give_pokemon) + local ctx = { save = game.save, game = game } + Commands.give_pokemon(ctx, p.species, p.level) + if not ctx.lastCheck then + finish(roomless) + return + end + elseif not require("src.inventory.Bag").add( + game.save, p.item, 1, game.data) then + finish(roomless) return end game.save.coins = game.save.coins - p.cost - if p.kind == "mon" then - Commands.give_pokemon({ save = game.save, game = game }, - p.species, p.level) - else - game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1 - end - list.footer = ("Got it! COINS %d"):format(game.save.coins) - end, - onCancel = done, - }) - game.stack:push(list) - end)) + -- no thank-you line: HereYouGoText is unreferenced in the asm, + -- which just redraws the coin box (PrintPrizePrice) and returns + list:close() + done() + end + list = ListMenu.new(game, "PRIZES (COINS)", items, { + footer = ("COINS %d"):format(game.save.coins or 0), + onChoose = function(item) + local p = item.value + if not p then -- NO THANKS is the B exit (cp 3 -> .noChoice) + list:close() + done() + return + end + local name = (p.kind == "mon") + and game.data.pokemon[p.species].name + or game.data.items[p.item].name + -- SoYouWantPrizeText names the prize out of wNameBuffer, which + -- is not one of TextBox's RAM tokens, so fill it in here + local ask = (t._SoYouWantPrizeText + or "So, you want\n{RAM:wNameBuffer}?") + :gsub("{RAM:wNameBuffer}", name) + game.stack:push(TextBox.new(game, ask, nil, { + choice = function(yes) + if not yes then + finish(t._OhFineThenText or "Oh, fine then.") + return + end + buy(p) + end, + })) + end, + onCancel = done, + }) + game.stack:push(list) + end)) + end end M.GAME_CORNER_PRIZE_ROOM = { - talk = { -- the three prize counters are bg events - TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter, - TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter, - TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter, + talk = { -- the three prize counters are bg events, one window each + TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1 = prizeCounter(1), + TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_2 = prizeCounter(2), + TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_3 = prizeCounter(3), }, } diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index 56bd07df..b1aeaa9f 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -108,8 +108,16 @@ return { "_ViridianGymGiovanniTM27ExplanationText", } }, - -- Silph Co. Giovanni: unlocks the president's Master Ball gift - ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI" }, + -- Silph Co. Giovanni: unlocks the president's Master Ball gift. + -- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the + -- battle SilphCo10FGiovanniILostAgainText through SaveEndBattleTextPointers, + -- but he has no def_trainers header on 11F, so engageTrainer finds no + -- header.won to give it -- this chain is the port's stand-in for that loss + -- line (#722). The "Blast it all!" speech, the fade and the rockets + -- leaving are SilphCo11FGiovanniAfterBattleScript, ported in M.SILPH_CO_11F + -- (data/scripts/story.lua). + ["OPP_GIOVANNI#2"] = { flag = "EVENT_BEAT_SILPH_CO_GIOVANNI", + dialogue = { "_SilphCo10FGiovanniILostAgainText" } }, -- Fighting Dojo Karate Master (scripts/FightingDojo.asm -- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER, diff --git a/data/scripts/yellow_viridian_old_man.lua b/data/scripts/yellow_viridian_old_man.lua index 0bf2380d..3cf7dcd9 100644 --- a/data/scripts/yellow_viridian_old_man.lua +++ b/data/scripts/yellow_viridian_old_man.lua @@ -24,7 +24,8 @@ -- ViridianCityPostInitialCatchTraining): stepping into (19,9) -- the gap -- east of the sleeper's cell -- faces the old man right and the player -- left, prints the apology, and without any choice runs the demo battle --- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5). After it, the same text pointer +-- (BATTLE_TYPE_OLD_MAN, RATTATA lvl 5), which he FAILS -- the ball shakes +-- three times and breaks open. After it, the same text pointer -- now prints _ViridianCityOldManLosingMyTouchText ("That didn't work! -- I must be losing my touch."), the old man walks off (down 6 with the -- player on (19,9), right 1 otherwise, Pikachu nudged out of the way @@ -63,7 +64,12 @@ end local function oldMan2Rows(game, ow, npc) local rows = { { "show_text", "_ViridianCityOldManHadMyCoffeeNowText" }, - { "old_man_demo" }, + -- ViridianCityOldManInitialCatchTrainingScript sets + -- EVENT_INITIAL_CATCH_TRAINING before the battle runs, and + -- ItemUseBall's .oldManBattle branch turns that event into anim data + -- $63: three shakes, then the ball breaks open. The losing-my-touch + -- line below only follows a throw that failed (#636). + { "old_man_demo", "fail" }, { "set_flag", "EVENT_COMPLETED_CATCH_TRAINING" }, { "show_text", "_ViridianCityOldManLosingMyTouchText" }, } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index fb9456c3..9334bf92 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -768,13 +768,23 @@ end -- engine/battle/core.asm DisplayBattleMenu .oldManName branch): no -- player mon; the battle menu appears under the OLD MAN's name and a -- scripted cursor hovers FIGHT, hops to ITEM and forces the item menu --- (one POKé BALL x50). The throw always catches; nothing is kept. +-- (one POKé BALL x50). Nothing is kept. -- Yellow's Pallet intro (BATTLE_TYPE_PIKACHU) is the same simulated -- script under "PROF.OAK" (pokeyellow core.asm .profOakName), so the -- displayed thrower name is a parameter. -function BattleState:makeOldManDemo(name) +-- The throw catches everywhere except Yellow's FIRST Viridian training. +-- ItemUseBall's .oldManBattle branch checks EVENT_INITIAL_CATCH_TRAINING +-- and, when it is set, stores anim data $63 in place of the $43 capture +-- value -- three shakes, then a breakout (pokeyellow +-- engine/items/item_effects.asm). Red/Blue's ItemUseBall has no such +-- branch and jumps straight to .captured, and Yellow's repeat "Watch +-- closely!" demo resets the event before its battle +-- (ViridianCityOldManStartCatchTrainingScript), so only the initial +-- tutorial passes failThrow -- it stands in for that event (#636). +function BattleState:makeOldManDemo(name, failThrow) self.demo = true self.demoName = name or "OLD MAN" + self.demoFails = failThrow and true or false -- LoadPlayerBackPic and DisplayBattleMenu split on the same wBattleType: -- BATTLE_TYPE_OLD_MAN gets .oldManName + OldManPicBack, BATTLE_TYPE_PIKACHU -- gets .profOakName + ProfOakPicBack (pokeyellow core.asm). The thrower @@ -2061,7 +2071,10 @@ end -- skipped -- the old man branch jumps straight to .captured, $43 anim -- data = 3 shakes and caught (:155-164 + :193-200) -- and -- .oldManCaughtMon prints the caught text WITHOUT adding the mon to --- the party or the dex (:568-570). The "used" line reads OLD MAN +-- the party or the dex (:568-570). Yellow's initial training is the one +-- exception (demoFails, #636): its .oldManBattle branch forces $63, so +-- the same chain ends in a breakout and ItemUseBallText04 instead. +-- The "used" line reads OLD MAN -- because DisplayBattleMenu swapped wPlayerName (core.asm:2024-2037); -- no ball is consumed (.done returns early, :576-578). function BattleState:oldManThrow() @@ -2074,6 +2087,14 @@ function BattleState:oldManThrow() -- ItemUseBall's beat before the toss chain (like throwBall) self.nextInsert = (self.nextInsert or 0) + 1 table.insert(self.queue, self.nextInsert, { wait = 20 }) + if self.demoFails then + -- $63 instead of $43: the same three shakes, then POOF+SHOWPIC and + -- ItemUseBallText04. No sound_caught_mon, and .captured is never + -- reached, so nothing touches the party or the dex either (#636). + self:ballChain("TOSS_ANIM", false, 3, "POKE_BALL") + self:sayNext(self:ballMissMessage(3)) + return + end self:ballChain("TOSS_ANIM", true, 3, "POKE_BALL") self:actNext(function() require("src.core.Sound").play(self.data, "Caught_Mon") diff --git a/src/core/Game.lua b/src/core/Game.lua index d89e5dd4..1ee03703 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -441,7 +441,14 @@ function Game:draw() if classicOffset ~= 0 and not wideState then love.graphics.push() love.graphics.translate(classicOffset, 0) + -- a classic state reports its trueColor rects in its own 160x144 + -- coordinates, so they take the same shift its pixels just got -- + -- centerClassicZones already does exactly this to its zone list, + -- and without the pair the unshaded re-blit misses the pic (#637) + local P = require("src.render.PaletteFX") + P.setMarkOffset(classicOffset) state:draw() + P.setMarkOffset(0) love.graphics.pop() else state:draw() diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index bb0a562c..ab4f6d01 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -54,10 +54,11 @@ local function C(name, a) return rgba(c[1], c[2], c[3], a) end --- Every element in this view refuses to flex-shrink: overflow is always a --- scroll container's job here, and the engine otherwise compresses +-- Non-scroll elements refuse to flex-shrink: the engine otherwise compresses -- auto-height children inside height-constrained columns until their text --- overlaps (the portrait single-column layout was the visible case). +-- overlaps (the portrait single-column layout was the visible case). Scroll +-- regions are the opposite — they MUST shrink to the viewport, or their +-- height grows with content, maxScrollY stays 0, and drag/wheel do nothing. -- horizontal padding of a props table, for content-width bookkeeping local function propsPadH(p) local pad = p.padding @@ -68,8 +69,18 @@ local function propsPadH(p) return 0 end +local function isScrollOverflow(props) + local o = props.overflowY or props.overflowX or props.overflow + return o == "scroll" or o == "auto" +end + local function mk(props) - if props.flexShrink == nil then props.flexShrink = 0 end + if props.flexShrink == nil then + props.flexShrink = isScrollOverflow(props) and 1 or 0 + end + if isScrollOverflow(props) and props.minHeight == nil then + props.minHeight = 0 + end -- Resolve "100%" here, against the parent's CONTENT width: the engine -- resolves a percentage against the parent's border box and ignores its -- padding, so every percent child of a padded container overflowed to the @@ -785,11 +796,23 @@ local function buildSlotCard(imp, parent, m, version) math.ceil(textHeight(pillSize)) + 8) local metaH = math.ceil(textHeight(metaSize)) local rowH = 10 + headH + 5 + metaH + 8 + btnH + 10 + -- Fixed-height scroller so 40 slots actually overflow (page-level flex + -- scroll alone was growing with content, leaving nothing to drag). + local listParent = c + if n > 0 then + local listH = math.floor(clamp(m.h * (m.twoCol and 0.58 or 0.42), 200, 720)) + listParent = mk({ + parent = c, id = "slots-" .. version, width = "100%", height = listH, + overflowY = "scroll", hideScrollbars = true, + positioning = "flex", flexDirection = "vertical", gap = 10 * m.s, + padding = { right = 4 }, + }) + end for _, slot in ipairs(slots) do local selected = slot.id == active local rowKey = "slot-" .. version .. "-" .. slot.id local row = mk({ - parent = c, width = "100%", height = rowH, + parent = listParent, width = "100%", height = rowH, backgroundColor = C("rowBg", imp._hot[rowKey] and 0.85 or 0.6), border = 1, borderColor = selected and C("green", 0.9) or C("border", 0.25), @@ -1824,9 +1847,11 @@ function LauncherView.draw(imp) -- One scroll region per tab (stable id keeps its offset across frames and -- separate per tab), holding the panel, the updater banner and the footer. + -- flexShrink/minHeight keep this viewport-sized so overflowY can scroll. local page = mk({ parent = root, id = "page-" .. imp.tab, - width = "100%", flex = 1, overflowY = "scroll", hideScrollbars = true, + width = "100%", flex = 1, flexShrink = 1, minHeight = 0, + overflowY = "scroll", hideScrollbars = true, positioning = "flex", flexDirection = "vertical", gap = 12 * m.s, padding = { left = m.pad, right = m.pad + m.gutter, diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 064b86fe..9de4f271 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -240,6 +240,14 @@ end -- and the zone lists are exactly the ones the states returned. local trueColorRects = { ui = {}, world = {} } local currentPass = nil +-- Horizontal shift applied to UI-pass marks. A wide battle keeps its 304px +-- surface through every classic state it opens, and Game:draw centres each +-- of those with a translate while centerClassicZones shifts their zone list +-- by the same amount. A rect reported from inside that translate has to +-- move with it, or the unshaded re-blit lands 72 columns off and the pic +-- keeps the shade remap -- the party STATS screen in a wide battle (#637). +-- World-pass marks are already in world-canvas space and are never centred. +local markOffsetX = 0 -- which canvas the renderer is filling. nil for a pass that composites -- with no zone list of its own (tilt's upright billboards carry their own @@ -252,11 +260,20 @@ function PaletteFX.clearTrueColor() for _, rects in pairs(trueColorRects) do for i = #rects, 1, -1 do rects[i] = nil end end + markOffsetX = 0 +end + +-- Game:draw declares the translate it is drawing a classic state under, so +-- that state's marks land where its pixels did. Cleared with the rects at +-- the top of every frame (Renderer:beginFrame). #637 +function PaletteFX.setMarkOffset(dx) + markOffsetX = tonumber(dx) or 0 end function PaletteFX.markTrueColor(x, y, w, h) local rects = currentPass and trueColorRects[currentPass] if not rects or w <= 0 or h <= 0 then return end + if currentPass == "ui" then x = x + markOffsetX end rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h } end diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 4f9380dc..9d815504 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -784,12 +784,17 @@ end -- The Viridian old man's catch tutorial (scripts/ViridianCity.asm -- BATTLE_TYPE_OLD_MAN): a demo wild battle where the old man throws -- one POKé BALL; nothing is kept. -function Commands.old_man_demo(ctx) +-- `outcome` == "fail" is Yellow's initial training: ItemUseBall's +-- .oldManBattle branch reads EVENT_INITIAL_CATCH_TRAINING and stores anim +-- data $63, so the ball shakes three times and breaks open (pokeyellow +-- engine/items/item_effects.asm). Red, Blue and Yellow's repeat "Watch +-- closely!" demo all catch, and all of them omit the argument (#636). +function Commands.old_man_demo(ctx, outcome) local BattleState = require("src.battle.BattleState") local runner = ctx.runner local om = ctx.game.data.field.oldManBattle or { species = "WEEDLE", level = 5 } local battle = BattleState.newWild(ctx.game, om.species, om.level) - battle:makeOldManDemo() + battle:makeOldManDemo(nil, outcome == "fail") battle.onFinish = function() runner:resume() end -- InitWildBattle calls DoBattleTransitionAndInitBattleVariables -- unconditionally (core.asm:6699) -- there is no BATTLE_TYPE_OLD_MAN diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index 68c71258..610df29f 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -73,8 +73,12 @@ function HallOfFame.new(game, onDone) self.timer = 0 self.phase = "mons" self.sprites = {} -- species -> image or false - self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath( - game.data, "front", { kind = "hof" })) + self.spriteTrueColor = {} -- species -> full-color art flag (#637) + local playerPath, playerTrueColor = + require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "hof" }) + self.playerPic = tryImage(playerPath) + self.playerTrueColor = self.playerPic and playerTrueColor or false self.scrollX = PIC_X self.showHofBanner = false self.fade = 0 @@ -113,10 +117,11 @@ end function HallOfFame:spriteFor(species) local cached = self.sprites[species] if cached == nil then - local path = require("src.pokemon.Sprites").path( + local path, trueColor = require("src.pokemon.Sprites").path( self.game.data, species, "front", { kind = "hof" }) cached = tryImage(path) or false self.sprites[species] = cached + self.spriteTrueColor[species] = cached and trueColor or false end return cached or nil end @@ -225,13 +230,18 @@ function HallOfFame:drawMonInfo(mon) love.graphics.setColor(0, 0, 0, 1) local name = mon.nickname or (def and def.name) or mon.species Font.draw(name, 1 * 8, 4 * 8) + -- HoFMonInfoText is placed at (2,6) with "next" separators, and PlaceNextChar + -- advances 2 * SCREEN_WIDTH per unless BIT_SINGLE_SPACED_LINES is set + -- (home/text.asm); nothing on the HoF path sets it, so the labels sit on rows + -- 6/8/10 lined up with their values, not 6/7/8 (#697). Font.draw(Strings("LEVEL/"), 2 * 8, 6 * 8) - Font.draw(Strings("TYPE1/"), 2 * 8, 7 * 8) + Font.draw(Strings("TYPE1/"), 2 * 8, 8 * 8) local t1 = def and def.types and def.types[1] local t2 = def and def.types and def.types[2] local dual = t2 and t2 ~= t1 if dual then - Font.draw(Strings("TYPE2/"), 2 * 8, 8 * 8) + -- EraseType2Text blanks 6 tiles at hl+$13 from (3,9), so TYPE2/ is at (2,10) + Font.draw(Strings("TYPE2/"), 2 * 8, 10 * 8) end -- PrintLevelCommon at (8,7): bare level digits (no tile here) Font.draw(tostring(mon.level), 8 * 8, 7 * 8) @@ -251,10 +261,20 @@ function HallOfFame:drawHofBanner() Font.draw(Strings("HALL OF FAME"), 4 * 8, 15 * 8) end -function HallOfFame:drawPic(img) +function HallOfFame:drawPic(img, trueColor) if not img then return end love.graphics.setColor(1, 1, 1, 1) - love.graphics.draw(img, self.scrollX or PIC_X, PIC_Y) + local x = self.scrollX or PIC_X + love.graphics.draw(img, x, PIC_Y) + -- SET_PAL_POKEMON_WHOLE_SCREEN (HoFShowMonOrPlayer) colors the WHOLE + -- screen in the mon's palette, so sgbPalettes above hands the blit a + -- whole-canvas zone -- and a full-color pic has to sit that remap out. + -- Report the rect the pic covers for the unshaded pass, the way + -- SummaryMenu does for the status screen pic (#637; #430). + if trueColor then + require("src.render.PaletteFX").markTrueColor(x, PIC_Y, + img:getDimensions()) + end end -- HoFDisplayPlayerStats boxes + labels (player pic already on the right) @@ -284,7 +304,8 @@ function HallOfFame:draw() if self.phase == "mons" or self.phase == "fade" then local mon = self.game.save.party[self.index] if mon then - self:drawPic(self:spriteFor(mon.species)) + self:drawPic(self:spriteFor(mon.species), + self.spriteTrueColor[mon.species]) if self.scrollX >= PIC_X then self:drawMonInfo(mon) if self.showHofBanner then @@ -297,13 +318,13 @@ function HallOfFame:draw() love.graphics.rectangle("fill", 0, 0, 160, 144) end elseif self.phase == "player" then - self:drawPic(self.playerPic) + self:drawPic(self.playerPic, self.playerTrueColor) elseif self.phase == "player_stats" then - self:drawPic(self.playerPic) + self:drawPic(self.playerPic, self.playerTrueColor) self:drawPlayerStats() elseif self.phase == "player_dex" or self.phase == "player_rating" then -- the TextBox chain draws the dex texts over the stat boxes - self:drawPic(self.playerPic) + self:drawPic(self.playerPic, self.playerTrueColor) self:drawPlayerStats() end diff --git a/src/ui/PokedexMenu.lua b/src/ui/PokedexMenu.lua index 656b890c..e25a7854 100644 --- a/src/ui/PokedexMenu.lua +++ b/src/ui/PokedexMenu.lua @@ -47,7 +47,14 @@ function PokedexMenu.new(game, opts) end end local list = ListMenu.new(game, "POKéDEX", items, { - footer = Strings("SEEN %d OWNED %d", seen, owned), + -- SEEN / OWN in the original's fixed three-digit field: engine/menus/ + -- pokedex.asm HandlePokedexListMenu prints both counts with + -- `lb bc, 1, 3` (hlcoord 16,3 and 16,6), labelled by PokedexSeenText + -- and PokedexOwnText ("OWN", not "OWNED"). The width is load bearing + -- here: a bare ListMenu footer goes through the 18-column text wrap, + -- so the old 19-glyph "SEEN 100 OWNED 100" split in two and its first + -- half landed on the list's last row at y=120 (#639). + footer = Strings("SEEN %3d OWN %3d", seen, owned), pageJump = true, -- Left/Right page jumps like the original onCancel = opts.onCancel, -- B returns to the start menu when opened from it onChoose = function(item, dexList) diff --git a/src/world/FieldDefaults.lua b/src/world/FieldDefaults.lua index 781495e8..4a8ede3c 100644 --- a/src/world/FieldDefaults.lua +++ b/src/world/FieldDefaults.lua @@ -155,6 +155,14 @@ FieldDefaults.FIELD = { "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" } }, }, }, + -- Floors whose door callback a version does not have. Yellow dropped + -- RocketHideoutB4FDoorCallbackScript entirely (pokeyellow + -- scripts/RocketHideoutB4F.asm goes straight to EnableAutoTextBoxDrawing) + -- and its .blk ships the same open $0e doorway, so B4F's lift gate is + -- never barred there. Every manifest still carries the row above, so + -- this is what stops a Yellow cache walling Giovanni off behind two + -- guard flags Jessie & James never set (#650). + skipMaps = { yellow = { ROCKET_HIDEOUT_B4F = true } }, }, -- VermilionGymSetDoorTile opens the motorized door once both locks are hit hiddenExtras = { diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d2325c6d..ff2b9f58 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -8,6 +8,7 @@ local Camera = require("src.render.Camera") local Collision = require("src.world.Collision") local Encounter = require("src.world.Encounter") local FieldDefaults = require("src.world.FieldDefaults") +local GameVersion = require("src.core.GameVersion") local Logger = require("src.core.Logger") local Map = require("src.world.Map") local MapLoader = require("src.world.MapLoader") @@ -224,6 +225,13 @@ function OverworldState:stampClosedDoors() "closedDoors") local floorDoors = self.map and closedDoors and closedDoors[self.map.id] if not floorDoors then return end + -- ...and floors the running version has no callback for: Yellow's B4F + -- lift gate stands open from the first visit, since Jessie & James take + -- the two guard slots there and set neither guard flag (#650) + local skipMaps = FieldDefaults.fieldValue(Game.data, "cardKeyDoors", + "skipMaps") + local skipped = skipMaps and skipMaps[GameVersion.get()] + if skipped and skipped[self.map.id] then return end local stamped, unlocked = false, false for _, door in ipairs(floorDoors) do local open diff --git a/tests/drivers/game_corner_bug624_test.lua b/tests/drivers/game_corner_bug624_test.lua new file mode 100644 index 00000000..7254cb94 --- /dev/null +++ b/tests/drivers/game_corner_bug624_test.lua @@ -0,0 +1,267 @@ +-- Eyes on the Celadon Game Corner counters: the coin clerk's MONEY/COIN +-- windows and his question staying up under YES/NO (#624), then a prize +-- counter's three-prize window and its confirmation (#623). +-- Oracle: scripts/GameCorner.asm GameCornerClerk1Text + GameCornerDrawCoinBox, +-- engine/events/prize_menu.asm CeladonPrizeMenu / HandlePrizeChoice. +-- No POKEPORT_SPEED here: it scales only the logic clock while audio runs on +-- its own accumulator, so the menu beeps drift out of the presses. +-- POKEPORT_DRIVER=tests/drivers/game_corner_bug624_test.lua POKEPORT_IDENTITY=bug624 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local ChoiceBox = require("src.ui.ChoiceBox") + local ListMenu = require("src.ui.ListMenu") + local TextBox = require("src.render.TextBox") + local mapScripts = require("data.scripts.init") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local ok = true + + local function check(label, pass) + U.log(pass and "PASS" or "FAIL", label) + ok = ok and pass and true or false + return pass + end + + -- Read a page, press A, read the next one: tapping blind at a fixed rate + -- drops presses, because a TextBox only takes A once it has finished + -- typing (self.waiting). Runs until `pred` holds or the frames run out. + local function mashUntil(pred, frames) + for _ = 1, frames or 900 do + if pred() then return true end + local top = game.stack:top() + if top and (top.waiting or top.done + or getmetatable(top) == ChoiceBox) then + U.tap(game, "a") + else + U.wait(1) + end + end + return pred() + end + + local function topIsChoice() + return getmetatable(game.stack:top()) == ChoiceBox + end + + -- ---------------------------------------------------------------- wiring + -- a renamed text id, a dropped vendor binding and an unresolvable string + -- all look exactly like the bug from the outside: nothing on screen + local clerk = mapScripts.talkScript("GAME_CORNER", "TEXT_GAMECORNER_CLERK1") + check("the coin clerk has a hand-ported handler", type(clerk) == "function") + + local vendors = {} + for i = 1, 3 do + vendors[i] = mapScripts.talkScript("GAME_CORNER_PRIZE_ROOM", + "TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_" .. i) + end + check("all three prize counters have handlers", + type(vendors[1]) == "function" and type(vendors[2]) == "function" + and type(vendors[3]) == "function") + -- GetPrizeMenuId indexes PrizeDifferentMenuPtrs off the vendor's text id, + -- so the three counters must not share one closure (#623) + check("each counter is its own window, not one shared list", + vendors[1] ~= vendors[2] and vendors[2] ~= vendors[3] + and vendors[1] ~= vendors[3]) + + local t = game.data.text + local NEEDED = { + "_GameCornerClerk1DoYouNeedSomeGameCoinsText", + "_ExchangeCoinsForPrizesText", "_SoYouWantPrizeText", + "_SorryNeedMoreCoinsText", "_OopsYouDontHaveEnoughRoomText", + "_OhFineThenText", + } + local missing = {} + for _, key in ipairs(NEEDED) do + if type(t[key]) ~= "string" or t[key] == "" then + missing[#missing + 1] = key + end + end + check("every counter line resolves out of the cache (no silent fallback): " + .. (#missing == 0 and "all present" or table.concat(missing, ", ")), + #missing == 0) + + local vol = game.save.options and game.save.options.sfxVol + if vol == 0 then + U.log("sfx volume is 0, so the menu beeps under all this are muted.") + end + + -- --------------------------------------------------------------- wallet + -- enough of both to reach every branch: ¥1000 buys coins, 500 coins buys + -- the cheapest mon on vendor 1 (ABRA, 180) + game.save.money = 3000 + game.save.coins = 500 + game.save.inventory.COIN_CASE = 1 + + -- ------------------------------------------------------------ the clerk + -- pokered data/maps/objects/GameCorner.asm: GAMECORNER_CLERK1 stands at + -- (5, 6) facing DOWN, so the approach is the cell below him. + local function clerkNpc() + for _, n in ipairs((game.overworld or {}).npcs or {}) do + if n.def and n.def.name == "GAMECORNER_CLERK1" then return n end + end + return nil + end + + local function facingClerk() + local ow = game.overworld + local man = ow and clerkNpc() + if not man then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == man + end + + U.teleport(game, "GAME_CORNER", 5, 7, "up") + U.wait(10) + local man = clerkNpc() + check("the clerk object loaded on GAME_CORNER", man ~= nil) + if man and not facingClerk() then + -- a map edit moved him: take any free walkable neighbour instead of + -- talking to a wall. {dx, dy, facing} is the offset from the clerk to + -- the stand cell plus the way to look back at him. + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + local ow = game.overworld + for _, s in ipairs(sides) do + local cx, cy = man.cellX + s[1], man.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("(5, 7) is not against the clerk any more, standing on", + cx, cy, "facing", s[3]) + U.teleport(game, "GAME_CORNER", cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("standing at the coin counter", facingClerk()) + + U.tap(game, "a") + U.wait(20) + -- GameCornerDrawCoinBox is a plain TextBoxBorder with no input of its own: + -- it has to sit under the dialogue for the whole exchange, i.e. the state + -- pushed straight on top of the overworld draws and never updates (#624) + local coinBox = game.stack.states[2] + check("a draw-only MONEY/COIN window is under the conversation", + game.stack.states[1] == game.overworld and type(coinBox) == "table" + and coinBox.draw ~= nil and coinBox.update == nil) + + -- read through the offer the way a player does; it runs three pages + check("the offer ends in a YES/NO box", mashUntil(topIsChoice, 900)) + -- the question used to close before the prompt opened; opts.choice keeps + -- the box that asked it on screen underneath (#624) + local under = game.stack.states[#game.stack.states - 1] + check("the question is still on screen under YES/NO", + getmetatable(under) == TextBox) + check("captured the coin counter", + U.shot(game, SHOT_DIR .. "/bug624_coin_clerk.png")) + + -- answer NO and let the goodbye line close itself out + U.tap(game, "down") + U.wait(6) + mashUntil(function() return game.stack:top() == game.overworld end, 600) + check("declining puts the coin window away with the dialogue", + game.stack:top() == game.overworld and game.save.money == 3000) + + -- ------------------------------------------------------- a prize window + -- pokered data/maps/objects/GameCornerPrizeRoom.asm: PRIZE_VENDOR_1 is a + -- bg event at (2, 2), read by facing up from the tile below it. + local SIGN = "TEXT_GAMECORNERPRIZEROOM_PRIZE_VENDOR_1" + local function facingVendor() + local ow = game.overworld + if not ow then return false end + local fx, fy = ow.player:facingCell() + local sign = ow.map:signAtCell(fx, fy) + return sign ~= nil and sign.text == SIGN + end + + U.teleport(game, "GAME_CORNER_PRIZE_ROOM", 2, 3, "up") + U.wait(10) + if not facingVendor() then + local ow = game.overworld + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = 2 + s[1], 2 + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("(2, 3) no longer reads the first counter, standing on", + cx, cy, "facing", s[3]) + U.teleport(game, "GAME_CORNER_PRIZE_ROOM", cx, cy, s[3]) + U.wait(10) + break + end + end + end + check("standing at the first prize counter", facingVendor()) + + U.tap(game, "a") + U.wait(20) + local function listUp() + return getmetatable(game.stack:top()) == ListMenu + end + mashUntil(listUp, 600) + local list = listUp() and game.stack:top() or nil + check("the exchange line opens a prize list", list ~= nil) + + if list then + -- wMaxMenuItem is 3: this counter's three prizes and NO THANKS, never + -- the whole catalogue (#623) + local rows = list.items or {} + local names = {} + for _, item in ipairs(rows) do + names[#names + 1] = item.label .. + (item.right and (" " .. item.right) or "") + end + U.log("counter 1 offers:", table.concat(names, ", ")) + check("four rows: three prizes and NO THANKS", #rows == 4) + check("the last row is the NO THANKS exit", + rows[4] ~= nil and rows[4].label == "NO THANKS" + and rows[4].value == nil) + local priced = true + for i = 1, math.min(3, #rows) do + if not (rows[i].value and tonumber(rows[i].right)) then priced = false end + end + check("each prize names a real species or TM and a coin price", priced) + check("captured the prize list", + U.shot(game, SHOT_DIR .. "/bug623_prize_list.png")) + + -- take the top prize so the confirmation comes up + U.tap(game, "a") + U.wait(12) + check("picking a prize asks before it takes the coins", + mashUntil(topIsChoice, 600)) + local ask = game.stack.states[#game.stack.states - 1] + local said = "" + if getmetatable(ask) == TextBox then + for _, page in ipairs(ask.pages or {}) do + for _, l in ipairs(page) do said = said .. " " .. l end + end + end + U.log("it asks:", (said:gsub("^%s+", ""))) + check("the question names the prize, not {RAM:wNameBuffer}", + said:find("wNameBuffer", 1, true) == nil + and rows[1] ~= nil and said:find(rows[1].label:match("^%S+"), 1, true) + ~= nil) + check("captured the confirmation", + U.shot(game, SHOT_DIR .. "/bug623_prize_confirm.png")) + end + + U.log(ok and "checks are green, the screen is worth looking at." + or "something above says FAIL, do not trust what is on screen.") + U.log("shots are in " .. SHOT_DIR .. ".") + U.log("") + U.log("on screen is counter 1's confirmation (#623): three prizes and NO") + U.log("THANKS, and a YES/NO over \"So, you want ABRA?\" with nothing bought") + U.log("yet. Answer either way and the conversation ends, then talk to the") + U.log("counters again; each of the three sells its own window.") + U.log("the clerk shot (#624) is the other half: the question still typed") + U.log("out under the YES/NO, MONEY ¥3000 and COIN 500 in the top-right") + U.log("window. the near-miss to look for is that window going stale, still") + U.log("reading ¥3000 after the 50 coins are bought.") + U.log("the prize list is still full-screen and the YES/NO lands on top of") + U.log("the third row. the small windowed menu half of #623 was left alone.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua new file mode 100644 index 00000000..765c4fc2 --- /dev/null +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -0,0 +1,294 @@ +-- Driver: the Hall of Fame induction, end to end (#704, #697, #637). +-- Runs the tail of ChampionsRoomRivalDefeatedScript (scripts/ChampionsRoom.asm) +-- from just after the rival battle, so the walk out after Oak, the LEVEL/TYPE +-- box (engine/movie/hall_of_fame.asm HoFDisplayMonInfo) and the pic's true +-- color exemption all play at real speed. Do NOT add POKEPORT_SPEED: it +-- scales the logic clock only, and the cries here run off the audio clock. +-- POKEPORT_DRIVER=tests/drivers/hall_of_fame_bug704_test.lua \ +-- POKEPORT_IDENTITY=hof704 POKEPORT_TOUCH=0 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 Pokemon = require("src.pokemon.Pokemon") + local Sprites = require("src.pokemon.Sprites") + local PaletteFX = require("src.render.PaletteFX") + local Renderer = require("src.render.Renderer") + local BattleState = require("src.battle.BattleState") + local SummaryMenu = require("src.ui.SummaryMenu") + local Font = require("src.render.Font") + local HallOfFame = require("src.ui.HallOfFame") + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- HoFLoadMonPlayerPicTileIDs rests the front pic at hlcoord 12,5 + local PIC_X, PIC_Y = 96, 40 + + -- --------------------------------------------------------------- + -- setup + -- --------------------------------------------------------------- + local options = game.save.options + options.battleLayout = "wide" -- #637 is reported with BATTLE LAYOUT = WIDE + -- CHARIZARD is dual type (FIRE/FLYING) so TYPE2/ is drawn; SNORLAX is + -- single type so the run also shows the row EraseType2Text blanks. + local SPECIES = { "CHARIZARD", "SNORLAX", "PIKACHU" } + game.save.party = {} + for i, name in ipairs(SPECIES) do + game.save.party[i] = Pokemon.new(game.data, name, 100 - (i - 1) * 27) + end + game.save.player.name = "BRYAN" + game.save.money = 54321 + game.save.playTime = 9 * 3600 + 42 * 60 + -- DisplayDexRating tiers off owned; give it something past the first row + game.save.pokedex = game.save.pokedex or { seen = {}, owned = {} } + for i = 1, 60 do + game.save.pokedex.seen[i] = true + if i <= 47 then game.save.pokedex.owned[i] = true end + end + + -- A stock ROM import carries no full-color art, so def.trueColor is false + -- for every species and the #637 exemption never engages. Flip it on for + -- this party the way an art-pack mod does (mods/examples/example_shiny_ + -- palette patches the same field), or there is nothing to look at. + for _, name in ipairs(SPECIES) do + local def = game.data.pokemon[name] + if def then def.trueColor = true end + end + + if (options.sfxVol or 0) == 0 then + U.log("WARNING sfxVol is 0: the cries are muted, so the run is silent") + end + if (options.musicVol or 0) == 0 then + U.log("WARNING musicVol is 0: Music_HallOfFame will not be audible") + end + U.log("colors option is", tostring(options.colors), + "-- the pic exemption only shows while the palette shader is on") + + -- --------------------------------------------------------------- + -- machine checks: everything an eye cannot tell from the bug + -- --------------------------------------------------------------- + for _, name in ipairs(SPECIES) do + local path, trueColor = Sprites.path(game.data, name, "front", + { kind = "hof" }) + check(name .. " has a front pic for the hof screen", + type(path) == "string" and path ~= "") + check(name .. " reports the full-color flag through Sprites.path", + trueColor == true) + end + + -- #704: the walk-out rows. Read the live registry rather than the module, + -- so a mod's map_scripts contribution is what gets inspected. + local mapScripts = require("data.scripts.init") + local champ = mapScripts.get("CHAMPIONS_ROOM") + local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL + check("CHAMPIONS_ROOM keeps its rival script", type(rows) == "table") + rows = rows or {} + + local iWalk, iWarp + for i, row in ipairs(rows) do + if row[1] == "move_player" and row[2] == "up" and not iWarp then + iWalk = i + elseif row[1] == "warp" and row[2] == "HALL_OF_FAME" then + iWarp = iWarp or i + end + end + check("the script walks the player up before it warps (#704)", + iWalk ~= nil and iWarp ~= nil and iWalk < iWarp) + + -- #637, the other half: a classic state drawn inside a wide battle reports + -- its rects in 160px coordinates, and Game:draw shifts them with the + -- translate it centred the state under. + check("PaletteFX takes a mark offset", type(PaletteFX.setMarkOffset) == "function") + if type(PaletteFX.setMarkOffset) == "function" then + PaletteFX.setPass("ui") + PaletteFX.setMarkOffset(72) -- (304 - 160) / 2 + PaletteFX.markTrueColor(8, 24, 56, 56) + local probe = PaletteFX.trueColorRects("ui") + local last = probe[#probe] + check("a ui mark moves with the centring translate", + last ~= nil and last.x == 80) + PaletteFX.setMarkOffset(0) + PaletteFX.clearTrueColor() + end + + -- --------------------------------------------------------------- + -- the wide-battle STATS screen (#637, second screen in the issue) + -- --------------------------------------------------------------- + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(30) + local battle = BattleState.newWild(game, "PIDGEY", 3, { onFinish = function() end }) + game.overworld:pushBattle(battle) + U.wait(300) + check("the wide battle took the 304x144 surface", + Renderer.uiWidth == 304 and Renderer.uiHeight == 144) + game.stack:push(SummaryMenu.new(game, game.save.party[1])) + U.wait(8) + check("stats screenshot", U.shot(game, DIR .. "/hof637_wide_stats.png")) + local statRect + for _, r in ipairs(PaletteFX.trueColorRects("ui")) do + if r.colors == false and r.w > 32 then statRect = r end + end + -- StatusScreen draws the pic at x = 8; centred in the wide surface that is 80 + check("the STATS pic is exempted at its centred x (#637)", + statRect ~= nil and statRect.x == 80) + game.stack:pop() -- summary + game.stack:pop() -- battle + U.wait(20) + check("the Game Boy surface came back", + Renderer.uiWidth == 160 and Renderer.uiHeight == 144) + + -- --------------------------------------------------------------- + -- the champion's room walk-out + -- --------------------------------------------------------------- + -- pokered data/maps/objects/ChampionsRoom.asm: the rival stands at (4,2), + -- both HALL_OF_FAME warps are on row 0, and ChampionsRoomPlayerEntersScript + -- leaves the player at (4,3). Start there, facing the rival. + local STAND = { x = 4, y = 3 } + U.teleport(game, "CHAMPIONS_ROOM", STAND.x, STAND.y, "up") + U.wait(20) + local ow = game.overworld + local rival + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end + end + check("the rival object is on the map", rival ~= nil) + if ow.player.cellY ~= STAND.y then + -- a map edit moved the entrance walk's landing cell: stand anywhere the + -- warp row is still straight above, rather than face a wall + U.log("player did not land on (4,3); it is at", + ow.player.cellX, ow.player.cellY) + end + + -- Run the script from the row after the rival battle, so no one has to win + -- OPP_RIVAL3 to see the cutscene. Rows before that point are the only ones + -- carrying jump targets, so the slice needs no reindexing -- assert that. + local from + for i, row in ipairs(rows) do + if row[1] == "show_text" + and row[2] == "_ChampionsRoomRivalAfterBattleText" then + from = i + break + end + end + check("found the post-battle row to start from", from ~= nil) + local slice, jumpy = {}, false + for i = from or 1, #rows do + local row = rows[i] + if row[1] == "jump" or row[1] == "jump_if_true" + or row[1] == "jump_if_false" then + jumpy = true + end + slice[#slice + 1] = row + end + check("the tail of the script has no jump targets to reindex", not jumpy) + + if failures > 0 then + U.log("stopping before the cutscene:", failures, + "check(s) failed above, so what you would see means nothing") + while true do coroutine.yield() end + end + + ow:queueScript(slice, { npc = rival }) + + local startY = ow.player.cellY + local minY, walkShot = startY, false + local hof + for i = 1, 5000 do + local top = game.stack:top() + if getmetatable(top) == HallOfFame or (top and top.drawMonInfo) then + hof = top + break + end + local w = game.overworld + if w and w.map and w.map.id == "CHAMPIONS_ROOM" then + local y = w.player.cellY + if y < minY then minY = y end + if y <= 2 and not walkShot then + walkShot = U.shot(game, DIR .. "/hof704_follows_oak.png") + end + end + if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + check("the player walked out of the room before the warp (#704)", + minY < startY) + check("walk-out screenshot", walkShot) + check("the induction started", hof ~= nil) + if not hof then + while true do coroutine.yield() end + end + + -- --------------------------------------------------------------- + -- the induction screen + -- --------------------------------------------------------------- + -- mid scroll: .ScrollPic nudges hSCX 4px a frame, and the exemption has to + -- travel with the pic instead of sitting at its resting column (#637) + for _ = 1, 200 do + if (hof.scrollX or PIC_X) > 8 then break end + U.wait(1) + end + check("scrolling-in screenshot", U.shot(game, DIR .. "/hof637_scroll.png")) + + for _ = 1, 300 do + if (hof.scrollX or 0) >= PIC_X and hof.phase == "mons" then break end + U.wait(1) + end + + -- read back where the labels actually land: on screen a row collision and + -- a missing label look the same once the box is drawn over them + local realDraw = Font.draw + local seen = {} + Font.draw = function(text, x, y, ...) + seen[#seen + 1] = { tostring(text), x, y } + return realDraw(text, x, y, ...) + end + U.wait(4) + Font.draw = realDraw + + local function rowOf(needle) + for _, d in ipairs(seen) do + if d[1]:find(needle, 1, true) then return d[3] end + end + return nil + end + local yLevel, yLevelVal = rowOf("LEVEL"), rowOf("100") + local yType1, yType1Val = rowOf("TYPE1"), rowOf("FIRE") + local yType2, yType2Val = rowOf("TYPE2"), rowOf("FLYING") + U.log("label rows:", tostring(yLevel), tostring(yType1), tostring(yType2), + "value rows:", tostring(yLevelVal), tostring(yType1Val), + tostring(yType2Val)) + check("all six LEVEL/TYPE cells were drawn", + yLevel and yLevelVal and yType1 and yType1Val and yType2 and yType2Val) + check("no label shares a row with a value (#697)", + yLevel ~= yLevelVal and yType1 ~= yType1Val and yType2 ~= yType2Val + and yType1 ~= yLevelVal and yType2 ~= yType1Val) + check("the labels sit on rows 6/8/10 like PlaceNextChar's double spacing", + yLevel == 6 * 8 and yType1 == 8 * 8 and yType2 == 10 * 8) + + local picRect + for _, r in ipairs(PaletteFX.trueColorRects("ui")) do + if r.colors == false and r.y == PIC_Y then picRect = r end + end + check("the settled pic is exempted from the whole-screen palette (#637)", + picRect ~= nil and picRect.x == PIC_X) + check("settled screenshot", U.shot(game, DIR .. "/hof697_mon_info.png")) + + U.log(failures == 0 and "all checks passed" or ("FAILURES: " .. failures)) + U.log("walked you into the hall of fame (#704, #697, #637).") + U.log("the player should have followed oak up and out of the champion's") + U.log("room before the fade, the TYPE1/TYPE2 rows should sit under LEVEL") + U.log("rather than on top of the level and type values, and each pic") + U.log("should keep its own colors while the rest of the screen carries") + U.log("the mon's tint, from the first scrolled-in pixel to the fade.") + U.log("the near miss to watch for: the pic goes right but a column at its") + U.log("left edge stays tinted while it slides in.") + U.log("the other two party members and the player stats page follow on") + U.log("their own; re-run the command to watch the whole thing again.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/oldman_yellow_bug636_test.lua b/tests/drivers/oldman_yellow_bug636_test.lua new file mode 100644 index 00000000..ddf36ede --- /dev/null +++ b/tests/drivers/oldman_yellow_bug636_test.lua @@ -0,0 +1,238 @@ +-- The Yellow Viridian catch tutorial has to FAIL its throw (#636). +-- pokeyellow scripts/ViridianCity.asm:168 (ViridianCityCheckWaitingOldMan) +-- starts the demo from (19,9); ItemUseBall's .oldManBattle branch reads +-- EVENT_INITIAL_CATCH_TRAINING and stores anim data $63, so the ball shakes +-- three times and breaks open, and only then does the losing-my-touch line +-- make sense. No POKEPORT_SPEED here: the shakes and the ball sounds run on +-- the real-time audio clock and fast-forward pulls them apart. +-- POKEPORT_DRIVER=tests/drivers/oldman_yellow_bug636_test.lua \ +-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug636 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local mapScripts = require("data.scripts.init") + local GameVersion = require("src.core.GameVersion") + local BattleState = require("src.battle.BattleState") + local TextBox = require("src.render.TextBox") + local Pokemon = require("src.pokemon.Pokemon") + + local MAP = "VIRIDIAN_CITY" + local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2" + local TEXT = "TEXT_VIRIDIANCITY_OLD_MAN2" + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + -- ../pokeyellow/data/maps/objects/ViridianCity.asm:37 puts OLD_MAN2 on + -- (18,9); the trigger cell is the gap east of him, (19,9). Each candidate + -- is {stand x, stand y, direction that walks into (19,9)}. + local TRIGGER = { x = 19, y = 9 } + local APPROACHES = { + { 19, 10, "up" }, { 19, 8, "down" }, { 20, 9, "left" }, + } + + local fails = 0 + local function check(label, ok) + if not ok then fails = fails + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function flat(s) return (tostring(s):gsub("[\n\v\f]", " / ")) end + + -- ---- what the eye cannot see ------------------------------------------- + check("booted on Yellow (the fail branch is Yellow-only)", + GameVersion.get() == "yellow") + + local hooks = mapScripts.get(MAP) + check(MAP .. " has a map-script table", type(hooks) == "table") + check("the (19,9) step hook survived the base-script merge", + type(hooks) == "table" and type(hooks.onStep) == "function") + check(TEXT .. " has a talk handler", + type(hooks) == "table" and type(hooks.talk) == "table" + and type(hooks.talk[TEXT]) == "function") + + -- a species constant that no longer resolves builds a nameless battler and + -- looks exactly like the demo never starting + local om = game.data.field.oldManBattle + check("field.oldManBattle exists", type(om) == "table") + check("it is Yellow's RATTATA, not Red's WEEDLE", + type(om) == "table" and om.species == "RATTATA") + check("that species resolves in the pokemon table", + type(om) == "table" and game.data.pokemon[om.species] ~= nil) + + -- the flag itself, off a throwaway battle that is never pushed + local probe = BattleState.newWild(game, (om and om.species) or "RATTATA", + (om and om.level) or 5) + probe:makeOldManDemo(nil, true) + check("makeOldManDemo(name, true) sets demoFails", probe.demoFails == true) + probe:makeOldManDemo(nil, false) + check("and without it the demo still catches, for Red and the reruns", + probe.demoFails == false) + + local miss = game.data.text._ItemUseBallText04 + check("_ItemUseBallText04 (three shakes, then free) resolves", + type(miss) == "string" and miss ~= "") + local touch = game.data.text._ViridianCityOldManLosingMyTouchText + check("_ViridianCityOldManLosingMyTouchText resolves", + type(touch) == "string" and touch ~= "") + check("it really is the losing-my-touch line", + type(touch) == "string" and touch:find("losing", 1, true) ~= nil) + if type(miss) == "string" then U.log("breakout line reads:", flat(miss)) end + if type(touch) == "string" then U.log("old man then says:", flat(touch)) end + + local sfx = (game.save.options or {}).sfxVol + if sfx == 0 then + U.log("FAIL sfx volume is 0, so the ball wobbles and the break will be silent") + else + U.log("sfx volume is", tostring(sfx), "-- the ball should be audible") + end + + -- ---- put the tutorial back to its untouched state ----------------------- + game.save.party = { + Pokemon.new(game.data, "PIKACHU", 6), + } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_POKEDEX = true + game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = nil + game.save.objectToggles = {} + local ownedBefore = 0 + for _ in pairs((game.save.pokedex and game.save.pokedex.owned) or {}) do + ownedBefore = ownedBefore + 1 + end + local partyBefore = #game.save.party + + local function npcNamed(name) + local ow = game.overworld + for _, n in ipairs((ow and ow.npcs) or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- walk into (19,9) from the first free neighbour, so a later map edit that + -- walls off the south approach degrades instead of pressing into a fence + local stand + U.teleport(game, MAP, APPROACHES[1][1], APPROACHES[1][2], "up") + U.wait(20) + local ow = game.overworld + check("the tutorial old man is on the map", npcNamed(OLD_MAN2) ~= nil) + for _, a in ipairs(APPROACHES) do + if ow and ow.map:isWalkableCell(a[1], a[2]) and not ow:npcAtCell(a[1], a[2]) then + stand = a + break + end + end + check(("a free cell next to (%d,%d) to walk in from") + :format(TRIGGER.x, TRIGGER.y), stand ~= nil) + stand = stand or APPROACHES[1] + if stand ~= APPROACHES[1] then + U.log(("south approach blocked, walking in from (%d,%d)"):format(stand[1], stand[2])) + U.teleport(game, MAP, stand[1], stand[2], stand[3]) + U.wait(15) + end + + -- ---- step on the trigger and let the demo run --------------------------- + local function topIsBattle() + local top = game.stack:top() + return (top and top.demo and top.demoName) and top or nil + end + + local battle + for _ = 1, 40 do + U.hold(game, stand[3], 6) + U.wait(4) + battle = topIsBattle() + if battle then break end + -- the apology speech comes first; page through it to reach the battle + if getmetatable(game.stack:top()) == TextBox then + U.tap(game, "a") + U.wait(6) + end + end + if not battle then + for _ = 1, 200 do + battle = topIsBattle() + if battle then break end + if getmetatable(game.stack:top()) == TextBox then U.tap(game, "a") end + U.wait(6) + end + end + check("stepping onto (19,9) opened the catch demo", battle ~= nil) + check("the demo is the failing one (#636)", + battle ~= nil and battle.demoFails == true) + if battle then + U.log("demo opponent is", tostring(battle.enemy and battle.enemy.name)) + end + + -- Advance only pages that have finished typing (msgPrompt / msgWaiting are + -- the same holds the player would clear by hand), so the toss animation and + -- its sounds play at their own speed. + local sawMiss, sawCaught, shot = false, false, false + if battle then + for _ = 1, 3000 do + if game.stack:top() ~= battle then break end + local cur = battle.current + local line = cur and cur.text + if type(line) == "string" then + if miss and line == miss then sawMiss = true end + if line:find("caught", 1, true) then sawCaught = true end + end + if battle.msgPrompt and sawMiss and not shot then + shot = U.shot(game, SHOT_DIR .. "/bug636_ball_broke_open.png") + if shot then U.log("captured", SHOT_DIR .. "/bug636_ball_broke_open.png") end + end + if battle.msgPrompt or battle.msgWaiting then + U.tap(game, "a") + end + U.wait(2) + end + end + check("the ball broke open and printed the three-shake line", sawMiss) + check("nothing was caught", not sawCaught) + + -- ---- the line that only follows a failed throw -------------------------- + local box + for _ = 1, 400 do + local top = game.stack:top() + if getmetatable(top) == TextBox then box = top break end + U.wait(4) + end + check("the old man speaks again after the battle", box ~= nil) + local shown = {} + for _, page in ipairs((box and box.pages) or {}) do + for _, l in ipairs(page) do shown[#shown + 1] = l end + end + local joined = table.concat(shown, " / ") + if box then U.log("his box reads:", joined) end + check("he says he must be losing his touch", + joined:find("losing", 1, true) ~= nil) + if box then + if U.shot(game, SHOT_DIR .. "/bug636_losing_my_touch.png") then + U.log("captured", SHOT_DIR .. "/bug636_losing_my_touch.png") + end + end + + local ownedAfter = 0 + for _ in pairs((game.save.pokedex and game.save.pokedex.owned) or {}) do + ownedAfter = ownedAfter + 1 + end + check("the demo added nothing to the party", #game.save.party == partyBefore) + check("and nothing to the dex's owned list", ownedAfter == ownedBefore) + + -- ---- rewind so it can be watched again ---------------------------------- + game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = nil + game.save.objectToggles = {} + U.teleport(game, MAP, stand[1], stand[2], stand[3]) + U.wait(15) + + if fails > 0 then + U.log(fails, "check(s) above say FAIL; the screen is not worth watching yet.") + end + U.log(("ran the tutorial once and rewound it: you are on (%d,%d), so") + :format(stand[1], stand[2])) + U.log("holding " .. stand[3] .. " into (19,9) starts the demo again. The ball") + U.log("should wobble three times and pop open, then he blames his touch. The") + U.log("near miss to watch for is three wobbles, the caught jingle, and the") + U.log("same line anyway.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/silph_giovanni_bug722_test.lua b/tests/drivers/silph_giovanni_bug722_test.lua new file mode 100644 index 00000000..ea5f9e5c --- /dev/null +++ b/tests/drivers/silph_giovanni_bug722_test.lua @@ -0,0 +1,279 @@ +-- Manual check on the Silph Co. Giovanni aftermath (#722). pokered +-- scripts/SilphCo11F.asm SilphCo11FGiovanniAfterBattleScript: the loss line, +-- then TEXT_SILPHCO11F_GIOVANNI_YOU_RUINED_OUR_PLANS, GBFadeOutToBlack, the +-- rockets leaving, Delay3, GBFadeInFromBlack. The order and the fade are the +-- whole report, so no POKEPORT_SPEED here -- it scales only the logic clock. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/silph_giovanni_bug722_test.lua POKEPORT_IDENTITY=bug722 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local BattleState = require("src.battle.BattleState") + local ScriptRunner = require("src.script.ScriptRunner") + local mapScripts = require("data.scripts.init") + local victories = require("data.scripts.victories") + + -- pokered data/maps/objects/SilphCo11F.asm: GIOVANNI (6,9), ROCKET1 (3,16), + -- ROCKET2 (15,9). SilphCo11FDefaultScript.PlayerCoordsArray is (6,13) and + -- (7,12); each is stepped onto from the cell directly below it, and the + -- trigger only reads the cell the player lands on. + local MAP = "SILPH_CO_11F" + local TRIGGERS = { { stand = { 7, 13 }, cell = { 7, 12 } }, + { stand = { 6, 14 }, cell = { 6, 13 } } } + local ELEVENTH = { "SILPHCO11F_GIOVANNI", "SILPHCO11F_ROCKET1", + "SILPHCO11F_ROCKET2" } + local SPEECH = "_SilphCo11FGiovanniYouRuinedOurPlansText" + local LOSS = "_SilphCo10FGiovanniILostAgainText" + -- SilphCo11FTeamRocketLeavesScript hides 31 toggles across 2F-11F; the + -- Saffron street half is M.SAFFRON_CITY.onEnter (data/scripts/story4.lua). + local HIDE_ROWS = 31 + + 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 + + local function spawned(name) + for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local function boxText(top) + local lines = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + return table.concat(lines, " ") + end + + local function topBox() + local top = game.stack:top() + if getmetatable(top) == TextBox then return top end + return nil + end + + -- ---- the half the eye cannot judge ------------------------------------- + -- A missing text key, a renamed command and a script that never gets queued + -- all look the same on screen: the battle ends and the rockets are gone. + local hooks = mapScripts.get(MAP) + check("SILPH_CO_11F still has the coordinate trigger", + hooks ~= nil and type(hooks.onStep) == "function") + + for _, key in ipairs({ SPEECH, LOSS }) do + local body = game.data.text[key] + check(key .. " is extracted", + type(body) == "string" and body ~= "") + end + local speech = game.data.text[SPEECH] + if type(speech) == "string" then + U.log(" the speech reads:", + (speech:gsub("\n", " "):gsub("\f", " "))) + end + + local reward = victories["OPP_GIOVANNI#2"] + check("OPP_GIOVANNI#2 still sets EVENT_BEAT_SILPH_CO_GIOVANNI", + reward ~= nil and reward.flag == "EVENT_BEAT_SILPH_CO_GIOVANNI") + check("and now carries the loss line he has no trainer header for (#722)", + reward ~= nil and reward.dialogue ~= nil and reward.dialogue[1] == LOSS) + + local vol = game.save.options and game.save.options.sfxVol + if (vol or 0) == 0 then + U.log("sfxVol is 0 -- the battle and its text will be silent, raise it in OPTION") + else + U.log("sfxVol", tostring(vol)) + end + + -- ---- put the fight back on the table ----------------------------------- + local function armFight() + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 70), + Pokemon.new(game.data, "SNORLAX", 70), + Pokemon.new(game.data, "LAPRAS", 70), + } + game.save.player.name = "bryan" + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI = nil + game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = nil + game.save.flags.EVENT_BEAT_SILPH_CO_11F_TRAINER_1 = nil + game.save.objectToggles = {} + game.save.defeatedTrainers = {} + end + armFight() + + -- ---- walk onto the trigger pad ----------------------------------------- + local function liveBattle() + for _, s in ipairs(game.stack.states or {}) do + if getmetatable(s) == BattleState then return s end + end + return nil + end + + local battle, cell + for i, t in ipairs(TRIGGERS) do + U.teleport(game, MAP, t.stand[1], t.stand[2], "up") + U.wait(10) + if i == 1 then + for _, name in ipairs(ELEVENTH) do + check(name .. " is on the floor before the fight", spawned(name) ~= nil) + end + U.shot(game, DIR .. "/bug722_0_before.png") + end + U.hold(game, "up", 24) + for _ = 1, 300 do + U.wait(1) + battle = liveBattle() + if battle then break end + -- his pre-battle line holds the overworld while he walks down + if topBox() then U.tap(game, "a") end + end + if battle then cell = t.cell break end + U.log(("the step up from (%d,%d) missed the trigger; trying the other pad") + :format(t.stand[1], t.stand[2])) + end + cell = cell or TRIGGERS[1].cell + check(("stepping onto (%d,%d) started GIOVANNI"):format(cell[1], cell[2]), + battle ~= nil) + if not battle then + U.log("nothing below ran: the trigger pads moved, check") + U.log("SilphCo11FDefaultScript.PlayerCoordsArray against M.SILPH_CO_11F.onStep") + while true do coroutine.yield() end + end + U.shot(game, DIR .. "/bug722_1_battle.png") + + -- ---- win it ------------------------------------------------------------ + for _ = 1, 1200 do + if battle.result then break end + U.tap(game, "a") + U.wait(4) + end + check("the battle was won", battle.result == "win") + if battle.result ~= "win" then + U.log("the party lost, so the aftermath never runs; nothing below applies") + while true do coroutine.yield() end + end + + -- The aftermath is queued, not run inline: the battle's own callbacks are + -- still unwinding when engageTrainer's onDone fires, so it has to wait for + -- an idle overworld frame (OverworldState:drainPendingScripts). + local queued + for _ = 1, 900 do + local ow = game.overworld + local pending = ow and ow.pendingScripts and ow.pendingScripts[1] + if pending then queued = pending.script break end + if game.stack:top() == ow and not (ow.runner and ow.runner:isRunning()) then + -- already drained: nothing left to inspect + break + end + U.tap(game, "a") + U.wait(4) + end + check("the win queues an aftermath script (#722)", type(queued) == "table") + if type(queued) == "table" then + local problems = ScriptRunner.validate(queued) + check("it validates: " .. (problems[1] or "no problems"), #problems == 0) + local first, second, last = queued[1], queued[2], queued[#queued] + check("row 1 is the Blast-it-all speech", + first and first[1] == "show_text" and first[2] == SPEECH) + check("row 2 fades out before anyone leaves", + second and second[1] == "fade" and second[2] == "out") + check("the last row fades back in", + last and last[1] == "fade" and last[2] == "in") + local hides, waits, sawWait = 0, 0, false + for _, row in ipairs(queued) do + if row[1] == "hide_object" then hides = hides + 1 end + if row[1] == "wait" then waits = waits + 1 sawWait = true end + end + check(("all %d rockets leave behind the fade (found %d)") + :format(HIDE_ROWS, hides), hides == HIDE_ROWS) + check("Delay3 is held between the hides and the fade in", + sawWait and waits >= 1) + end + + -- ---- the loss line, then the speech ------------------------------------ + local sawLoss, sawSpeech = false, false + for _ = 1, 600 do + local box = topBox() + if box then + local txt = boxText(box) + if txt:find("lost again", 1, true) then sawLoss = true end + if txt:find("Blast it all", 1, true) and not sawSpeech then + sawSpeech = true + U.wait(60) -- let the box finish typing before the capture + if not U.shot(game, DIR .. "/bug722_2_speech.png") then + U.log("the speech screenshot did not reach disk") + end + end + end + if sawSpeech and not topBox() then break end + U.tap(game, "a") + U.wait(6) + end + check("his \"Arrgh!! I lost again!?\" box came first", sawLoss) + check("then the \"Blast it all!\" speech played", sawSpeech) + + -- ---- the fade ---------------------------------------------------------- + local sawFade, shotFade = false, false + for _ = 1, 600 do + local ow = game.overworld + local overlay = ow and ow.fadeOverlay + if overlay then + sawFade = true + if (overlay.alpha or 0) > 0.85 and not shotFade then + shotFade = U.shot(game, DIR .. "/bug722_3_black.png") + end + end + if sawFade and shotFade and (not overlay or (overlay.alpha or 0) < 0.05) then + break + end + U.wait(1) + end + check("the screen faded to black over the departure", sawFade) + + for _ = 1, 240 do + local ow = game.overworld + if game.stack:top() == ow and not (ow.runner and ow.runner:isRunning()) then + break + end + U.wait(1) + end + U.shot(game, DIR .. "/bug722_4_after.png") + + for _, name in ipairs(ELEVENTH) do + check(name .. " left the floor", spawned(name) == nil) + end + check("EVENT_BEAT_SILPH_CO_GIOVANNI is set", + game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI == true) + local toggles = game.save.objectToggles[MAP] or {} + check("the 11F toggles were written to the save", + toggles.SILPHCO11F_GIOVANNI == false) + local twoF = game.save.objectToggles.SILPH_CO_2F or {} + check("the lower floors were cleared in the same pass", + twoF.SILPHCO2F_ROCKET1 == false) + + U.log(("%d passed, %d failed"):format(pass, fail)) + if fail > 0 then + U.log("something above failed, so what is on screen is not the fix") + end + + -- ---- hand off ---------------------------------------------------------- + -- re-arm and park the player one cell below the pad so the whole thing can + -- be watched again from a step and a mash + armFight() + U.teleport(game, MAP, TRIGGERS[1].stand[1], TRIGGERS[1].stand[2], "up") + U.wait(10) + + U.log("fought giovanni on silph 11F and watched the aftermath once (#722).") + U.log("walk up one cell and win again: he should say \"Arrgh!!\", then the") + U.log("whole \"Blast it all!\" speech, and only then should the screen fade") + U.log("out, the rockets vanish behind the black, and fade back in. the") + U.log("near miss to watch for is the room going quiet and empty with no") + U.log("speech, or the rockets popping out in plain sight before the fade.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/pokedex_counts_bug639.lua b/tests/engine/pokedex_counts_bug639.lua new file mode 100644 index 00000000..c0a32ae7 --- /dev/null +++ b/tests/engine/pokedex_counts_bug639.lua @@ -0,0 +1,90 @@ +-- The Pokédex seen/own footer survives three-digit counts (#639). +-- engine/menus/pokedex.asm HandlePokedexListMenu prints both counts into a +-- fixed three-digit field (`lb bc, 1, 3` at hlcoord 16,3 and 16,6) under +-- PokedexSeenText / PokedexOwnText, so the line never changes width as the +-- dex fills. The port built "SEEN %d OWNED %d", which is 19 glyphs once +-- either count reaches 100: one column over the text box, so ListMenu's +-- bare footer paginated it into two lines and drew the first at y=120, +-- on top of the list's last row. +-- ROM-free: the real PokedexMenu over a synthetic 151-entry dex. +-- luajit tests/engine/pokedex_counts_bug639.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local check, eq = T.check, T.eq +local Data = T.fixtures.load() + +local PokedexMenu = require("src.ui.PokedexMenu") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local TextBox = require("src.render.TextBox") + +-- A full Kanto numbering, which the fixture dataset deliberately does not +-- carry (tests/fixture_data/constants.lua caps dexSize at 3). Everything +-- the menu reads off a species is here: id, name, dex number. +local DEX_SIZE = 151 +local dexData = setmetatable({ + pokemon = {}, + constants = { dexSize = DEX_SIZE, dexDigits = 3 }, +}, { __index = Data }) +for n = 1, DEX_SIZE do + local id = ("DEXMON_%03d"):format(n) + dexData.pokemon[id] = { id = id, name = ("MON%03d"):format(n), dex = n } +end + +-- builds the dex list the way Screens does, with the first `ownedCount` +-- species owned and the next `seenOnly` species seen but not caught +local function footerFor(ownedCount, seenOnly) + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local save = SaveData.newGame() + save.pokedex = { seen = {}, owned = {} } + for n = 1, ownedCount do + local id = ("DEXMON_%03d"):format(n) + save.pokedex.seen[id] = true + save.pokedex.owned[id] = true + end + for n = ownedCount + 1, ownedCount + seenOnly do + save.pokedex.seen[("DEXMON_%03d"):format(n)] = true + end + local game = { data = dexData, save = save, stack = stack } + return PokedexMenu.new(game, {}).footer +end + +-- how many lines ListMenu's bare-footer branch would end up drawing: it +-- flattens every paginated line and, at two or more, starts them at y=120 +-- instead of y=136, which is the row the last list entry sits on +local function lines(text) + local flat = {} + for _, page in ipairs(TextBox.paginate(text)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + return flat +end + +-- --------------------------------------------------------- 100 or more +local hundred = footerFor(100, 5) +eq(hundred, "SEEN 105 OWN 100", "105 seen / 100 owned prints both counts") +eq(#lines(hundred), 1, "the three-digit footer still fits on one line") + +local full = footerFor(DEX_SIZE, 0) +eq(full, "SEEN 151 OWN 151", "a completed dex prints 151 twice") +eq(#lines(full), 1, "the widest possible footer still fits on one line") + +-- The witness for the bug: the string this replaced is one column over the +-- 18-column box, so it wrapped and the wrap is what collided with the list. +eq(#lines("SEEN 105 OWNED 100"), 2, + "the old OWNED wording is what pushed the footer onto a second line") + +-- --------------------------------------------------------- under 100 +-- The field is fixed width, so a small dex right-aligns into the same +-- columns rather than sliding left as it grows. +local small = footerFor(3, 6) +eq(small, "SEEN 9 OWN 3", "single digits keep the three-wide field") +eq(#lines(small), 1, "a small dex is one line too") +eq(#small, #hundred, "the footer is the same width empty or full") +check(small:find("OWN ") == hundred:find("OWN "), + "OWN starts in the same column at 3 caught and at 100") + +T.finish("pokedex_counts_bug639") diff --git a/tests/parity_game_corner_clerk_bug552.lua b/tests/parity_game_corner_clerk_bug552.lua index adbb45c7..b5ab73ce 100644 --- a/tests/parity_game_corner_clerk_bug552.lua +++ b/tests/parity_game_corner_clerk_bug552.lua @@ -50,6 +50,9 @@ TextBox.new = function(game, text, onDone, opts) end local sawChoice = false +-- what sat under the YES/NO box the first time it opened, and what sat at +-- the bottom of the stack for the whole conversation (#624) +local underChoice, coinBox = nil, nil -- One conversation, start to finish. `answer` is which row of the YES/NO -- box to take; the loop mashes A the way a player does and nudges the @@ -58,16 +61,21 @@ local function talk(save, answer) Game.save = save StateStack:init() shown, sawChoice = {}, false + underChoice, coinBox = nil, nil local done = false local ow = { map = { id = "GAME_CORNER", def = Data.maps.GAME_CORNER }, npcs = {}, entities = {} } yellow(Game, ow, { def = {} }, function() done = true end) + coinBox = StateStack.states[1] local moved = false for _ = 1, 2000 do local top = StateStack:top() if not top then break end if getmetatable(top) == ChoiceBox then + if not sawChoice then + underChoice = StateStack.states[#StateStack.states - 1] + end sawChoice = true if answer == "no" and not moved then moved = true @@ -108,7 +116,14 @@ do check(said("¥1000 for 50"), "the offer quotes ¥1000 for 50 coins") eq(save.money, 0, "¥1000 leaves the wallet") eq(save.coins, 50, "50 coins land in the case") - check(said("COINS: 50"), "the receipt prints the new coin total") + check(getmetatable(underChoice) == TextBox, + "the offer stays on screen under the YES/NO box (#624)") + check(coinBox and coinBox.draw and not coinBox.update, + "a draw-only MONEY/COIN box sits under the whole conversation," + .. " like GameCornerDrawCoinBox (#624)") + check(not said("COINS: 50"), + "the receipt is the plain thanks line: the new total shows in that" + .. " box, which the asm redraws after the sale (#624)") end -- ---------------------------------------------------------------- saying no diff --git a/tests/parity_hideout_gate.lua b/tests/parity_hideout_gate.lua index c82ba8f9..643152be 100644 --- a/tests/parity_hideout_gate.lua +++ b/tests/parity_hideout_gate.lua @@ -65,6 +65,18 @@ if b1 then eq(b1.event, B1F_GUARD, "B1F waits on the fifth grunt") end +-- Yellow ships no RocketHideoutB4FDoorCallbackScript (its +-- scripts/RocketHideoutB4F.asm calls EnableAutoTextBoxDrawing first thing) +-- and the same open .blk doorway, and Jessie & James hold the two guard +-- slots without setting either guard flag, so the B4F row must not be +-- stamped on a Yellow boot (#650). Read through fieldValue, which is how +-- a cache carrying closedDoors but no skipMaps still resolves it. +local skipMaps = FieldDefaults.fieldValue(Data, "cardKeyDoors", "skipMaps") +check(skipMaps and skipMaps.yellow and skipMaps.yellow[B4F], + "Yellow skips the B4F lift gate") +check(not (skipMaps and skipMaps.red), "Red keeps both gates") +check(not (skipMaps and skipMaps.blue), "Blue keeps both gates") + -- the events the rows name have to be the ones the guards actually set: -- interact() writes trainerHeader().event on a win eq(Data:trainerHeader("RocketHideoutB4F", 2).event, GUARD_0, diff --git a/tests/parity_yellow_hideout_gate.lua b/tests/parity_yellow_hideout_gate.lua new file mode 100644 index 00000000..5f1a4026 --- /dev/null +++ b/tests/parity_yellow_hideout_gate.lua @@ -0,0 +1,152 @@ +-- Parity (#650): Yellow's Rocket Hideout B4F lift gate is never barred. +-- +-- Oracle: pokeyellow scripts/RocketHideoutB4F.asm has no +-- RocketHideoutB4FDoorCallbackScript at all -- the map script header goes +-- straight to EnableAutoTextBoxDrawing -- and the floor's two guard slots +-- are Jessie & James (data/maps/objects/RocketHideoutB4F.asm, object_event +-- 24, 10, SPRITE_JESSIE), who set EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES. +-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 / _1 do not exist in Yellow, so the +-- Red/Blue closedDoors row from #372 stamped a $2d block over a doorway +-- nothing in the game could ever re-open: Giovanni was unreachable. +-- pokeyellow scripts/RocketHideoutB1F.asm still calls its own +-- RocketHideoutB1FDoorCallbackScript, so only B4F is skipped. +-- +-- The Red-imported cache is what this process has, and the two floors' +-- block layouts are the same in Yellow, so the version switch alone +-- reproduces the bug: what changes between the sections below is +-- GameVersion.get(), nothing else. +-- +-- Self-contained: `luajit tests/parity_yellow_hideout_gate.lua`; also +-- globbed by tests/run_tests.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.ROCKET_HIDEOUT_B4F) then Data:load() end +local S = require("tests.harness").suite("parity Yellow hideout gate (#650)") +local check, eq = S.check, S.eq + +local FieldDefaults = require("src.world.FieldDefaults") +local GameVersion = require("src.core.GameVersion") + +local B4F, B1F = "ROCKET_HIDEOUT_B4F", "ROCKET_HIDEOUT_B1F" +local GUARD_0 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0" +local GUARD_1 = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1" +local B1F_GUARD = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4" +-- the flag Yellow's pair actually writes (SetEvent at scripts/ +-- RocketHideoutB4F.asm:274); no closedDoors row names it, by design +local JESSIE_JAMES = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" + +-- ---- the skip row -------------------------------------------------------- +-- through fieldValue, so a cache that carries closedDoors from an older +-- manifest but no skipMaps still resolves the built-in table +local skipMaps = FieldDefaults.fieldValue(Data, "cardKeyDoors", "skipMaps") +check(skipMaps and skipMaps.yellow and skipMaps.yellow[B4F], + "Yellow skips the B4F lift gate") +check(not (skipMaps and skipMaps.yellow and skipMaps.yellow[B1F]), + "and keeps B1F, which Yellow still has a callback for") + +-- ---- the live floor, on a Yellow boot ------------------------------------ +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local Sound = require("src.core.Sound") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "CHARMANDER", 20) } + +local sfx = {} +local realPlay = Sound.play +Sound.play = function(_, id) sfx[#sfx + 1] = id end +local function goInsides() + local n = 0 + for _, id in ipairs(sfx) do if id == "Go_Inside" then n = n + 1 end end + return n +end + +-- Map:setBlock writes through to the shared Data record every later suite +-- in this process reads, so both doorways are put back at the bottom +local restore = {} +local function remember(mapId, bx, by) + local def = Data.maps[mapId] + restore[#restore + 1] = { mapId, bx, by, def.blocks[by * def.width + bx + 1] } +end +remember(B4F, 12, 5) +remember(B1F, 12, 8) + +local function arrive(mapId, x, y) + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, mapId, x, y, "up") + return Game.stack:top() +end + +local oldVersion = GameVersion.get() +GameVersion.set("yellow") + +-- one cell south of the doorway, where the lift corridor starts +local ow = arrive(B4F, 24, 13) +eq(ow.map:blockAt(12, 5), 0x0e, "Yellow arrives on B4F with the gate open") +check(ow.map:isWalkableCell(24, 11) and ow.map:isWalkableCell(25, 11), + "both doorway cells are floor from the first visit") +eq(goInsides(), 0, "and nothing plays the door sound on arrival") + +-- beating the pair: the flag Yellow sets is not one the Red/Blue row +-- names, and afterBattle re-runs the callback on the same map instance +-- (home/trainers.asm EndTrainerBattle), which is where the stamp used to +-- slam the doorway shut without a warp anywhere near it +Game.save.flags[JESSIE_JAMES] = true +ow:afterBattle("win", {}) +check(not Game.save.flags[GUARD_0] and not Game.save.flags[GUARD_1], + "Jessie & James set neither Red/Blue guard flag") +eq(ow.map:blockAt(12, 5), 0x0e, "beating them leaves the gate open") +check(ow.map:isWalkableCell(24, 11) and ow.map:isWalkableCell(25, 11), + "and the way through to Giovanni stays walkable") +eq(goInsides(), 0, "a gate that was never barred needs no door sound") + +-- no map reload in between: the same OverworldState the battle returned +-- to is the one being read, and a reload does not change the answer +ow:stampClosedDoors() +eq(ow.map:blockAt(12, 5), 0x0e, "a direct callback run on the live floor is a no-op") +ow = arrive(B4F, 24, 13) +eq(ow.map:blockAt(12, 5), 0x0e, "re-entering the floor still finds it open") + +-- ---- B1F, which Yellow did keep ----------------------------------------- +sfx = {} +ow = arrive(B1F, 24, 17) +eq(ow.map:blockAt(12, 8), 0x54, "Yellow's B1F is still barred until the grunt") +Game.save.flags[B1F_GUARD] = true +ow:afterBattle("win", {}) +eq(ow.map:blockAt(12, 8), 0x0e, "beating him opens it on the spot") +eq(goInsides(), 1, "with SFX_GO_INSIDE") + +-- ---- the control: Red still stamps B4F ---------------------------------- +-- without this the section above would pass just as well against a +-- closedDoors table someone had emptied outright +GameVersion.set("red") +sfx = {} +Game.save.flags[JESSIE_JAMES] = nil +ow = arrive(B4F, 24, 13) +eq(ow.map:blockAt(12, 5), 0x2d, "Red arrives on B4F with the gate barred") +Game.save.flags[GUARD_0] = true +Game.save.flags[GUARD_1] = true +ow:afterBattle("win", {}) +eq(ow.map:blockAt(12, 5), 0x0e, "and its two guards still open it") +eq(goInsides(), 1, "with the door sound Yellow has no script for") + +for _, r in ipairs(restore) do + local def = Data.maps[r[1]] + def.blocks[r[3] * def.width + r[2] + 1] = r[4] +end +Sound.play = realPlay +GameVersion.set(oldVersion) +while Game.stack:top() do Game.stack:pop() end + +return S:finish() diff --git a/tests/parity_yellow_old_man.lua b/tests/parity_yellow_old_man.lua index 54de6b44..60ce0319 100644 --- a/tests/parity_yellow_old_man.lua +++ b/tests/parity_yellow_old_man.lua @@ -190,6 +190,8 @@ do check(battle and battle.demo, "it is the old-man demo battle") eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA", "the demo is a RATTATA in Yellow (#617)") + check(battle and battle.demoFails, + "the initial training throw breaks out, never catches (#636)") eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo") battle.onFinish() -- the battle ends, the post-battle text prints