diff --git a/src/core/DiscordPresence.lua b/src/core/DiscordPresence.lua index 47b73836..344152cb 100644 --- a/src/core/DiscordPresence.lua +++ b/src/core/DiscordPresence.lua @@ -783,5 +783,6 @@ end -- test / debug helpers DiscordPresence._state = state DiscordPresence.locationName = locationName +DiscordPresence.handleJoinRequest = handleJoinRequest return DiscordPresence diff --git a/src/ui/PartyMenu.lua b/src/ui/PartyMenu.lua index 5de185ca..474fbdb7 100644 --- a/src/ui/PartyMenu.lua +++ b/src/ui/PartyMenu.lua @@ -9,6 +9,7 @@ -- opts.onCancel: fired when the menu closes without a pick (B) -- Pops itself on B. +local Assets = require("src.render.Assets") local Font = require("src.render.Font") local Logger = require("src.core.Logger") local Runtime = require("src.mods.Runtime") @@ -96,7 +97,10 @@ local function drawIcon(game, mon, x, y, selected, counter) end if not path then return end if iconImages[path] == nil then - local ok, img = pcall(love.graphics.newImage, path) + -- resolve through Assets so an overrides/ or transform-derived icon + -- (e.g. a per-species image at assets/generated/icons/.png) is + -- picked up the same way battle sprites are + local ok, img = pcall(love.graphics.newImage, Assets.resolve(path)) iconImages[path] = ok and img or false end local img = iconImages[path] diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 0772056f..d3624c77 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3323,10 +3323,19 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- outdoor. Auto-walk leaves the mat, so the arrival disable -- (warpEntryCell / justWarped) is unnecessary -- and would let you -- stand on the door without re-entering if you hold back into it. + -- The walk-out is a simulated d-pad press (wSimulatedJoypadStates), + -- not a forced move, so it obeys collision: on a landing with a + -- solid cell south of the door (the mansion stair landings back + -- onto shelves) the step bumps and the player stays on the door, + -- arrival disable intact, instead of clipping into the wall. if self.map:isDoorTileCell(self.player.cellX, self.player.cellY) then - self.warpEntryCell = nil - self.justWarped = false - self:scriptMove(self.player, "down", 1) + if Collision.canMove(self.map, self.entities, self.player, "down") then + self.warpEntryCell = nil + self.justWarped = false + self:scriptMove(self.player, "down", 1) + else + self.player.facing = "down" + end end end end, function() diff --git a/tests/drivers/celadon_stairs_test.lua b/tests/drivers/celadon_stairs_test.lua new file mode 100644 index 00000000..c9e47bd8 --- /dev/null +++ b/tests/drivers/celadon_stairs_test.lua @@ -0,0 +1,75 @@ +-- Driver: Celadon Mansion back-stair landings. The 2F/3F/1F west and +-- middle stair landings are door tiles with a solid shelf cell directly +-- south; the door walk-out step must bump there (simulated d-pad press), +-- not force the player through the wall (the Eevee-house stairwell +-- stuck-in-shelves bug). East stairs keep the normal walk-out. + +return function(game) + local U = dofile("tests/drivers/util.lua") + local ow + local fails = 0 + local function expect(cond, ...) + if not cond then fails = fails + 1 end + U.log(cond and "PASS" or "FAIL", ...) + end + local function settle(mapId) + for _ = 1, 300 do + ow = game.overworld + if ow and ow.map.id == mapId and not ow.transitioning + and #ow.scriptMoves == 0 and not ow.player.moving then + break + end + U.wait(1) + end + U.wait(4) + ow = game.overworld + end + + -- climb the west back stairs: 1F (2,1) -> 2F landing (2,1), whose south + -- cell is the solid shelf row + U.teleport(game, "CELADON_MANSION_1F", 4, 1, "left") + U.hold(game, "left", 70) + settle("CELADON_MANSION_2F") + expect(ow.map.id == "CELADON_MANSION_2F", "west stairs arrive 2F, map:", ow.map.id) + expect(ow.player.cellX == 2 and ow.player.cellY == 1, + "player stays on landing (2,1), got:", ow.player.cellX, ow.player.cellY) + expect(ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY), + "standing cell walkable, tile:", + string.format("%02x", ow.map:cellTile(ow.player.cellX, ow.player.cellY))) + + -- the landing is not a trap: one step east onto the walkway works + -- (short tap: (4,1) beyond it is the middle-stairs warp) + U.hold(game, "right", 4) + U.wait(24) + ow = game.overworld + expect(ow.map.id == "CELADON_MANSION_2F" and ow.player.cellX == 3, + "stepped east off the landing, at:", ow.map.id, + ow.player.cellX, ow.player.cellY) + + -- stepping back onto the stairs still takes them down to 1F + U.hold(game, "left", 4) + settle("CELADON_MANSION_1F") + expect(ow.map.id == "CELADON_MANSION_1F", "stairs back down, map:", ow.map.id) + expect(ow.player.cellX == 2 and ow.player.cellY == 1, + "1F landing holds too (2,1), got:", ow.player.cellX, ow.player.cellY) + + -- east stairs land on (7,1) with open floor south: the vanilla + -- walk-out step must still fire + U.teleport(game, "CELADON_MANSION_1F", 7, 3, "up") + U.hold(game, "up", 70) + settle("CELADON_MANSION_2F") + expect(ow.map.id == "CELADON_MANSION_2F", "east stairs arrive 2F, map:", ow.map.id) + expect(ow.player.cellX == 7 and ow.player.cellY == 2, + "walk-out stepped south to (7,2), got:", ow.player.cellX, ow.player.cellY) + + -- a save already stuck inside the shelf (pre-fix) escapes north onto + -- the stairs, which warp back down + U.teleport(game, "CELADON_MANSION_2F", 2, 2, "up") + U.hold(game, "up", 24) + settle("CELADON_MANSION_1F") + expect(ow.map.id == "CELADON_MANSION_1F", "stuck save escapes via stairs, map:", + ow.map.id) + + if fails > 0 then error(fails .. " check(s) failed") end + U.log("all checks passed") +end diff --git a/tests/engine/discord_join_requested.lua b/tests/engine/discord_join_requested.lua new file mode 100644 index 00000000..650fdf33 --- /dev/null +++ b/tests/engine/discord_join_requested.lua @@ -0,0 +1,124 @@ +-- Unit coverage for event:discord.join_requested (DiscordPresence Ask-to-Join). +-- Mods subscribe through Runtime; the engine emits before pushing JoinOnline. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Runtime = require("src.mods.Runtime") +local Events = require("src.mods.Events") +local DiscordPresence = require("src.core.DiscordPresence") + +local savedLink = package.loaded["src.link.LinkState"] +local savedTour = package.loaded["src.link.Tournament"] +package.loaded["src.link.LinkState"] = { + newJoinOnline = function(_game, code) + return { tag = "link", code = code, stage = true, net = {} } + end, +} +package.loaded["src.link.Tournament"] = { + newJoinOnline = function(_game, code) + return { tag = "tournament", code = code, stage = true, net = {} } + end, +} + +local function freshGame() + local items = {} + return { + stack = { + items = items, + top = function(self) return self.items[#self.items] end, + push = function(self, screen) self.items[#self.items + 1] = screen end, + }, + } +end + +local bus = Events.new() +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +Runtime.events = bus + +local function listen() + local seen = {} + bus:on("discord.join_requested", function(ev) + seen[#seen + 1] = ev + end, 0, "discord_join_test") + return seen +end + +do + local game = freshGame() + local st = DiscordPresence._state + st.game = game + st.activity = "exploring" + local seen = listen() + + DiscordPresence.handleJoinRequest("m:HOST01") + T.eq(#seen, 1, "match join emits discord.join_requested") + T.eq(seen[1].code, "HOST01", "payload carries the match code") + T.eq(seen[1].kind, "m", "payload carries kind tag m") + T.eq(game.stack:top().tag, "link", "pushes LinkState.newJoinOnline") + T.eq(game.stack:top().code, "HOST01", "join screen gets the code") + bus:removeOwner("discord_join_test") +end + +do + local game = freshGame() + local st = DiscordPresence._state + st.game = game + st.activity = "menu" + local seen = listen() + + DiscordPresence.handleJoinRequest("t:TOUR99") + T.eq(#seen, 1, "tournament join emits discord.join_requested") + T.eq(seen[1].kind, "t", "payload carries kind tag t") + T.eq(seen[1].code, "TOUR99", "payload carries the tournament code") + T.eq(game.stack:top().tag, "tournament", "pushes Tournament.newJoinOnline") + bus:removeOwner("discord_join_test") +end + +do + local game = freshGame() + local st = DiscordPresence._state + st.game = game + st.activity = "exploring" + local seen = listen() + + DiscordPresence.handleJoinRequest("PLAIN42") + T.eq(#seen, 1, "plain secret still emits discord.join_requested") + T.eq(seen[1].kind, "m", "plain secret defaults to match kind") + T.eq(seen[1].code, "PLAIN42", "plain secret is the whole code") + bus:removeOwner("discord_join_test") +end + +do + local game = freshGame() + local st = DiscordPresence._state + st.game = game + st.activity = "battle" + local seen = listen() + + DiscordPresence.handleJoinRequest("m:NOPE") + T.eq(#seen, 0, "battle activity suppresses join dispatch") + T.eq(game.stack:top(), nil, "battle activity pushes no join screen") + bus:removeOwner("discord_join_test") +end + +do + local game = freshGame() + game.stack:push({ stage = true, net = {} }) -- already in a link session + local st = DiscordPresence._state + st.game = game + st.activity = "exploring" + local seen = listen() + + DiscordPresence.handleJoinRequest("m:NOPE") + T.eq(#seen, 0, "active link session suppresses join dispatch") + T.eq(#game.stack.items, 1, "active link session is not replaced") + bus:removeOwner("discord_join_test") +end + +DiscordPresence._state.game = nil +DiscordPresence._state.activity = "menu" +Runtime.events, Runtime.hooks = savedEvents, savedHooks +package.loaded["src.link.LinkState"] = savedLink +package.loaded["src.link.Tournament"] = savedTour + +T.finish("discord_join_requested") diff --git a/tests/engine/pc_menu_sides.lua b/tests/engine/pc_menu_sides.lua index 93b8bc3d..87113864 100644 --- a/tests/engine/pc_menu_sides.lua +++ b/tests/engine/pc_menu_sides.lua @@ -1,10 +1,9 @@ -- Bill's PC vs player's PC top-menu origin/size (#176). +-- ROM-free: uses the fixture dataset so CI (no data/generated/) stays green. package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local T = require("tests.harness") -local Data = require("src.core.Data") -if not (Data.pokemon and next(Data.pokemon)) then Data:load() end +local T = require("tests.modkit") +local Data = T.fixtures.load() local SaveData = require("src.core.SaveData") local BoxMenu = require("src.ui.BoxMenu") diff --git a/tests/engine/pc_release.lua b/tests/engine/pc_release.lua index bfa9d39b..f8987dcb 100644 --- a/tests/engine/pc_release.lua +++ b/tests/engine/pc_release.lua @@ -1,10 +1,10 @@ -- Headless regression: two Bill's PC releases in one list session (#171). +-- ROM-free: fixture species only (CI has no data/generated/). package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local T = require("tests.harness") -local Data = require("src.core.Data") -if not (Data.pokemon and Data.pokemon.RATTATA) then Data:load() end +local T = require("tests.modkit") +local Data = T.fixtures.load() +local ids = T.fixtures.ids require("src.render.Font").load(Data) local Pokemon = require("src.pokemon.Pokemon") @@ -45,10 +45,11 @@ local game = { game.save.options = game.save.options or {} game.save.options.textSpeed = 1 +local a, b, c = ids.species[1], ids.species[2], ids.species[3] local box = Boxes.active(game.save) -box[1] = Pokemon.new(Data, "RATTATA", 5) -box[2] = Pokemon.new(Data, "PIDGEY", 6) -box[3] = Pokemon.new(Data, "CATERPIE", 4) +box[1] = Pokemon.new(Data, a, 5) +box[2] = Pokemon.new(Data, b, 6) +box[3] = Pokemon.new(Data, c, 4) local function press(btn) pressed = { [btn] = true } @@ -86,7 +87,7 @@ releaseCurrent() releaseCurrent() T.eq(#box, 1, "two releases leave one mon") T.check(topMt() == ListMenu, "still on RELEASE list after the second") -T.eq(box[1].species, "CATERPIE", "remaining mon is the third seeded one") +T.eq(box[1].species, c, "remaining mon is the third seeded one") Sound.playCry, Sound.play = realCry, realPlay T.finish("pc_release") diff --git a/tests/engine/save_file_io_tests.lua b/tests/engine/save_file_io_tests.lua index 1cb765ae..046a6e84 100644 --- a/tests/engine/save_file_io_tests.lua +++ b/tests/engine/save_file_io_tests.lua @@ -51,10 +51,17 @@ local function fresh() end -- ---- crosswalk data + synthetic 32KB save (built the way the codec tests do) +-- Gen1 encode/decode needs Red species/item indices from data/generated/, +-- which CI never has. Skip cleanly so the ROM-free T1/T2 tier stays green. +local loadPokemon = loadfile("data/generated/pokemon.lua") +if not loadPokemon then + print("save_file_io skipped (needs data/generated/ for Gen1 save codec)") + os.exit(0) +end GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) local data = { - pokemon = loadfile("data/generated/pokemon.lua")(), + pokemon = loadPokemon(), moves = loadfile("data/generated/moves.lua")(), items = loadfile("data/generated/items.lua")(), maps = loadfile("data/generated/maps.lua")(), diff --git a/tests/engine/trade_anim_tests.lua b/tests/engine/trade_anim_tests.lua index 373d867f..1713ec37 100644 --- a/tests/engine/trade_anim_tests.lua +++ b/tests/engine/trade_anim_tests.lua @@ -1,10 +1,11 @@ -- TradeAnim InternalClockTradeFuncSequence completes under A-skip and -- exposes the cable-trade phases (engine/movie/trade.asm). +-- ROM-free: fixture species only (CI has no data/generated/). 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 T = require("tests.modkit") +local Data = T.fixtures.load() +local ids = T.fixtures.ids local S = require("tests.harness").suite("trade anim") local check, eq = S.check, S.eq @@ -22,8 +23,8 @@ Game.stack = StateStack; StateStack:init() Game.save = SaveData.newGame() require("src.render.Font").load(Data) -local sent = Pokemon.new(Data, "SPEAROW", 10) -local recv = Pokemon.new(Data, "FARFETCHD", 10) +local sent = Pokemon.new(Data, ids.species[1], 10) +local recv = Pokemon.new(Data, ids.species[2], 10) recv.nickname = "DUX" recv.ot = "TRAINER" recv.otId = 8193