From 1c4515eaaa4450cae1d36c703aece821e14e6a32 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 23 Jul 2026 15:42:54 -0400 Subject: [PATCH] squashy squash (#127) --- data/scripts/story.lua | 100 +++++++++--- data/scripts/story2.lua | 185 ++++++++++++++++------ data/scripts/story3.lua | 61 +++++--- src/render/Font.lua | 11 ++ src/render/Renderer.lua | 13 ++ src/render/Transition.lua | 10 ++ src/script/Commands.lua | 6 + src/ui/BoxMenu.lua | 6 +- src/ui/ListMenu.lua | 16 +- src/ui/PartyMenu.lua | 5 +- src/ui/ShopMenu.lua | 10 +- src/ui/SummaryMenu.lua | 10 +- src/world/Map.lua | 73 +++++---- src/world/OverworldController.lua | 164 ++++++++++++++++---- tests/mod_graphics_tests.lua | 62 ++++++++ tests/parity_C.lua | 55 ++++++- tests/parity_bills_pc.lua | 228 ++++++++++++++++++++++++++++ tests/parity_cinnabar_east_surf.lua | 116 ++++++++++++++ tests/parity_daycare.lua | 184 ++++++++++++++++++++++ tests/parity_ss_anne_guard.lua | 199 ++++++++++++++++++++++++ tests/run_tests.lua | 81 ++++++++++ 21 files changed, 1437 insertions(+), 158 deletions(-) create mode 100644 tests/parity_bills_pc.lua create mode 100644 tests/parity_cinnabar_east_surf.lua create mode 100644 tests/parity_daycare.lua create mode 100644 tests/parity_ss_anne_guard.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 9c7d3efc..f0926930 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -222,17 +222,55 @@ M.BILLS_HOUSE = { -- repair saves that already got the ticket under the old collapsed -- script (the monster never hidden, human Bill never shown) onEnter = function(game, ow) - if game.save.flags.EVENT_GOT_SS_TICKET - and not game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then + local flags = game.save.flags + if flags.EVENT_GOT_SS_TICKET + and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL then local Commands = require("src.script.Commands") local ctx = { game = game, save = game.save, overworld = ow } Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON") Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1") - game.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true - game.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true - game.save.flags.EVENT_MET_BILL = true - game.save.flags.EVENT_MET_BILL_2 = true + flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true + flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true + flags.EVENT_MET_BILL = true + flags.EVENT_MET_BILL_2 = true end + -- After Route25ToggleBillsScript, BILL2 is the visible human Bill + -- ("check out my rare POKéMON"). Re-apply on enter so a mid-house + -- load still matches the toggles if the pool was rebuilt. + if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON") + Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1") + Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2") + end + end, +} + +-- ------------------------------------------------------------------- +-- Route 25 outside Bill's house (scripts/Route25.asm +-- Route25ToggleBillsScript): leaving after the SS Ticket arms the +-- Eevee PC list and swaps human Bill to his post-help dialogue NPC. +-- Leaving mid-quest (Bill still in the machine) puts the monster back. +-- ------------------------------------------------------------------- + +M.ROUTE_25 = { + onEnter = function(game, ow) + local flags = game.save.flags + if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then return end + local Commands = require("src.script.Commands") + local ctx = { game = game, save = game.save, overworld = ow } + if not flags.EVENT_MET_BILL_2 then + flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = nil + Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON") + return + end + if not flags.EVENT_GOT_SS_TICKET then return end + flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true + -- TOGGLE_NUGGET_BRIDGE_GUY (ROUTE24_COOLTRAINER_M1) + Commands.hide_object(ctx, "ROUTE_24", "ROUTE24_COOLTRAINER_M1") + Commands.hide_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL1") + Commands.show_object(ctx, "BILLS_HOUSE", "BILLSHOUSE_BILL2") end, } @@ -256,25 +294,40 @@ M.VERMILION_CITY = { end, -- VermilionCityDefaultScript's per-frame SSAnneTicketCheckCoords check: -- the unguarded cell (18,30) just west of the sailor leads straight - -- onto the dock warp, so stepping onto it heading for the dock gets - -- ticket-checked (and turned back once the ship has sailed) without - -- the player ever pressing A. The sailor himself never disappears. + -- onto the dock warp. Stepping onto it facing down always runs the + -- sailor dialog (DisplayTextID TEXT_VERMILIONCITY_SAILOR1); only a + -- ticket while the ship is still docked lets the player continue -- + -- otherwise they are walked back up. The sailor himself never hides. onStep = function(game, ow, x, y) if x ~= 18 or y ~= 30 then return false end if ow.player.facing ~= "down" then return false end - local f = game.save.flags + local Flags = require("src.script.Flags") local t = game.data.text local TextBox = require("src.render.TextBox") - if f.EVENT_SS_ANNE_LEFT then + local shipLeft = Flags.get(game.save, "EVENT_SS_ANNE_LEFT") + local hasTicket = (game.save.inventory.S_S_TICKET or 0) > 0 + if shipLeft then game.stack:push(TextBox.new(game, t._VermilionCitySailor1ShipSetSailText or "The ship set sail.", function() ow:scriptMove(ow.player, "up", 1) end)) return true end - if (game.save.inventory.S_S_TICKET or 0) > 0 then return false end + -- Walk-past is never facing-right / inFrontOfOrBehindGuardCoords, so + -- VermilionCitySailor1Text always takes .greet_player_and_check_ticket: + -- DoYouHaveATicket, then FlashedTicket (allow through) or YouNeedATicket + -- (walk back). Returning true while the box is up also blocks the + -- dock warp on this step. + local ask = t._VermilionCitySailor1DoYouHaveATicketText + or "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?" + if hasTicket then + game.stack:push(TextBox.new(game, + ask .. "\f" + .. (t._VermilionCitySailor1FlashedTicketText + or "{PLAYER} flashed\nthe S.S.TICKET!"))) + return true + end game.stack:push(TextBox.new(game, - (t._VermilionCitySailor1WelcomeToSSAnneText or "Welcome to S.S.\nANNE!") - .. "\f" + ask .. "\f" .. (t._VermilionCitySailor1YouNeedATicketText or "You need a ticket\nto get aboard."), function() ow:scriptMove(ow.player, "up", 1) end)) @@ -288,7 +341,7 @@ M.VERMILION_CITY = { { "face_player" }, -- 1 { "check_flag", "EVENT_SS_ANNE_LEFT" }, -- 2 { "jump_if_true", 11 }, -- 3 - { "show_text", "_VermilionCitySailor1WelcomeToSSAnneText" }, -- 4 + { "show_text", "_VermilionCitySailor1DoYouHaveATicketText" }, -- 4 { "check_item", "S_S_TICKET" }, -- 5 { "jump_if_false", 9 }, -- 6 { "show_text", "_VermilionCitySailor1FlashedTicketText" }, -- 7 @@ -318,18 +371,21 @@ M.SS_ANNE_2F = { M.SS_ANNE_CAPTAINS_ROOM = { talk = { + -- SSAnneCaptainsRoomCaptainText: after the rub line's text_asm tail, + -- pokered plays MUSIC_PKMN_HEALED (scripts/SSAnneCaptainsRoom.asm). TEXT_SSANNECAPTAINSROOM_CAPTAIN = { { "check_flag", "EVENT_GOT_HM01" }, -- 1 - { "jump_if_true", 9 }, -- 2 + { "jump_if_true", 10 }, -- 2 { "show_text", "_SSAnneCaptainsRoomRubCaptainsBackText" }, -- 3 - { "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 4 + { "play_once", "Music_PkmnHealed" }, -- 4 + { "show_text", "_SSAnneCaptainsRoomCaptainIFeelMuchBetterText" }, -- 5 -- give-then-print like scripts/SSAnneCaptainsRoom.asm (GiveItem -- fills wStringBuffer; the received text reads it) - { "give_item", "HM_CUT", 1, false }, -- 5 - { "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 6 - { "set_flag", "EVENT_GOT_HM01" }, -- 7 - { "jump", 10 }, -- 8 - { "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 9 + { "give_item", "HM_CUT", 1, false }, -- 6 + { "show_text", "_SSAnneCaptainsRoomCaptainReceivedHM01Text" }, -- 7 + { "set_flag", "EVENT_GOT_HM01" }, -- 8 + { "jump", 11 }, -- 9 + { "show_text", "_SSAnneCaptainsRoomCaptainNotSickAnymoreText" }, -- 10 }, }, } diff --git a/data/scripts/story2.lua b/data/scripts/story2.lua index 25dc89ef..9156d1f5 100644 --- a/data/scripts/story2.lua +++ b/data/scripts/story2.lua @@ -719,73 +719,170 @@ M.CINNABAR_LAB_FOSSIL_ROOM = { -- ------------------------------------------------------------------- -- Day-care (scripts/Daycare.asm): the boarded Pokémon earns 1 exp per -- step; the fee is ¥100 plus ¥100 per level gained. +-- +-- #118: do not raise mon.level until a paid retrieve (pokered reverts +-- wDayCareMonBoxLevel on .leaveMonInDayCare). Fold pending steps into +-- mon.exp once and clear them so a second talk cannot re-apply the same +-- walk. Fill {RAM:wNameBuffer}/{RAM:wDayCareMonName}/{NUM:...} here — +-- TextBox.TOKENS.RAM only knows wStringBuffer. -- ------------------------------------------------------------------- +local function fillDaycareText(s, subs) + s = s:gsub("{PLAYER}", subs.player or "") + s = s:gsub("{RAM:([^}]*)}", function(name) return subs[name] or "" end) + -- extractor NUM spans may include flags after a comma; keep the name + s = s:gsub("{NUM:([%w_]+)[^}]*}", function(name) + return tostring(subs[name] or "0") + end) + return s +end + M.DAYCARE = { talk = { TEXT_DAYCARE_GENTLEMAN = function(game, ow, npc, done) local TextBox = require("src.render.TextBox") - local ChoiceBox = require("src.ui.ChoiceBox") local t = game.data.text local dc = game.save.daycare + local playerName = game.save.player and game.save.player.name or "RED" + + local function monName(mon) + local def = game.data.pokemon[mon.species] + return mon.nickname or (def and def.name) or mon.species + end if dc and dc.mon then local Growth = require("src.pokemon.Growth") local Stats = require("src.pokemon.Stats") + local Party = require("src.pokemon.Party") local mon = dc.mon local def = game.data.pokemon[mon.species] - mon.exp = mon.exp + (dc.steps or 0) - local newLevel = math.min(100, Growth.levelForExp(def.growthRate, mon.exp)) - local fee = 100 + (newLevel - mon.level) * 100 - local grew = newLevel > mon.level - mon.level = newLevel - mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) - mon.hp = mon.stats.hp - local msg = grew and (t._DaycareGentlemanMonHasGrownText or "It's grown a lot!") - or "Back already?" - game.stack:push(TextBox.new(game, - msg .. ("\fThe fee is ¥%d.\nGet it back?"):format(fee), function() - game.stack:push(ChoiceBox.new(game, function(yes) - if yes and game.save.money >= fee then + -- Apply deferred step-exp once (OverworldController only bumps + -- daycare.steps). Clearing prevents double-count on re-talk. + mon.exp = (mon.exp or 0) + (dc.steps or 0) + dc.steps = 0 + -- depositLevel mirrors wDayCareMonBoxLevel: fee baseline that + -- must survive a declined retrieve. Fall back to mon.level for + -- older saves that predate the field. + if dc.depositLevel == nil then dc.depositLevel = mon.level end + local startLevel = dc.depositLevel + local newLevel = Growth.levelForExp(def and def.growthRate, mon.exp) + if newLevel >= 100 then + newLevel = 100 + if def then + mon.exp = Growth.expForLevel(def.growthRate, 100) + end + end + local levelsGrown = math.max(0, newLevel - startLevel) + local fee = 100 + levelsGrown * 100 + local name = monName(mon) + local subs = { + player = playerName, + wNameBuffer = name, + wDayCareMonName = name, + wDayCareNumLevelsGrown = levelsGrown, + wDayCareTotalCost = fee, + } + local statusText = levelsGrown > 0 + and (t._DaycareGentlemanMonHasGrownText + or "Your {RAM:wNameBuffer}\nhas grown a lot!\fBy level, it's\ngrown by {NUM:wDayCareNumLevelsGrown, 1, 3}!\fAren't I great?") + or (t._DaycareGentlemanMonNeedsMoreTimeText + or "Back already?\nYour {RAM:wNameBuffer}\nneeds some more\ntime with me.") + + game.stack:push(TextBox.new(game, fillDaycareText(statusText, subs), function() + if #game.save.party >= Party.MAX then + game.stack:push(TextBox.new(game, + t._DaycareGentlemanNoRoomForMonText + or "You have no room\nfor this POKéMON!", done)) + return + end + game.stack:push(TextBox.new(game, + fillDaycareText( + t._DaycareGentlemanOweMoneyText + or "You owe me ¥{NUM:wDayCareTotalCost, 2 | LEADING_ZEROES | LEFT_ALIGN}\nfor the return\nof this POKéMON.", + subs), + nil, { choice = function(yes) + if not yes then + -- .leaveMonInDayCare: revert any transient level bump + mon.level = startLevel + game.stack:push(TextBox.new(game, + (t._DaycareGentlemanAllRightThenText or "All right then,\n") + .. (t._DaycareGentlemanComeAgainText or "come again."), + done)) + return + end + if (game.save.money or 0) < fee then + mon.level = startLevel + game.stack:push(TextBox.new(game, + t._DaycareGentlemanNotEnoughMoneyText + or "Hey, you don't\nhave enough ¥!", done)) + return + end game.save.money = game.save.money - fee + mon.level = newLevel + if def then + mon.stats = Stats.calc(def, mon.level, mon.dvs, mon.statExp) + mon.hp = mon.stats.hp + end table.insert(game.save.party, mon) game.save.daycare = nil game.stack:push(TextBox.new(game, - t._DaycareGentlemanGotMonBackText or "Here you go!", done)) - else - game.stack:push(TextBox.new(game, - yes and (t._DaycareGentlemanOweMoneyText or "You owe me money!") - or "Come again!", done)) - end - end)) + t._DaycareGentlemanHeresYourMonText + or "Thank you! Here's\nyour POKéMON!", function() + game.stack:push(TextBox.new(game, + fillDaycareText( + t._DaycareGentlemanGotMonBackText + or "{PLAYER} got\n{RAM:wDayCareMonName} back!", + subs), done)) + end)) + end })) end)) return end - if #game.save.party < 2 then - game.stack:push(TextBox.new(game, - "You only have one\nPOKéMON with you!", done)) - return - end game.stack:push(TextBox.new(game, - t._DaycareGentlemanIntroText or "I can raise a\nPOKéMON for you.", function() - game.stack:push(ChoiceBox.new(game, function(yes) - if not yes then done() return end - local PartyMenu = require("src.ui.PartyMenu") - game.stack:push(PartyMenu.new(game, { - pickOnly = true, - onSwitch = function(mon) - for i, m in ipairs(game.save.party) do - if m == mon then table.remove(game.save.party, i) break end - end - game.save.daycare = { mon = mon, steps = 0 } - game.stack:push(TextBox.new(game, - t._DaycareGentlemanWillLookAfterMonText or - "Fine, I'll look\nafter it a while!", done)) - end, - })) - end)) - end)) + t._DaycareGentlemanIntroText + or "I run a DAYCARE.\nWould you like me\nto raise one of\nyour POKéMON?", + nil, { choice = function(yes) + if not yes then + game.stack:push(TextBox.new(game, + t._DaycareGentlemanComeAgainText or "come again.", done)) + return + end + if #game.save.party < 2 then + game.stack:push(TextBox.new(game, + t._DaycareGentlemanOnlyHaveOneMonText + or "You only have one\nPOKéMON with you.", done)) + return + end + game.stack:push(TextBox.new(game, + t._DaycareGentlemanWhichMonText or "Which POKéMON\nshould I raise?", + function() + local PartyMenu = require("src.ui.PartyMenu") + game.stack:push(PartyMenu.new(game, { + pickOnly = true, + onSwitch = function(mon) + for i, m in ipairs(game.save.party) do + if m == mon then table.remove(game.save.party, i) break end + end + local name = monName(mon) + -- depositLevel = wDayCareMonBoxLevel at deposit time + game.save.daycare = { + mon = mon, steps = 0, depositLevel = mon.level, + } + game.stack:push(TextBox.new(game, + fillDaycareText( + t._DaycareGentlemanWillLookAfterMonText + or "Fine, I'll look\nafter {RAM:wNameBuffer}\nfor a while.", + { player = playerName, wNameBuffer = name }), + function() + game.stack:push(TextBox.new(game, + t._DaycareGentlemanComeSeeMeInAWhileText + or "Come see me in\na while.", done)) + end)) + end, + })) + end)) + end })) end, }, } diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index ce0a29f3..06e5f892 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -150,18 +150,20 @@ local WALK_DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = local WALK_OPP = { up = "down", down = "up", left = "right", right = "left" } local WALK_ORDER = { "up", "down", "left", "right" } -local function elevatorWalkOut(ow, floor) - local m, p = ow.map, ow.player - -- .UpdateWarp is run twice, so BOTH car warp entries get the same - -- (warp id, map id): point every exit warp at the picked floor's - -- elevator-door warp (the reciprocal warp found while building the - -- menu). The car map's def is shared generated data, but its own - -- warps are only ever read from inside the car, and this rewrite runs - -- on every ride before the walk-out fires, so it is self-correcting. - for _, w in ipairs(m.def.warps) do +-- .UpdateWarp: point EVERY car exit warp at the same floor's elevator +-- door (warp id, map id). Shared generated map data, but the car's +-- warps are only read from inside the car; rides rewrite them again. +local function elevatorSetExit(ow, floor) + if not floor then return end + for _, w in ipairs(ow.map.def.warps) do w.destMap = floor.map w.destWarp = floor.warpIdx end +end + +local function elevatorWalkOut(ow, floor) + local m, p = ow.map, ow.player + elevatorSetExit(ow, floor) -- leave by the exit tile under the player (they warped in onto one), -- else the nearest local door @@ -201,7 +203,11 @@ end local function elevator(elevatorMapId, keyGate, preFrames) return { - onEnter = function(game, ow) + -- fromMapId: the floor the player just left (setMap passes it), so a + -- B-cancel can still walk out onto a real map. Silph's ROM car warps + -- default to UNUSED_MAP_ED, which is not in Data.maps -- Warp.resolve + -- asserted and hard-crashed (#123). + onEnter = function(game, ow, fromMapId) if keyGate and not game.save.inventory[keyGate.item] then local TextBox = require("src.render.TextBox") game.stack:push(TextBox.new(game, @@ -230,6 +236,17 @@ local function elevator(elevatorMapId, keyGate, preFrames) return (tonumber(a.token:match("%d+")) or 0) < (tonumber(b.token:match("%d+")) or 0) end) + -- Seed a walk-out destination before the menu: entry floor when + -- known, else the first listed floor (1F). Choosing a floor still + -- rewrites via elevatorWalkOut; B-cancel keeps this seed so leaving + -- the car cannot hit a missing ROM placeholder map. + local exitFloor = floors[1] + if fromMapId then + for _, f in ipairs(floors) do + if f.map == fromMapId then exitFloor = f break end + end + end + elevatorSetExit(ow, exitFloor) local items = {} for _, f in ipairs(floors) do table.insert(items, { label = f.token, value = f }) @@ -262,7 +279,8 @@ local function elevator(elevatorMapId, keyGate, preFrames) end, onCancel = function() -- DisplayElevatorFloorMenu: `ret c` on B -- no warp, nothing - -- happens, the player just stays in the car + -- happens, the player just stays in the car (exit warps were + -- already seeded to the entry floor above) end, })) end, @@ -481,8 +499,9 @@ local DOCK_SHIP_BLOCKS = { M.VERMILION_DOCK = { onEnter = function(game, ow) + local Flags = require("src.script.Flags") local f = game.save.flags - if f.EVENT_SS_ANNE_LEFT then + if Flags.get(game.save, "EVENT_SS_ANNE_LEFT") then -- the ship is long gone: erase her right away, and anyone who -- still lands here is sent back out past the guard for _, b in ipairs(DOCK_SHIP_BLOCKS) do @@ -498,13 +517,15 @@ M.VERMILION_DOCK = { elseif f.EVENT_GOT_HM01 and ow.player.cellY == 2 then -- VermilionDockSSAnneLeavesScript: only stepping OFF the ship -- triggers the departure (wDestinationWarpID == 1 in pokered) -- - -- the horn blows, smoke puffs drift off the funnel, the ship is - -- erased to open water, and the player is walked off the dock - -- into the city past the guard (VermilionCity's + -- Music_Surfing plays for the sail-away cutscene, smoke puffs + -- drift off the funnel, the horn blows, the ship is erased to + -- open water, and the player is walked off the dock into the + -- city past the guard (VermilionCity's -- SCRIPT_VERMILIONCITY_PLAYER_EXIT_SHIP walk) - f.EVENT_SS_ANNE_LEFT = true - require("src.core.Music").stop() - require("src.core.Sound").play(game.data, "SS_Anne_Horn") + Flags.set(game.save, "EVENT_SS_ANNE_LEFT") + local Music = require("src.core.Music") + Music.stop() + Music.play(game.data, "Music_Surfing") local function puff(n, cx) if n <= 0 then return end ow:startDustAnim(cx, 1, function() puff(n - 1, cx + 2) end) @@ -512,11 +533,15 @@ M.VERMILION_DOCK = { puff(3, 15) local rows = {} rows[#rows + 1] = { "wait", 100 } + rows[#rows + 1] = { "play_sound", "SS_Anne_Horn" } for _, b in ipairs(DOCK_SHIP_BLOCKS) do rows[#rows + 1] = { "replace_block", b.bx, b.by, b.water } end rows[#rows + 1] = { "wait", 30 } rows[#rows + 1] = { "move_player", "up", 2 } + -- keep Music_Surfing across the city warp (pokered stays on it + -- through the exit-ship walk) + rows[#rows + 1] = { "play_music", "Music_Surfing", { keep = true } } rows[#rows + 1] = { "warp", "VERMILION_CITY", 18, 31, "up" } rows[#rows + 1] = { "move_player", "up", 2 } ow:queueScript(rows) diff --git a/src/render/Font.lua b/src/render/Font.lua index ed8c8363..e25e6ef2 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -148,6 +148,17 @@ function Font.advanceOf(code) return page and page.advance or GLYPH end +-- Pixel width of a string (glyph advances, not UTF-8 byte length). +-- Multi-byte charmap entries like "¥" are one glyph; callers that +-- right-align with `#text * 8` mis-place them. +function Font.width(text) + local w = 0 + for _, code in ipairs(Font.encode(text)) do + w = w + Font.advanceOf(code) + end + return w +end + -- Draw a plain single-line string at pixel (x, y). Returns the width -- drawn, which is #codes * 8 for every fixed-width page. function Font.draw(text, x, y) diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index bcb4c253..24618c9a 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -62,6 +62,9 @@ end function Renderer:beginFrame(transparent) self.worldActive = false self.uprightActive = false + -- warp-fade overlay from Transition (issue #121); cleared each frame so + -- a popped transition cannot leave a sticky black veil + self.worldFadeAlpha = nil -- last frame's trueColor rects and sprite redraws go before anything -- draws this one PaletteFX.clearTrueColor() @@ -414,6 +417,16 @@ function Renderer:endFrame(zones, worldZones) love.graphics.draw(self.uprightCanvas, wox - M * s, woy - M * s, 0, s, s) love.graphics.setScissor() end + -- Screen-space warp fade (Transition) over the full world composite so + -- survey zoom / tilt edges darken with the center, not only the 160x144 + -- UI letterbox. Drawn before the UI blit so menus above a fade still + -- composite normally if one is ever stacked that way. + local fade = self.worldFadeAlpha + if fade and fade > 0 then + love.graphics.setColor(0, 0, 0, fade) + love.graphics.rectangle("fill", 0, 0, ww, wh) + love.graphics.setColor(1, 1, 1, 1) + end end -- UI stays in the classic centered GB letterbox blit(self.canvas, S, zones, S, ox, oy, ox, oy, vpw, vph) diff --git a/src/render/Transition.lua b/src/render/Transition.lua index c6c06511..9d1b7406 100644 --- a/src/render/Transition.lua +++ b/src/render/Transition.lua @@ -57,6 +57,16 @@ end function Transition:draw() local alpha = self.t / self.frames if self.phase == "in" then alpha = 1 - alpha end + -- Survey zoom draws the overworld into a window-filling world canvas + -- while the UI pass stays the classic 160x144 letterbox. A rect on the + -- UI canvas only darkens that center box (issue #121); when the world + -- pass ran this frame, hand the alpha to Renderer:endFrame so it paints + -- a screen-space overlay over the full composite instead. + local r = self.game and self.game.renderer + if r and r.worldActive then + r.worldFadeAlpha = alpha + return + end love.graphics.setColor(0, 0, 0, alpha) love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.setColor(1, 1, 1, 1) diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 63e11515..b1aa2407 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -438,6 +438,12 @@ function Commands.play_sound(ctx, soundId) require("src.core.Sound").play(ctx.game.data, soundId) end +-- play_once : one-shot jingle (Music_PkmnHealed, etc.); the map +-- theme resumes when it ends (Music.playOnce / pendingRestore) +function Commands.play_once(ctx, songId) + require("src.core.Music").playOnce(ctx.game.data, songId) +end + -- play_cry : PlayCry (home/audio.asm). The text_asm bodies that -- use it run text_far (a no-button-wait "...@" string) -> PlayCry -> -- WaitForSoundToFinish, all within the same text ID -- the cry only starts diff --git a/src/ui/BoxMenu.lua b/src/ui/BoxMenu.lua index 2e5ca380..ba205a51 100644 --- a/src/ui/BoxMenu.lua +++ b/src/ui/BoxMenu.lua @@ -145,6 +145,10 @@ end function BoxMenu.new(game) Boxes.ensure(game.save) + -- bills_pc.asm BillsPCMenu: TextBoxBorder at (0,0) with interior + -- 12x10 → total 14x12. "CHANGE BOX" is 10 tiles and needs the + -- full interior (cursor col + label); the old tw=12 right-side box + -- drew the final glyph on the border. return Menu.new(game, { { label = "WITHDRAW", onSelect = function() withdraw(game) end }, { label = "DEPOSIT", onSelect = function() deposit(game) end }, @@ -153,7 +157,7 @@ function BoxMenu.new(game) { label = "SEE YA!" }, -- Bill's PC runs silent end to end (BIT_NO_MENU_BUTTON_SOUND, -- engine/menus/pokemon_pc.asm) - }, { tx = 8, ty = 0, tw = 12, th = 12, noSound = true }) + }, { tx = 0, ty = 0, tw = 14, th = 12, noSound = true }) end return BoxMenu diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index 8f26df84..bc0ee654 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -117,7 +117,7 @@ function ListMenu:draw() love.graphics.setColor(0, 0, 0, 1) end if item.right then - Font.draw(item.right, 160 - 8 - #item.right * 8, y) + Font.draw(item.right, 160 - 8 - Font.width(item.right), y) end if i == self.index then -- hollowIndex: a chosen row keeps the hollow '▷' left behind by @@ -136,7 +136,7 @@ function ListMenu:draw() Font.drawBox(11, 0, 9, 3) love.graphics.setColor(0, 0, 0, 1) local money = ("¥%d"):format(self.money and self.money() or 0) - Font.draw(money, 152 - #money * 8, 8) + Font.draw(money, 152 - Font.width(money), 8) -- the clerk's line in the standard bottom text box; long prompts -- wrap and keep their last two lines, like the GB's scrolled box Font.drawBox(0, 12, 20, 6) @@ -153,7 +153,17 @@ function ListMenu:draw() end end elseif self.footer then - Font.draw(self.footer, 8, 136) + -- PC deposit/withdraw footers use "\n"; draw the last two lines so + -- long item names are not clipped at the screen edge (#115). + local flat = {} + for _, page in ipairs(require("src.render.TextBox").paginate(self.footer)) do + for _, line in ipairs(page) do flat[#flat + 1] = line end + end + local y = (#flat >= 2) and 120 or 136 + for i = math.max(1, #flat - 1), #flat do + Font.draw(flat[i], 8, y) + y = y + 16 + end end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 4cfde96e..817125e8 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -182,12 +182,13 @@ function PartyMenu:update(dt) -- and music return first (PlayDefaultMusic + -- LoadWalkingPlayerSpriteGraphics), the menu closes with the -- GBPalWhiteOutWithDelay3 blink, and the simulated pad press - -- steps the player forward onto land + -- steps the player forward onto land (or across a connection + -- strip when the shore is the next map's edge) self.game.stack:pop() ow.player.surfing = false require("src.core.Music").setSurfing(self.game.data, false) self.game.stack:push(Transition.whiteFlash(self.game, nil, function() - ow:scriptMove(ow.player, ow.player.facing, 1) + ow:stepForwardOrCrossEdge(ow.player.facing) end)) return end diff --git a/src/ui/ShopMenu.lua b/src/ui/ShopMenu.lua index a315c111..65b93fa0 100644 --- a/src/ui/ShopMenu.lua +++ b/src/ui/ShopMenu.lua @@ -84,13 +84,17 @@ local function buy(game, stock) end local function sell(game) + -- Sell list is ITEMLISTMENU with wPrintItemPrices cleared + -- (pokemart.asm .sellMenuLoop): name + quantity only. Price shows + -- in the quantity chooser. Stuffing "xN" into the label next to a + -- right-aligned ¥ price made long names overlap (issue #116). local items = {} for _, id in ipairs(Bag.order(game.save)) do local def = game.data.items[id] table.insert(items, { value = id, - label = (def and def.name or id) .. " x" .. game.save.inventory[id], - right = ("¥%d"):format(def and math.floor(def.price / 2) or 0), + label = def and def.name or id, + right = "x" .. game.save.inventory[id], }) end local greet = txt(game, "_PokemartBuyingGreetingText", "Take your time.") @@ -128,7 +132,7 @@ local function sell(game) Bag.remove(game.save, item.value, qty) local left = game.save.inventory[item.value] if left then - item.label = def.name .. " x" .. left + item.right = "x" .. left else list:removeCurrent() end diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index c3abc14e..ddba6e69 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -115,12 +115,16 @@ function SummaryMenu:draw() drawLineBox(19, 1, 6, 10) Font.draw("EXP POINTS", 72, 24) Font.draw(("%d"):format(mon.exp), 96, 32) - Font.draw("LEVEL UP", 72, 44) + -- StatusScreen2: "LEVEL UP" at (9,5); next-exp PrintNumber 7 cols + -- at (7,6); space at (14,6); PrintLevel at (16,6). The old + -- "%d to L%d" string at x=88 overflowed the DrawLineBox edge. + Font.draw("LEVEL UP", 72, 40) local Growth = require("src.pokemon.Growth") local nextExp = mon.level < 100 and (Growth.expForLevel(def.growthRate, mon.level + 1) - mon.exp) or 0 - Font.draw(("%d to L%d"):format(math.max(0, nextExp), - math.min(100, mon.level + 1)), 88, 52) + Font.draw(("%7d"):format(math.max(0, nextExp)), 56, 48) + HudTiles.tile(0x6E, 128, 48) -- + Font.draw(tostring(math.min(100, mon.level + 1)), 136, 48) Font.drawBox(0, 8, 20, 10) for i = 1, 4 do local mv = mon.moves[i] diff --git a/src/world/Map.lua b/src/world/Map.lua index bcf5e105..7c261732 100644 --- a/src/world/Map.lua +++ b/src/world/Map.lua @@ -38,6 +38,51 @@ local function hashSet(list, into) return into end +-- Collision tile (bottom-left 8x8) of a cell on an UNLOADED map def -- +-- the connected neighbor during an edge crossing. pokered's +-- GetTileAndCoordsInFrontOfPlayer / collision checks read the neighbor +-- strip's tile bytes the same way. +function Map.defCellTile(def, tilesetDef, cx, cy) + if not (def and tilesetDef and tilesetDef.blocks) then return nil end + local tx, ty = cx * 2, cy * 2 + 1 + local bx, by = math.floor(tx / 4), math.floor(ty / 4) + local id + if bx < 0 or by < 0 or bx >= def.width or by >= def.height then + id = def.borderBlock + else + id = def.blocks[by * def.width + bx + 1] + end + local block = tilesetDef.blocks[(id or 0) + 1] + if not block then return nil end + return block[(ty % 4) * 4 + (tx % 4) + 1] +end + +local function defWaterTileSet(def, tilesetDef) + local water = {} + hashSet(tilesetDef.waterTiles or WATER_TILES, water) + local shore = tilesetDef.shoreTiles + if shore == nil and not NO_SHORE_TILESETS[def.tileset] then shore = SHORE_TILES end + hashSet(shore or {}, water) + return water +end + +-- Water/shore on an unloaded map def (same tile ids as Map:isWaterCell). +function Map.defIsWaterCell(def, tilesetDef, cx, cy) + local tile = Map.defCellTile(def, tilesetDef, cx, cy) + if tile == nil then return false end + return defWaterTileSet(def, tilesetDef)[tile] or false +end + +function Map.defIsWalkableCell(def, tilesetDef, cx, cy) + if not (tilesetDef and tilesetDef.walkable) then return false end + local tile = Map.defCellTile(def, tilesetDef, cx, cy) + if tile == nil then return false end + for _, t in ipairs(tilesetDef.walkable) do + if t == tile then return true end + end + return false +end + -- Passability of a cell of an UNLOADED map def -- the connected neighbor -- during an edge crossing. pokered's collision check reads the neighbor -- strip's tile bytes, so a step off the map edge onto a solid tile of @@ -52,32 +97,8 @@ function Map.defPassable(def, tilesetDef, cx, cy, surfing) if not (def and tilesetDef and tilesetDef.blocks and tilesetDef.walkable) then return false end - local tx, ty = cx * 2, cy * 2 + 1 - local bx, by = math.floor(tx / 4), math.floor(ty / 4) - local id - if bx < 0 or by < 0 or bx >= def.width or by >= def.height then - id = def.borderBlock - else - id = def.blocks[by * def.width + bx + 1] - end - local block = tilesetDef.blocks[(id or 0) + 1] - if not block then return false end - local tile = block[(ty % 4) * 4 + (tx % 4) + 1] - for _, t in ipairs(tilesetDef.walkable) do - if t == tile then return true end - end - if surfing then - for _, t in ipairs(tilesetDef.waterTiles or WATER_TILES) do - if t == tile then return true end - end - local shore = tilesetDef.shoreTiles - if shore == nil and not NO_SHORE_TILESETS[def.tileset] then - shore = SHORE_TILES - end - for _, t in ipairs(shore or {}) do - if t == tile then return true end - end - end + if Map.defIsWalkableCell(def, tilesetDef, cx, cy) then return true end + if surfing and Map.defIsWaterCell(def, tilesetDef, cx, cy) then return true end return false end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e525ac68..213437fc 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -338,10 +338,13 @@ function OverworldState:setMap(mapId, x, y, facing, opts) or (fromMapId and "warp" or "boot"), }) - -- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers) + -- map-enter hooks (hand-ported map scripts, e.g. Victory Road barriers). + -- fromMapId lets elevators seed a valid walk-out floor when the ROM + -- car warps still point at a missing map (Silph's UNUSED_MAP_ED) and + -- the player B-cancels the floor menu without .UpdateWarp. local hooks = mapScripts.get(mapId) if hooks and hooks.onEnter then - hooks.onEnter(Game, self) + hooks.onEnter(Game, self, fromMapId) end self:rebuildNeighbors() @@ -1000,20 +1003,19 @@ function OverworldState:checkEdgeExit(dir) return false end --- Map connections: the connected map's strip offset is in blocks; arriving --- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md). --- The crossing scrolls continuously: the map data swaps while the player --- is placed one cell before the entry point (their old world position, --- which the neighbor strips render identically) and walks the seam step. -function OverworldState:crossConnection(dir, conn) +-- Landing cell on the connected map for a step off this map's edge in +-- `dir` (same math as crossConnection). Returns destDef, tilesetDef, x, y +-- or nil when there is no usable connection. +function OverworldState:connectionLanding(dir) + local conn = self.map:connection(COMPASS[dir]) + if not conn then return nil end local dest = Game.data.maps[conn.map] - if not dest then - Logger.warn("connection to unknown map %s", tostring(conn.map)) - return false - end + if not dest then return nil end + local ts = Game.data.tilesets[dest.tileset] + if not ts then return nil end local p = self.player - local x, y = p.cellX, p.cellY local destW, destH = dest.width * 2, dest.height * 2 + local x, y if dir == "up" then x, y = p.cellX - conn.offset * 2, destH - 1 elseif dir == "down" then @@ -1025,13 +1027,27 @@ function OverworldState:crossConnection(dir, conn) end x = math.max(0, math.min(destW - 1, x)) y = math.max(0, math.min(destH - 1, y)) + return dest, ts, x, y, conn +end + +-- Map connections: the connected map's strip offset is in blocks; arriving +-- coordinates follow destX = curX - offset*2 (see docs/extraction-notes.md). +-- The crossing scrolls continuously: the map data swaps while the player +-- is placed one cell before the entry point (their old world position, +-- which the neighbor strips render identically) and walks the seam step. +function OverworldState:crossConnection(dir, conn) + local dest, ts, x, y = self:connectionLanding(dir) + if not dest then + Logger.warn("connection to unknown map %s", tostring(conn and conn.map)) + return false + end + local p = self.player -- pokered's collision check reads the NEIGHBOR strip's tile bytes, so -- stepping off the edge onto a solid tile of the connected map bumps -- exactly like an in-map wall. Without this read, Pallet's south -- shore (land at x2-3) walked straight onto ROUTE_21 (3,0) -- a -- collision tile -- stranding the player on a cell no walk can leave. - if not Map.defPassable(dest, Game.data.tilesets[dest.tileset], x, y, - p.surfing) then + if not Map.defPassable(dest, ts, x, y, p.surfing) then return false end self:setMap(conn.map, x, y, p.facing, { seamless = true }) @@ -1054,6 +1070,53 @@ function OverworldState:crossConnection(dir, conn) return true end +-- ItemUseSurfboard's simulated pad press: step onto the facing cell, or +-- cross a map connection when that cell is off this map's edge (Cinnabar +-- east coast -> Route 20 water, and the reverse dismount ashore). +function OverworldState:stepForwardOrCrossEdge(dir) + dir = dir or self.player.facing + local fx, fy = Collision.target(self.player.cellX, self.player.cellY, dir) + if not self.map:inBounds(fx, fy) then + return self:checkEdgeExit(dir) + end + self:scriptMove(self.player, dir, 1) + return true +end + +-- IsNextTileShoreOrWater across a connection strip: pokered loads the +-- neighbor's tiles into the border, so wTileInFrontOfPlayer is the +-- connected map's tile even when the facing cell is off this map. +-- Shore/water classification still uses THIS map's tileset rules +-- (SHIP_PORT's $32 dock exception), matching the asm. +function OverworldState:facingIsShoreOrWater() + if not self:tilesetHasWater() then return false end + local fx, fy = self.player:facingCell() + if self.map:inBounds(fx, fy) then + return self.map:isWaterCell(fx, fy) + end + local dest, ts, x, y = self:connectionLanding(self.player.facing) + if not dest then return false end + local tile = Map.defCellTile(dest, ts, x, y) + if tile == nil then return false end + return self.map.waterTiles[tile] or false +end + +-- tryToStopSurfing land check, including a land landing across a map +-- connection (surf off Cinnabar's east coast water back onto the coast). +function OverworldState:facingIsLandDismount() + local p = self.player + local fx, fy = p:facingCell() + if self.map:inBounds(fx, fy) then + return self.map:isWalkableCell(fx, fy) + and Collision.canMove(self.map, self.entities, p, p.facing) + end + local dest, ts, x, y = self:connectionLanding(p.facing) + if not dest then return false end + if not Map.defIsWalkableCell(dest, ts, x, y) then return false end + -- IsSpriteInFrontOfPlayer2: no current-map sprite can sit past the edge + return not Collision.occupied(self.entities, fx, fy, p) +end + -- ------------------------------------------------------------------------- -- interactions -- ------------------------------------------------------------------------- @@ -1571,16 +1634,21 @@ function OverworldState:trashCanSwitch(canIndex) end -- Bill's House PC (engine/events/hidden_events/bills_house_pc.asm --- BillsHousePC): once Bill-the-Pokémon has climbed into the machine --- (EVENT_BILL_SAID_USE_CELL_SEPARATOR), running the PC plays the cell --- separator's SFX sequence, sets EVENT_USED_CELL_SEPARATOR_ON_BILL and --- Bill steps back out of the machine human again --- (BillsHouseBillExitsMachineScript / CleanupScript set EVENT_MET_BILL). +-- BillsHousePC). Check order matches pokered: +-- 1) EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING -> Eevee collection list +-- 2) EVENT_USED_CELL_SEPARATOR_ON_BILL -> teleporter monitor text +-- 3) EVENT_BILL_SAID_USE_CELL_SEPARATOR -> cell-separator cutscene +-- 4) else -> teleporter monitor text +-- Leaving after the SS Ticket (Route25ToggleBillsScript) arms (1). function OverworldState:billsHousePC() local t = Game.data.text local flags = Game.save.flags - if not (flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR - and not flags.EVENT_USED_CELL_SEPARATOR_ON_BILL) then + if flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING then + self:billsHousePokemonList() + return + end + if flags.EVENT_USED_CELL_SEPARATOR_ON_BILL + or not flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR then Game.stack:push(TextBox.new(Game, t._BillsHouseMonitorText or "TELEPORTER is\ndisplayed on the\nPC monitor.")) return @@ -1604,9 +1672,40 @@ function OverworldState:billsHousePC() end)) end +-- BillsHousePokemonList: EEVEE / FLAREON / JOLTEON / VAPOREON + CANCEL; +-- picking one runs DisplayPokedex (DexEntryMenu) and returns to the list. +function OverworldState:billsHousePokemonList() + local t = Game.data.text + local Menu = require("src.ui.Menu") + local function openList() + local species = { "EEVEE", "FLAREON", "JOLTEON", "VAPOREON" } + local items = {} + for _, id in ipairs(species) do + local def = Game.data.pokemon[id] + table.insert(items, { + label = (def and def.name) or id, + keepOpen = true, + onSelect = function() + local dex = Game.save.pokedex + if dex then dex.seen[id] = true end + Screens.push(Game, "DexEntryMenu", id) + end, + }) + end + table.insert(items, { label = "CANCEL" }) + -- TextBoxBorder b=10,c=9 at (0,0) -> total tw=11, th=12 + Game.stack:push(Menu.new(Game, items, + { tx = 0, ty = 0, tw = 11, th = 12 })) + end + Game.stack:push(TextBox.new(Game, t._BillsHousePokemonListText1 + or "BILL's favorite\nPOKéMON list!", openList)) +end + -- BillsHouseBillExitsMachineScript: human Bill appears inside the machine -- at (1,2) and walks out to his spot at (4,4); the map music resumes and --- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. +-- EVENT_MET_BILL / EVENT_MET_BILL_2 arm the SS-Ticket dialogue. The Eevee +-- PC list arms later, on the first Route 25 load after the ticket +-- (EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING). function OverworldState:billsHouseBillExits() local Commands = require("src.script.Commands") local ctx = { game = Game, save = Game.save, overworld = self } @@ -1698,14 +1797,15 @@ function OverworldState:trySurf(fx, fy) Game.stack:push(TextBox.new(Game, text, function() -- start_sub_menus.asm .surf: UseItem returns (mount + text done), -- then GBPalWhiteOutWithDelay3 blinks before the simulated forward - -- press steps onto the water + -- press steps onto the water (or across a connection strip, like + -- Cinnabar's east coast onto Route 20) local Transition = require("src.render.Transition") if Transition.whiteFlash then Game.stack:push(Transition.whiteFlash(Game, nil, function() - self:scriptMove(p, p.facing, 1) + self:stepForwardOrCrossEdge(p.facing) end)) else - self:scriptMove(p, p.facing, 1) + self:stepForwardOrCrossEdge(p.facing) end end)) end @@ -1805,20 +1905,20 @@ function OverworldState:useSurfFieldMove() -- until both EVENT_SEAFOAM4_BOULDER*_DOWN_HOLE events are set. if Game.save.forcedBike then return "forced_bike" end if self:surfBlockedHere() then return "current" end - local fx, fy = p:facingCell() if p.surfing then -- ItemUseSurfboard .tryToStopSurfing: blocked by a sprite in front -- (IsSpriteInFrontOfPlayer2), a water tile-pair collision, or a -- facing tile that isn't in the tileset's land-passable list; - -- otherwise the player walks forward off the water. - if self.map:inBounds(fx, fy) and self.map:isWalkableCell(fx, fy) - and Collision.canMove(self.map, self.entities, p, p.facing) then + -- otherwise the player walks forward off the water. Facing a land + -- cell across a map connection (Cinnabar east coast) counts too -- + -- pokered reads that landing from the connection strip. + if self:facingIsLandDismount() then return "dismount" end return "no_place" end - if not self.map:inBounds(fx, fy) - or not self.map:isWaterCell(fx, fy) or not self:tilesetHasWater() then + -- IsNextTileShoreOrWater, including connection-strip water (issue #125) + if not self:facingIsShoreOrWater() then return "no_water" end return "ok" diff --git a/tests/mod_graphics_tests.lua b/tests/mod_graphics_tests.lua index 4bc65370..87b3dc14 100644 --- a/tests/mod_graphics_tests.lua +++ b/tests/mod_graphics_tests.lua @@ -647,6 +647,68 @@ check(Transition.new({ data = retimed }).frames == 30, check(Transition.new({ data = { transitions = {} } }).frames == 12, "an unregistered id falls back to the built-in 12 frames") +-- issue #121: with the survey-zoom world pass active, the warp fade must +-- darken the full window composite (via Renderer.worldFadeAlpha), not just +-- paint a 160x144 rect on the UI letterbox. +do + local rects = {} + local color = { 1, 1, 1, 1 } + local savedRect, savedColor = love.graphics.rectangle, love.graphics.setColor + love.graphics.rectangle = function(mode, x, y, w, h) + rects[#rects + 1] = { + mode = mode, x = x, y = y, w = w, h = h, + a = color[4], r = color[1], + } + end + love.graphics.setColor = function(r, g, b, a) + color[1], color[2], color[3], color[4] = r, g, b, a or 1 + end + + Renderer:init() + local fade = Transition.new({ renderer = Renderer, stack = { pop = noop } }) + fade.t = 6 -- mid fade-out (12 frames) + fade.phase = "out" + + Renderer:beginFrame(true) + Renderer:beginWorldPass() + Renderer:endWorldPass() + check(Renderer.worldActive == true, "world pass stays marked active until endFrame") + fade:draw() + check(Renderer.worldFadeAlpha == 0.5, + "warp fade hands mid-out alpha to the world composite overlay") + check(#rects == 0, + "warp fade does not paint the 160x144 UI letterbox while the world pass is up") + + resetLog() + rects = {} + Renderer:endFrame(nil, fullWorldZones()) + local fadeRect + for _, r in ipairs(rects) do + -- endFrame's letterbox clear is also a full-window black fill (a == 1); + -- the warp overlay is the half-alpha one Transition requested + if r.mode == "fill" and r.x == 0 and r.y == 0 + and r.w == 640 and r.h == 576 and r.r == 0 and r.a == 0.5 then + fadeRect = r + end + end + check(fadeRect ~= nil, + "endFrame paints the warp fade over the full window at the fade alpha") + check(Renderer.worldActive == false, "endFrame clears worldActive") + + -- without a world pass (opaque UI states), keep the classic UI rect + Renderer:beginFrame(false) + fade.t = 6 + fade.phase = "out" + rects = {} + fade:draw() + check(Renderer.worldFadeAlpha == nil, + "no world pass: warp fade does not set a world overlay") + check(#rects == 1 and rects[1].w == 160 and rects[1].h == 144, + "no world pass: warp fade still fills the 160x144 UI canvas") + + love.graphics.rectangle, love.graphics.setColor = savedRect, savedColor +end + -- ------- the transition.style hook local stack = { pop = function() end } diff --git a/tests/parity_C.lua b/tests/parity_C.lua index 16e5732d..a156c297 100644 --- a/tests/parity_C.lua +++ b/tests/parity_C.lua @@ -55,9 +55,27 @@ local function newStack() return stack, items end +-- snapshot / restore car warps so tests that seed or ride do not leak +-- across cases (Silph's ROM default is UNUSED_MAP_ED) +local function snapshotWarps(mapId) + local snap = {} + for i, w in ipairs(Data.maps[mapId].warps) do + snap[i] = { destMap = w.destMap, destWarp = w.destWarp } + end + return snap +end + +local function restoreWarps(mapId, snap) + for i, w in ipairs(Data.maps[mapId].warps) do + w.destMap, w.destWarp = snap[i].destMap, snap[i].destWarp + end +end + -- drives one elevator map's onEnter and returns the pushed state (either a --- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox) -local function openElevator(mapId, inventory) +-- ListMenu or, for the keyGated Rocket Hideout without the key, a TextBox). +-- fromMapId is the floor the player entered from (OverworldState:setMap +-- passes it), used to seed a cancel-safe walk-out destination. +local function openElevator(mapId, inventory, fromMapId) local script = mapScripts.get(mapId) check(script ~= nil, mapId .. " script registered") check(script and script.onEnter ~= nil, mapId .. " has onEnter") @@ -98,7 +116,7 @@ local function openElevator(mapId, inventory) stack = stack, } sfxCalls = {} - script.onEnter(game, ow) + script.onEnter(game, ow, fromMapId) return items[#items], warpCalls, stack, ow end @@ -130,7 +148,14 @@ end -- Silph Co elevator: 11 floors, the double-digit sort/label regression -- =================================================================== do - local menu, warpCalls, stack, ow = openElevator("SILPH_CO_ELEVATOR") + local silphSnap = snapshotWarps("SILPH_CO_ELEVATOR") + -- ROM default (UNUSED_MAP_ED) must still be what we start from so the + -- cancel-then-exit regression below is real + eq(silphSnap[1].destMap, "UNUSED_MAP_ED", + "Silph Co ROM car warps still default to UNUSED_MAP_ED") + + local menu, warpCalls, stack, ow = + openElevator("SILPH_CO_ELEVATOR", nil, "SILPH_CO_5F") check(menu ~= nil and getmetatable(menu) == ListMenu, "SILPH_CO_ELEVATOR opens a ListMenu") if menu then eq(#menu.items, 11, "Silph Co elevator lists all 11 floors") @@ -143,12 +168,32 @@ do check(menu.items[1].label ~= "SILPH CO 1F" and not menu.items[1].label:find("SILPH"), "Silph Co floor label is the short token, not the full map id") + -- onEnter seeds the car's exit to the floor we came from so a B-cancel + -- cannot leave UNUSED_MAP_ED in place (#123 hard crash on walk-out) + eq(ow.map.def.warps[1].destMap, "SILPH_CO_5F", + "Silph onEnter seeds exit warps to the entry floor") + check(Data.maps[ow.map.def.warps[1].destMap] ~= nil, + "seeded Silph exit map exists in Data.maps") + -- Cancel: pokered's DisplayElevatorFloorMenu does `ret c` on B -- -- no warp at all. clear(warpCalls) menu.onCancel() eq(#warpCalls, 0, "Cancel does not warp (bare ret c, no floors[1] fallback)") + -- #123: after B-cancel, walking out of the car must resolve without + -- asserting on the ROM placeholder UNUSED_MAP_ED + clear(warpCalls) + local okExit, errExit = pcall(function() + ow:takeWarp(ow.map.def.warps[1]) + end) + check(okExit, "cancel then exit does not crash: " .. tostring(errExit)) + eq(#warpCalls, 1, "cancel then exit takes the seeded entry-floor warp") + if warpCalls[1] then + eq(warpCalls[1].map, "SILPH_CO_5F", + "cancel then exit returns to the floor the player entered from") + end + -- Choose a mid-list floor (5F): pokered never warps on the spot -- -- DisplayElevatorFloorMenu sets BIT_CUR_MAP_USED_ELEVATOR and the -- map script runs ShakeElevator: a 12-frame lead-in (the script's @@ -157,6 +202,7 @@ do -- cycle, then SFX_SAFARI_ZONE_PA, and only then the floor warp. clear(warpCalls) sfxCalls = {} + ow.walkSteps = {} local chosen = menu.items[5] -- "5F" menu.onChoose(chosen, menu) eq(#warpCalls, 0, "choosing a floor does not warp on the spot (the shake runs first)") @@ -184,6 +230,7 @@ do eq(warpCalls[1].y, chosen.value.y, "walk-out lands on the chosen floor's y") end end + restoreWarps("SILPH_CO_ELEVATOR", silphSnap) end -- =================================================================== diff --git a/tests/parity_bills_pc.lua b/tests/parity_bills_pc.lua new file mode 100644 index 00000000..fd6ca010 --- /dev/null +++ b/tests/parity_bills_pc.lua @@ -0,0 +1,228 @@ +-- Parity test, Bill's House PC + Route25ToggleBillsScript (#120). +-- +-- asm sources: +-- engine/events/hidden_events/bills_house_pc.asm (BillsHousePC / +-- BillsHousePokemonList: after EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING +-- the PC opens EEVEE/FLAREON/JOLTEON/VAPOREON; before that the +-- teleporter monitor text or cell-separator cutscene) +-- scripts/Route25.asm (Route25ToggleBillsScript: first Route 25 load +-- after EVENT_MET_BILL_2 + EVENT_GOT_SS_TICKET sets +-- EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, hides BILL1 / nugget-bridge +-- guy, shows BILL2; mid-quest leave resets the separator flag) +-- +-- Self-contained: run via `luajit tests/parity_bills_pc.lua`; also +-- dofile'd by tests/run_tests.lua's aggregator. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity bills pc") +local check, eq = S.check, S.eq + +local OW = require("src.world.OverworldController") +local SaveData = require("src.core.SaveData") +local story = require("data.scripts.story") + +local function getUpvalue(fn, name) + local i = 1 + while true do + local n, v = debug.getupvalue(fn, i) + if not n then return nil end + if n == name then return v end + i = i + 1 + end +end +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end + +local pushed = {} +local stackStub = { + push = function(_, item) + pushed[#pushed + 1] = item + end, +} +local textBoxStub = { + new = function(_, text, onDone) + local box = { kind = "text", text = text, onDone = onDone } + if onDone then onDone() end + return box + end, +} +local menuStub = { + new = function(_, items, opts) + return { kind = "menu", items = items, opts = opts or {} } + end, +} +local screensStub = { + push = function(_, id, species) + pushed[#pushed + 1] = { kind = "screen", id = id, species = species } + end, +} + +local fakeGame = { + data = Data, + save = SaveData.newGame(), + stack = stackStub, +} +check(setUpvalue(OW.billsHousePC, "TextBox", textBoxStub), "TextBox upvalue") +check(setUpvalue(OW.billsHousePC, "Game", fakeGame), "Game upvalue for PC") +-- Screens is only referenced from billsHousePokemonList +check(setUpvalue(OW.billsHousePokemonList, "Screens", screensStub), + "Screens upvalue on pokemon list") +check(setUpvalue(OW.billsHousePokemonList, "Game", fakeGame), + "Game upvalue on pokemon list") +check(setUpvalue(OW.billsHousePokemonList, "TextBox", textBoxStub), + "TextBox upvalue on pokemon list") + +-- Menu is required inside billsHousePokemonList; stub via package.loaded +local realMenu = package.loaded["src.ui.Menu"] +package.loaded["src.ui.Menu"] = menuStub + +local fakeSelf = setmetatable({ + queueScript = function() end, + billsHouseBillExits = function() end, +}, { __index = OW }) + +local function resetFlags() + fakeGame.save = SaveData.newGame() + setUpvalue(OW.billsHousePC, "Game", fakeGame) + pushed = {} +end + +local function lastPush() + return pushed[#pushed] +end + +local function runPC() + pushed = {} + fakeSelf:billsHousePC() +end + +-- === 1) default: teleporter monitor text === +resetFlags() +runPC() +eq(lastPush() and lastPush().kind, "text", "default PC is a text box") +check(tostring(lastPush().text):find("TELEPORTER", 1, true) + or tostring(lastPush().text):find("displayed on the", 1, true), + "default PC shows monitor text") + +-- === 2) Bill in machine, separator not used yet: initiated text === +resetFlags() +fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true +local musicStopped = false +local realMusic = package.loaded["src.core.Music"] +package.loaded["src.core.Music"] = { + stop = function() musicStopped = true end, + playMap = function() end, +} +local realSound = package.loaded["src.core.Sound"] +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, +} +runPC() +check(musicStopped, "cell separator stops map music") +check(tostring(lastPush().text):find("Cell", 1, true) + or tostring(lastPush().text):find("TELEPORTER", 1, true), + "separator path prints initiated text") +check(fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL, + "separator path sets EVENT_USED_CELL_SEPARATOR_ON_BILL") + +-- === 3) after separator, before leaving: monitor text again === +resetFlags() +fakeGame.save.flags.EVENT_BILL_SAID_USE_CELL_SEPARATOR = true +fakeGame.save.flags.EVENT_USED_CELL_SEPARATOR_ON_BILL = true +fakeGame.save.flags.EVENT_MET_BILL = true +runPC() +check(tostring(lastPush().text):find("TELEPORTER", 1, true) + or tostring(lastPush().text):find("displayed on the", 1, true), + "post-separator pre-leave PC shows monitor text") + +-- === 4) after leaving: Eevee collection menu === +resetFlags() +fakeGame.save.flags.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true +runPC() +local menu +for _, p in ipairs(pushed) do + if p.kind == "menu" then menu = p break end +end +check(menu ~= nil, "post-leave PC opens a menu") +local labels = {} +for _, item in ipairs(menu.items) do labels[#labels + 1] = item.label end +eq(table.concat(labels, ","), "EEVEE,FLAREON,JOLTEON,VAPOREON,CANCEL", + "Eevee collection lists the four mons + CANCEL") + +-- selecting EEVEE marks seen and opens DexEntryMenu without closing list +fakeGame.save.pokedex = { seen = {}, owned = {} } +menu.items[1].onSelect() +check(fakeGame.save.pokedex.seen.EEVEE, "viewing marks EEVEE seen") +local dexPush +for i = #pushed, 1, -1 do + if pushed[i].kind == "screen" then dexPush = pushed[i] break end +end +eq(dexPush and dexPush.id, "DexEntryMenu", "selection opens DexEntryMenu") +eq(dexPush and dexPush.species, "EEVEE", "DexEntryMenu gets EEVEE") +check(menu.items[1].keepOpen, "dex pick keeps the list open") + +-- === 5) Route25ToggleBillsScript === +local toggles = {} +local Commands = require("src.script.Commands") +local realHide, realShow = Commands.hide_object, Commands.show_object +Commands.hide_object = function(_, mapId, name) + toggles[mapId .. ":" .. name] = false +end +Commands.show_object = function(_, mapId, name) + toggles[mapId .. ":" .. name] = true +end + +local function runRoute25(flags) + toggles = {} + local save = SaveData.newGame() + for k, v in pairs(flags) do save.flags[k] = v end + story.ROUTE_25.onEnter({ save = save }, {}) + return save.flags, toggles +end + +local f, t = runRoute25({}) +check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, + "no leave flag before meeting Bill") +check(t["BILLS_HOUSE:BILLSHOUSE_BILL_POKEMON"] == true, + "mid-quest leave restores monster Bill") +check(f.EVENT_BILL_SAID_USE_CELL_SEPARATOR == nil, + "mid-quest leave clears separator arming flag") + +f, t = runRoute25({ EVENT_MET_BILL_2 = true }) +check(not f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, + "MET_BILL_2 without ticket does not arm leave flag") + +f, t = runRoute25({ + EVENT_MET_BILL_2 = true, + EVENT_GOT_SS_TICKET = true, +}) +check(f.EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING, + "ticket + MET_BILL_2 arms EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING") +eq(t["BILLS_HOUSE:BILLSHOUSE_BILL1"], false, "hides SS-Ticket Bill") +eq(t["BILLS_HOUSE:BILLSHOUSE_BILL2"], true, "shows rare-POKéMON Bill") +eq(t["ROUTE_24:ROUTE24_COOLTRAINER_M1"], false, "hides nugget-bridge guy") + +f, t = runRoute25({ + EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = true, + EVENT_MET_BILL_2 = true, + EVENT_GOT_SS_TICKET = true, +}) +eq(next(t), nil, "already-left Route 25 enter is a no-op") + +Commands.hide_object = realHide +Commands.show_object = realShow +package.loaded["src.ui.Menu"] = realMenu +if realMusic ~= nil then package.loaded["src.core.Music"] = realMusic end +if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end + +S.finish() diff --git a/tests/parity_cinnabar_east_surf.lua b/tests/parity_cinnabar_east_surf.lua new file mode 100644 index 00000000..ee730bcf --- /dev/null +++ b/tests/parity_cinnabar_east_surf.lua @@ -0,0 +1,116 @@ +-- Regression (#125): SURF from Cinnabar's east coast into Route 20 water. +-- +-- Cinnabar's easternmost cells are walkable land (tile $39); the water is +-- on ROUTE_20 across the east connection. pokered loads that strip into +-- the border, so IsNextTileShoreOrWater sees shore tile $32. The port +-- must read the connection landing the same way -- an inBounds-only +-- water check returns "no_water" and blocks the mount (and the reverse +-- party-menu dismount back onto the coast). +-- +-- Self-contained; run via `luajit tests/parity_cinnabar_east_surf.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.CINNABAR_ISLAND) then Data:load() end + +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local Map = require("src.world.Map") +local MapLoader = require("src.world.MapLoader") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local OW = require("src.world.OverworldController") +local S = require("tests.harness").suite("parity cinnabar east surf") +local check, eq = S.check, S.eq + +local function mkMon(species, ...) + local moves = {} + for _, id in ipairs({ ... }) do + table.insert(moves, { id = id, pp = 10, ppUp = 0 }) + end + return { + species = species, level = 30, hp = 50, maxHp = 50, + status = 0, moves = moves, nickname = nil, + } +end + +local cin = MapLoader.load(Data, "CINNABAR_ISLAND") +local r20 = MapLoader.load(Data, "ROUTE_20") +local r20ts = Data.tilesets[r20.def.tileset] + +-- ground truth: east edge is land, Route 20 west landing is shore/water +check(cin:isWalkableCell(19, 8), "Cinnabar (19,8) is walkable coast") +check(not cin:isWaterCell(19, 8), "Cinnabar (19,8) is not water") +check(not cin:inBounds(20, 8), "facing east from (19,8) is off-map") +check(r20:isWaterCell(0, 8), "ROUTE_20 (0,8) is water/shore") +check(Map.defIsWaterCell(r20.def, r20ts, 0, 8), + "defIsWaterCell agrees on ROUTE_20 (0,8)") +check(Map.defIsWalkableCell(cin.def, Data.tilesets[cin.def.tileset], 19, 8), + "defIsWalkableCell agrees on Cinnabar coast") + +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 = { mkMon("SQUIRTLE", "SURF") } +Game.save.inventory = { SOULBADGE = true } +Game.overworld = OW + +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right") +local ow = Game.stack:top() +eq(ow.map.id, "CINNABAR_ISLAND", "start on Cinnabar east coast") +eq(ow.player.cellX, 19, "coast x") +eq(ow.player.cellY, 8, "coast y") +eq(ow.player.facing, "right", "facing east toward Route 20") + +check(ow:facingIsShoreOrWater(), + "facingIsShoreOrWater reads Route 20 shore across the seam") +eq(ow:useSurfFieldMove(), "ok", + "useSurfFieldMove ok facing connection water (#125)") + +-- reverse: surfing on Route 20 west edge, facing Cinnabar land +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "ROUTE_20", 0, 8, "left") +ow = Game.stack:top() +ow.player.surfing = true +eq(ow.map.id, "ROUTE_20", "on Route 20 west water") +check(ow.map:isWaterCell(0, 8), "standing cell is water") +check(ow:facingIsLandDismount(), + "facingIsLandDismount sees Cinnabar coast across the seam") +eq(ow:useSurfFieldMove(), "dismount", + "party-menu SURF dismounts onto Cinnabar east coast") + +-- facing open water while surfing still refuses +ow.player.facing = "right" +eq(ow:useSurfFieldMove(), "no_place", + "surfing + facing open water: no place (unchanged)") + +-- mount step crosses the seam onto Route 20 (surfing already armed) +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "CINNABAR_ISLAND", 19, 8, "right") +ow = Game.stack:top() +ow.player.surfing = true +check(ow:stepForwardOrCrossEdge("right"), + "stepForwardOrCrossEdge crosses onto Route 20 while surfing") +eq(ow.map.id, "ROUTE_20", "map swapped to Route 20 after edge surf step") +-- crossConnection parks one cell before the seam and walks in (same as +-- a live edge press); the landing target is Route 20 (0,8) +eq(ow.player.targetX, 0, "step target is Route 20 west column") +eq(ow.player.targetY, 8, "same Y across offset-0 east connection") +check(ow.player.moving, "seam step is in progress") + +-- in-map Pallet south shore still works (no false connection match) +while Game.stack:top() do Game.stack:pop() end +Game.stack:push(OW, "PALLET_TOWN", 4, 13, "down") +ow = Game.stack:top() +ow.player.surfing = false +eq(ow:useSurfFieldMove(), "ok", "in-map water mount still ok") +ow.player.facing = "up" +eq(ow:useSurfFieldMove(), "no_water", "in-map land still no_water") + +S.finish() diff --git a/tests/parity_daycare.lua b/tests/parity_daycare.lua new file mode 100644 index 00000000..eb4fa328 --- /dev/null +++ b/tests/parity_daycare.lua @@ -0,0 +1,184 @@ +-- Parity test for Route 5 Day Care (issue #118). +-- Self-contained: `luajit tests/parity_daycare.lua`; also dofile'd by +-- tests/run_tests.lua's parity_* aggregator. +-- +-- Covers scripts/Daycare.asm retrieve flow ported in +-- data/scripts/story2.lua M.DAYCARE: +-- * name substitution in grown / got-back text +-- * fee = ¥100 + ¥100 per level gained +-- * declining retrieve must not collapse the fee to ¥100 on re-talk +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local Data = require("src.core.Data") +if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity daycare") +local check, eq = S.check, S.eq + +local realTextBox = package.loaded["src.render.TextBox"] +local shownTexts = {} +local choiceAnswer = true +package.loaded["src.render.TextBox"] = { + new = function(game, text, onDone, opts) + table.insert(shownTexts, text) + if opts and opts.choice then + opts.choice(choiceAnswer) + elseif onDone then + onDone() + end + return { text = text } + end, +} + +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local Growth = require("src.pokemon.Growth") +local story2 = require("data.scripts.story2") + +check(story2.DAYCARE ~= nil, "DAYCARE registered") +check(story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN ~= nil, + "DAYCARE gentleman talk handler registered") + +local talkGentleman = story2.DAYCARE.talk.TEXT_DAYCARE_GENTLEMAN + +local function newGame() + local save = SaveData.newGame() + save.money = 10000 + local game = { data = Data, save = save, stack = { push = function() end } } + return game +end + +local function talk(game) + shownTexts = {} + local doneCalled = false + talkGentleman(game, {}, nil, function() doneCalled = true end) + return doneCalled +end + +local function boardAtLevel(game, species, level, nickname) + local mon = Pokemon.new(Data, species, level) + if nickname then mon.nickname = nickname end + game.save.daycare = { mon = mon, steps = 0 } + return mon +end + +-- Exp needed to gain `gained` levels from `level` (exclusive of current). +local function stepsForLevels(species, level, gained) + local def = Data.pokemon[species] + local target = Growth.expForLevel(def.growthRate, level + gained) + local current = Growth.expForLevel(def.growthRate, level) + return target - current +end + +-- === 1) grown text names the mon; fee matches levels grown === +do + local game = newGame() + local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY") + local gained = 3 + game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained) + choiceAnswer = false -- decline retrieve + check(talk(game), "grown+decline talk completes") + local grown + for _, s in ipairs(shownTexts) do + if s:find("grown a lot", 1, true) then grown = s break end + end + check(grown ~= nil, "shows MonHasGrownText") + check(grown:find("SCRAPPY", 1, true), "grown text substitutes wNameBuffer") + check(grown:find(tostring(gained), 1, true), "grown text shows levels grown") + check(not grown:find("{RAM:", 1, true), "grown text has no leftover RAM tokens") + local owe + for _, s in ipairs(shownTexts) do + if s:find("owe me", 1, true) then owe = s break end + end + check(owe ~= nil, "shows OweMoneyText") + eq(owe:match("¥(%d+)"), tostring(100 + gained * 100), + "fee is ¥100 + ¥100 per level gained") + eq(game.save.daycare.mon.level, 5, + "decline leaves deposit level untouched (BoxLevel baseline)") + eq(game.save.daycare.steps, 0, "pending steps folded into exp on talk") +end + +-- === 2) re-talk after decline keeps the correct fee (issue #118) === +do + local game = newGame() + boardAtLevel(game, "RATTATA", 5, "SCRAPPY") + local gained = 3 + game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained) + choiceAnswer = false + talk(game) + choiceAnswer = false + check(talk(game), "second decline talk completes") + local owe + for _, s in ipairs(shownTexts) do + if s:find("owe me", 1, true) then owe = s break end + end + check(owe ~= nil, "re-talk still shows OweMoneyText") + eq(owe:match("¥(%d+)"), tostring(100 + gained * 100), + "re-talk after decline keeps fee (not ¥100)") + eq(game.save.daycare.mon.level, 5, "re-talk still leaves deposit level") +end + +-- === 3) paid retrieve raises level, names the mon, clears daycare === +do + local game = newGame() + boardAtLevel(game, "RATTATA", 5, "SCRAPPY") + local gained = 2 + game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained) + local fee = 100 + gained * 100 + local moneyBefore = game.save.money + choiceAnswer = true + check(talk(game), "retrieve talk completes") + eq(game.save.daycare, nil, "daycare cleared after paid retrieve") + eq(#game.save.party, 1, "mon returned to party") + eq(game.save.party[1].nickname, "SCRAPPY", "retrieved nickname preserved") + eq(game.save.party[1].level, 5 + gained, "level applied only on retrieve") + eq(game.save.money, moneyBefore - fee, "money deducted by correct fee") + local got + for _, s in ipairs(shownTexts) do + if s:find("got", 1, true) and s:find("back", 1, true) then got = s break end + end + check(got ~= nil, "shows GotMonBackText") + check(got:find("SCRAPPY", 1, true), "got-back text substitutes wDayCareMonName") + check(not got:find("{RAM:", 1, true), "got-back text has no leftover RAM tokens") +end + +-- === 4) no levels gained: NeedsMoreTime still names the mon; fee ¥100 === +do + local game = newGame() + boardAtLevel(game, "RATTATA", 10, "PIP") + game.save.daycare.steps = 0 + choiceAnswer = false + check(talk(game), "no-growth talk completes") + local needs + for _, s in ipairs(shownTexts) do + if s:find("Back already", 1, true) then needs = s break end + end + check(needs ~= nil, "shows MonNeedsMoreTimeText") + check(needs and needs:find("PIP", 1, true), "needs-more-time names the mon") + local owe + for _, s in ipairs(shownTexts) do + if s:find("owe me", 1, true) then owe = s break end + end + eq(owe and owe:match("¥(%d+)"), "100", "no growth still costs base ¥100") +end + +-- === 5) depositLevel survives a corrupted mon.level from older buggy talks === +do + local game = newGame() + local mon = boardAtLevel(game, "RATTATA", 5, "SCRAPPY") + local gained = 4 + game.save.daycare.steps = stepsForLevels("RATTATA", 5, gained) + game.save.daycare.depositLevel = 5 + mon.level = 5 + gained -- simulate pre-#118 raise-on-talk corruption + choiceAnswer = false + check(talk(game), "depositLevel baseline talk completes") + local owe + for _, s in ipairs(shownTexts) do + if s:find("owe me", 1, true) then owe = s break end + end + eq(owe and owe:match("¥(%d+)"), tostring(100 + gained * 100), + "depositLevel keeps fee correct even if mon.level was raised early") + eq(game.save.daycare.mon.level, 5, "decline restores mon.level to depositLevel") +end + +package.loaded["src.render.TextBox"] = realTextBox +S.finish() diff --git a/tests/parity_ss_anne_guard.lua b/tests/parity_ss_anne_guard.lua new file mode 100644 index 00000000..5b115b80 --- /dev/null +++ b/tests/parity_ss_anne_guard.lua @@ -0,0 +1,199 @@ +-- Parity test: Vermilion City S.S. Anne sailor (#122). +-- +-- pokered VermilionCityDefaultScript (scripts/VermilionCity.asm): +-- stepping onto SSAnneTicketCheckCoords (18,30) facing down always +-- DisplayTextID's the sailor. With a ticket and the ship still docked, +-- FlashedTicket plays and the player may continue; without a ticket, or +-- after EVENT_SS_ANNE_LEFT, they are walked back up. +-- +-- The port used to early-return when the ticket was in the bag, skipping +-- the flash dialog entirely. +-- +-- Self-contained; run via `luajit tests/parity_ss_anne_guard.lua`. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end +local S = require("tests.harness").suite("parity ss anne guard") +local check, eq = S.check, S.eq + +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { text = text, done = done } end, +} + +local musicCalls = {} +package.loaded["src.core.Music"] = { + stop = function() musicCalls[#musicCalls + 1] = { "stop" } end, + play = function(_, song) + musicCalls[#musicCalls + 1] = { "play", song } + end, + playOnce = function(_, song) + musicCalls[#musicCalls + 1] = { "playOnce", song } + return true + end, +} + +local soundCalls = {} +package.loaded["src.core.Sound"] = { + play = function(_, id) soundCalls[#soundCalls + 1] = id end, +} + +local story = dofile("data/scripts/story.lua") +local story3 = dofile("data/scripts/story3.lua") +local Flags = require("src.script.Flags") + +local function gameWith(opts) + local pushed, moved = {}, {} + local game = { + save = { + inventory = opts.inventory or {}, + flags = opts.flags or {}, + }, + data = { + text = { + _VermilionCitySailor1DoYouHaveATicketText = + "Welcome to S.S.\nANNE!\fExcuse me, do you\nhave a ticket?", + _VermilionCitySailor1FlashedTicketText = + "PLAYER flashed\nthe S.S.TICKET!", + _VermilionCitySailor1YouNeedATicketText = + "You need a ticket\nto get aboard.", + _VermilionCitySailor1ShipSetSailText = "The ship set sail.", + _SSAnneCaptainsRoomRubCaptainsBackText = "Rub-rub...", + _SSAnneCaptainsRoomCaptainIFeelMuchBetterText = "I feel better!", + _SSAnneCaptainsRoomCaptainReceivedHM01Text = "Got HM01!", + _SSAnneCaptainsRoomCaptainNotSickAnymoreText = "Not sick.", + }, + }, + stack = { push = function(_, box) pushed[#pushed + 1] = box end }, + _pushed = pushed, + _moved = moved, + } + return game, pushed, moved +end + +local function owWith(moved) + return { + player = { facing = "down", cellY = 2 }, + scriptMove = function(_, _, dir, n) moved[#moved + 1] = { dir, n } end, + queueScript = function(self, rows) self._queued = rows end, + startDustAnim = function() end, + map = { + setBlock = function() end, + renderer = { rebuild = function() end }, + }, + } +end + +local city = story.VERMILION_CITY +check(city and city.onStep, "VERMILION_CITY has the sailor coord trigger") + +-- With ticket, ship still docked: flash dialog, do NOT walk back. +do + local game, pushed, moved = gameWith({ inventory = { S_S_TICKET = 1 } }) + local ow = owWith(moved) + check(city.onStep(game, ow, 18, 30), "ticket: walk-past trigger consumes the step") + check(#pushed == 1, "ticket: auto dialog is shown") + check(pushed[1].text:find("flashed", 1, true) + or pushed[1].text:find("ticket", 1, true), + "ticket: flash / ticket-check dialog text") + check(#moved == 0, "ticket: player is not walked back") +end + +-- Without ticket: dialog + walk back. +do + local game, pushed, moved = gameWith({}) + local ow = owWith(moved) + check(city.onStep(game, ow, 18, 30), "no ticket: trigger fires") + check(#pushed == 1, "no ticket: dialog shown") + if pushed[1] and pushed[1].done then pushed[1].done() end + check(#moved == 1 and moved[1][1] == "up", + "no ticket: walked back up after dialog") +end + +-- After the ship leaves: ShipSetSail + walk back, even with a ticket. +do + local game, pushed, moved = gameWith({ + inventory = { S_S_TICKET = 1 }, + flags = { EVENT_SS_ANNE_LEFT = true }, + }) + local ow = owWith(moved) + check(city.onStep(game, ow, 18, 30), "ship left: trigger still fires") + eq(pushed[1] and pushed[1].text, "The ship set sail.", + "ship left: dialog updates to ShipSetSail") + if pushed[1] and pushed[1].done then pushed[1].done() end + check(#moved == 1 and moved[1][1] == "up", + "ship left: walked back up") +end + +-- Off-tile / wrong facing: no trigger. +do + local game, pushed = gameWith({ inventory = { S_S_TICKET = 1 } }) + local ow = owWith({}) + eq(city.onStep(game, ow, 18, 29), false, "wrong Y: no trigger") + ow.player.facing = "up" + eq(city.onStep(game, ow, 18, 30), false, "facing up: no trigger") + check(#pushed == 0, "no spurious dialog off the check") +end + +-- Sailor talk after ship left: ShipSetSail branch (row script). +do + local ScriptRunner = require("src.script.ScriptRunner") + local game = gameWith({ flags = { EVENT_SS_ANNE_LEFT = true } }) + local rows = city.talk.TEXT_VERMILIONCITY_SAILOR1 + local runner = ScriptRunner.new(game, nil) + runner:run(rows, {}) + local guard = 0 + while runner:isRunning() and guard < 200 do + guard = guard + 1 + if game.stack and game._pushed then + local box = game._pushed[#game._pushed] + if box and box.done then box.done() end + end + runner:update() + end + local last = game._pushed[#game._pushed] + eq(last and last.text, "The ship set sail.", + "talk after ship left shows ShipSetSail") +end + +-- Departure cutscene: Music_Surfing + EVENT_SS_ANNE_LEFT via Flags.set. +do + for i = #musicCalls, 1, -1 do musicCalls[i] = nil end + local game, _, moved = gameWith({ flags = { EVENT_GOT_HM01 = true } }) + local ow = owWith(moved) + story3.VERMILION_DOCK.onEnter(game, ow) + check(Flags.get(game.save, "EVENT_SS_ANNE_LEFT"), + "departure sets EVENT_SS_ANNE_LEFT") + local sawSurf = false + for _, c in ipairs(musicCalls) do + if c[1] == "play" and c[2] == "Music_Surfing" then sawSurf = true end + end + check(sawSurf, "departure plays Music_Surfing") + check(ow._queued ~= nil, "departure queues the sail-away script") + local kept, horn = false, false + for _, row in ipairs(ow._queued or {}) do + if row[1] == "play_music" and row[2] == "Music_Surfing" + and row[3] and row[3].keep then + kept = true + end + if row[1] == "play_sound" and row[2] == "SS_Anne_Horn" then + horn = true + end + end + check(kept, "departure keeps Music_Surfing across the city warp") + check(horn, "departure queues SS_Anne_Horn") +end + +-- Captain rub jingle: play_once Music_PkmnHealed sits after the rub text. +do + local rows = story.SS_ANNE_CAPTAINS_ROOM.talk.TEXT_SSANNECAPTAINSROOM_CAPTAIN + local found = false + for i, row in ipairs(rows) do + if row[1] == "play_once" and row[2] == "Music_PkmnHealed" then + found = true + check(rows[i - 1] and rows[i - 1][2] == "_SSAnneCaptainsRoomRubCaptainsBackText", + "play_once follows the rub-back text") + end + end + check(found, "captain script plays Music_PkmnHealed after the rub") +end + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 7e1cd020..18028b32 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -1895,6 +1895,87 @@ do eq(#StateStack.states, depth0, "mart menu unwound cleanly") end +-- ---------------------------------------------------------------- UI text layout (#115/#116/#119) +-- Nested function so LuaJIT's 200-local main-chunk limit is not hit. +;(function() + local BoxMenu = require("src.ui.BoxMenu") + local box = BoxMenu.new(Game) + check(box.tw >= 14, "Bill's PC menu is wide enough for CHANGE BOX") + local labelTiles = 2 + #"CHANGE BOX" -- cursor gap + label + check(box.tx + labelTiles <= box.tx + box.tw - 1, + "CHANGE BOX fits inside the Bill's PC border") + + -- ¥ is one glyph (charmap 0xF0) but two UTF-8 bytes; right-align must + -- use Font.width, not #string. + eq(Font.width("¥300"), 4 * 8, "Font.width counts yen as one glyph") + check(Font.width("¥300") < #"¥300" * 8, + "byte-length right-align would shift yen prices left") + + -- Sell list: name + quantity column only (no ¥ price on the row). + Game.save.inventory = { GREAT_BALL = 5, HYPER_POTION = 99 } + local sellShop = require("src.ui.ShopMenu").new(Game, { "POTION" }, function() end) + StateStack:push(sellShop) + Input.pressed = { down = true }; StateStack:update(1 / 60); Input.pressed = {} + Input.pressed = { a = true }; StateStack:update(1 / 60); Input.pressed = {} + local sellList = StateStack:top() + local foundHyper + for _, it in ipairs(sellList.items or {}) do + if it.value == "HYPER_POTION" then + foundHyper = it + local nameEnd = 16 + Font.width(it.label) + local rightX = 160 - 8 - Font.width(it.right) + check(nameEnd <= rightX, + "sell HYPER POTION name does not overlap quantity") + check(not tostring(it.right):find("¥", 1, true), + "sell list keeps prices out of the right column") + check(tostring(it.label):find("x", 1, true) == nil, + "sell list does not glue quantity into the name") + end + end + check(foundHyper, "sell list includes HYPER POTION") + StateStack:pop() -- sell list + StateStack:pop() -- shop + Game.save.inventory = {} + + -- Status screen page 2: next-level line stays left of the line-box edge. + local Pokemon = require("src.pokemon.Pokemon") + local mon = Pokemon.new(Data, "RAICHU", 27) + local SummaryMenu = require("src.ui.SummaryMenu") + local HudTiles = require("src.render.HudTiles") + local drawn = {} + local savedDraw, savedTile = Font.draw, HudTiles.tile + Font.draw = function(text, x, y) + drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } + return Font.width(text) + end + HudTiles.tile = function(code, x, y) + drawn[#drawn + 1] = { tile = code, x = x, y = y } + end + local summary = SummaryMenu.new(Game, mon) + summary.page = 2 + summary:draw() + Font.draw = savedDraw + HudTiles.tile = savedTile + local nextExp, lvTile, lvNum + for _, d in ipairs(drawn) do + if d.text == "LEVEL UP" then + eq(d.y, 40, "LEVEL UP sits on tile row 5") + elseif type(d.text) == "string" and d.text:match("^%s*%d+$") and d.y == 48 and d.x == 56 then + nextExp = d + elseif d.tile == 0x6E and d.y == 48 then + lvTile = d + elseif d.text == "28" and d.y == 48 then + lvNum = d + end + end + check(nextExp, "next-exp prints at (7,6)") + check(lvTile and lvTile.x == 128, "next level uses the tile at col 16") + check(lvNum and lvNum.x == 136, "next level digits follow ") + local edge = 19 * 8 -- DrawLineBox vertical at col 19 + check(lvNum.x + Font.width(lvNum.text) <= edge, + "next level digits stay left of the status line-box") +end)() + -- ================= BUGS.md batch: battle-victory-music =================