From 9fc6ba5b01922fe6d6332cfc49627bea05793b3b Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Fri, 7 Aug 2026 22:57:33 +0200 Subject: [PATCH 1/3] Add mod.world:startWildBattle Starting a wild encounter had no supported entry point, so mods built a BattleState and pushed it themselves -- silently losing onFinish (and with it evolutions and blackout-on-loss) and pushBattle (entry wipe, battle theme). Neither failure raises. Also covers awardExp -> leveledUp -> afterBattle -> checkParty, which parity_trainer_evolution_order stubs BattleState out of. --- src/world/WorldAPI.lua | 22 +++++ tests/parity_world_start_wild_battle.lua | 112 +++++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 tests/parity_world_start_wild_battle.lua diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 6adadc51..21fe9d87 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -164,6 +164,28 @@ function WorldAPI:queueScript(rows, extra) return true end +-- The supported way to start a wild encounter. Hand-rolling this -- build a +-- BattleState, push it -- silently costs evolutions and blackout-on-loss +-- (both hang off onFinish -> afterBattle) plus the entry wipe and battle +-- theme (both owned by pushBattle). Nothing raises when they are missing. +function WorldAPI:startWildBattle(species, level) + local ow = self:overworld() + if not ow then return nil, NO_OVERWORLD end + if not self.game.data.pokemon[species] then + return nil, "unknown species: " .. tostring(species) + end + level = tonumber(level) + if not level or level < 1 or level > 100 then + return nil, "level must be 1..100" + end + local battle = require("src.battle.BattleState") + .newWild(self.game, species, level) + if battle.dead then return nil, "no healthy party" end + battle.onFinish = function(result) ow:afterBattle(result, battle) end + ow:pushBattle(battle) + return true +end + -- drop a map's cached instance so the next load re-reads its record; when -- it is the active map the world reloads around the player in place function WorldAPI:invalidateMap(mapId) diff --git a/tests/parity_world_start_wild_battle.lua b/tests/parity_world_start_wild_battle.lua new file mode 100644 index 00000000..15ad79ed --- /dev/null +++ b/tests/parity_world_start_wild_battle.lua @@ -0,0 +1,112 @@ +-- mod.world:startWildBattle. What regresses is the handoff, not the battle: +-- a mod that pushes its own BattleState still fights and still levels, it just +-- loses onFinish -> afterBattle (evolutions, blackout-on-loss) and pushBattle +-- (entry wipe, battle theme), silently. Shipped mods have hit exactly this. +-- +-- T3, not the ROM-free tier: the handoff only exists once a real map is up +-- (OverworldState:enter binds the module-local Game and MapLoader needs real +-- tilesets), and the fixture dataset carries neither. + +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 then Data:load() end +local S = require("tests.harness").suite("parity world startWildBattle") +local check = S.check + +local Game = require("src.core.Game") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local Pokemon = require("src.pokemon.Pokemon") +local WorldAPI = require("src.world.WorldAPI") +local OverworldState = require("src.world.OverworldController") +require("src.render.Font").load(Data) + +-- data/generated/audio.lua only exists after an in-app ROM import; the Python +-- developer builder has no audio stage, so Music's entry points can be absent. +-- setMap plays map music on the way in, which is incidental to the handoff +-- under test -- no-op whatever is missing rather than depend on the dataset. +-- run_tests.lua dofiles every suite into one process, so this is restored at +-- the end: leaving it patched makes later suites pass that otherwise fail. +local Music = require("src.core.Music") +local musicSaved = {} +for _, fn in ipairs({ "playMap", "setSurfing", "playBattle" }) do + -- `or false` so a genuinely-absent entry point is still recorded and gets + -- restored to nil; storing the nil directly would drop the key entirely + musicSaved[fn] = Music[fn] or false + Music[fn] = Music[fn] or function() end +end +local function restoreMusic() + for fn, orig in pairs(musicSaved) do Music[fn] = orig or nil end +end + +local function freshWorld() + Game.data = Data + Game.save = SaveData.newGame() + Game.stack = StateStack; StateStack:init() + Game.renderer = { worldViewSize = function() return 160, 144 end } + Game.overworld = OverworldState + OverworldState:enter("ROUTE_1", 5, 5, "down") + return WorldAPI.new(Game, "testmod") +end + +-- ------- argument handling: every failure is a nil + reason, never a throw + +local world = WorldAPI.new({ data = Data }, "testmod") +local ok, err = world:startWildBattle("PIDGEY", 5) +check(ok == nil, "no overworld up refuses") +check(err == "no overworld", "and says so") + +world = freshWorld() +Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } + +ok, err = world:startWildBattle("NOT_A_MON", 5) +check(ok == nil, "an unknown species refuses") +check(err and err:find("unknown species", 1, true), "and names the species") + +for _, lv in ipairs({ 0, 101, "nope" }) do + check(world:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") +end + +-- ------- the handoff: a level-up evolution is offered after the win + +world = freshWorld() +local caterpie = Pokemon.new(Data, "CATERPIE", 6) +Game.save.party = { caterpie } + +check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + +-- pushBattle pushes the transition, which pushes the battle from its callback +local top = Game.stack:top() +check(top ~= nil, "something was pushed") +check(top.screenId ~= "BattleState", "the entry transition goes on first") + +local battle +for _ = 1, 400 do + local t = Game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end +end +check(battle ~= nil, "the transition hands off to the battle") + +battle.participants = { [caterpie] = true } +battle:awardExp() +check(caterpie.level >= 7, "the mon levels past its evolution threshold") +check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") + +Game.stack:pop() +battle.onFinish("win") +for _ = 1, 12 do + local t = Game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + Game.stack:pop() + if t.onDone then t.onDone() end +end +check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") + +restoreMusic() +S.finish() From a710c64909ef149f198640a6f72733db4161e0f1 Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Sat, 8 Aug 2026 12:23:05 +0200 Subject: [PATCH 2/3] Refuse re-entrant, fractional-level and no-party wild battles overworld() resolves the world from under the stack, so a call from a battle hook stacked a second battle over the live one -- on a loss its afterBattle blacked out and warped with the outer battle still up. newWild marks the species SEEN before it reports an empty party, so a refused call still wrote the Pokedex; test the party before building it. tonumber accepts 5.5, which Pokemon.new writes straight into the stat calc and the exp curve. --- src/world/WorldAPI.lua | 28 +++++- tests/parity_world_start_wild_battle.lua | 104 +++++++++++++---------- 2 files changed, 86 insertions(+), 46 deletions(-) diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index 21fe9d87..fb2488af 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -7,6 +7,7 @@ local Logger = require("src.core.Logger") local MapLoader = require("src.world.MapLoader") +local Party = require("src.pokemon.Party") local Runtime = require("src.mods.Runtime") local WorldAPI = {} @@ -174,13 +175,34 @@ function WorldAPI:startWildBattle(species, level) if not self.game.data.pokemon[species] then return nil, "unknown species: " .. tostring(species) end + -- Pokemon.new writes the level through verbatim -- into level, the stat + -- calc and the exp curve -- so a fraction has to be refused here rather + -- than round somewhere downstream. The % test also catches NaN, which + -- passes both range comparisons. level = tonumber(level) - if not level or level < 1 or level > 100 then - return nil, "level must be 1..100" + if not level or level % 1 ~= 0 or level < 1 or level > 100 then + return nil, "level must be a whole number 1..100" + end + -- overworld() resolves the world from UNDER whatever sits on top of it, + -- so from a battle hook this would otherwise stack a second battle over + -- the live one -- and on a loss its afterBattle blacks out and warps + -- with the outer battle still on the stack. + local BattleTransition = require("src.render.BattleTransition") + for _, state in ipairs(self.game.stack and self.game.stack.states or {}) do + if state.awardExp or getmetatable(state) == BattleTransition then + return nil, "a battle is already running" + end + end + if ow.transitioning then return nil, "the world is mid-warp" end + -- BattleState.newWild marks the species SEEN before it reports an empty + -- party, so the party check comes first: a refused call must not leave a + -- Pokedex entry behind. + local save = self.game.save + if not (save and Party.firstHealthy(save.party or {})) then + return nil, "no healthy party" end local battle = require("src.battle.BattleState") .newWild(self.game, species, level) - if battle.dead then return nil, "no healthy party" end battle.onFinish = function(result) ow:afterBattle(result, battle) end ow:pushBattle(battle) return true diff --git a/tests/parity_world_start_wild_battle.lua b/tests/parity_world_start_wild_battle.lua index 15ad79ed..d9ee6cec 100644 --- a/tests/parity_world_start_wild_battle.lua +++ b/tests/parity_world_start_wild_battle.lua @@ -51,62 +51,80 @@ local function freshWorld() return WorldAPI.new(Game, "testmod") end --- ------- argument handling: every failure is a nil + reason, never a throw +-- The assertions run under pcall so the Music patch is handed back even when +-- one of them throws: run_tests.lua dofiles the later suites into this same +-- process, and a Music left stubbed lets their own music checks pass. +local function body() + -- ----- argument handling: every failure is a nil + reason, never a throw -local world = WorldAPI.new({ data = Data }, "testmod") -local ok, err = world:startWildBattle("PIDGEY", 5) -check(ok == nil, "no overworld up refuses") -check(err == "no overworld", "and says so") + local world = WorldAPI.new({ data = Data }, "testmod") + local ok, err = world:startWildBattle("PIDGEY", 5) + check(ok == nil, "no overworld up refuses") + check(err == "no overworld", "and says so") -world = freshWorld() -Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } + world = freshWorld() + Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } -ok, err = world:startWildBattle("NOT_A_MON", 5) -check(ok == nil, "an unknown species refuses") -check(err and err:find("unknown species", 1, true), "and names the species") + ok, err = world:startWildBattle("NOT_A_MON", 5) + check(ok == nil, "an unknown species refuses") + check(err and err:find("unknown species", 1, true), "and names the species") -for _, lv in ipairs({ 0, 101, "nope" }) do - check(world:startWildBattle("PIDGEY", lv) == nil, - "level " .. tostring(lv) .. " refuses") -end + -- 5.5 too: Pokemon.new writes the level through into the stat calc and the + -- exp curve verbatim, so a fraction has to be refused, not rounded + for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do + check(world:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") + end --- ------- the handoff: a level-up evolution is offered after the win + -- ----- the handoff: a level-up evolution is offered after the win -world = freshWorld() -local caterpie = Pokemon.new(Data, "CATERPIE", 6) -Game.save.party = { caterpie } + world = freshWorld() + local caterpie = Pokemon.new(Data, "CATERPIE", 6) + Game.save.party = { caterpie } -check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") --- pushBattle pushes the transition, which pushes the battle from its callback -local top = Game.stack:top() -check(top ~= nil, "something was pushed") -check(top.screenId ~= "BattleState", "the entry transition goes on first") + -- pushBattle pushes the transition, which pushes the battle from its + -- callback. awardExp is the BattleState marker the drain loop below + -- identifies it by; screenId would NOT work here -- only Screens.push + -- stamps that, and pushBattle pushes the battle straight onto the stack, + -- so `screenId ~= "BattleState"` holds even with the transition skipped. + local top = Game.stack:top() + check(top ~= nil, "something was pushed") + check(top.awardExp == nil, "the entry transition goes on first") -local battle -for _ = 1, 400 do - local t = Game.stack:top() - if t and t.awardExp then battle = t break end - if t and t.update then t:update(1 / 60) else break end -end -check(battle ~= nil, "the transition hands off to the battle") + -- overworld() resolves the world from under the battle, so a second call + -- while one is up has to refuse rather than stack another + check(world:startWildBattle("PIDGEY", 5) == nil, + "a battle already running refuses") -battle.participants = { [caterpie] = true } -battle:awardExp() -check(caterpie.level >= 7, "the mon levels past its evolution threshold") -check(battle.leveledUp and battle.leveledUp[caterpie], - "awardExp records the level-up for EvolveAfterBattle") + local battle + for _ = 1, 400 do + local t = Game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end + end + check(battle ~= nil, "the transition hands off to the battle") + + battle.participants = { [caterpie] = true } + battle:awardExp() + check(caterpie.level >= 7, "the mon levels past its evolution threshold") + check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") -Game.stack:pop() -battle.onFinish("win") -for _ = 1, 12 do - local t = Game.stack:top() - if not t or t.screenId == "EvolutionState" then break end Game.stack:pop() - if t.onDone then t.onDone() end + battle.onFinish("win") + for _ = 1, 12 do + local t = Game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + Game.stack:pop() + if t.onDone then t.onDone() end + end + check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") end -check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", - "the win reaches the evolution screen") +local ran, runErr = pcall(body) restoreMusic() +if not ran then error(runErr, 0) end S.finish() From 492e0344b54c9a406e1040986351f8642b18f4e5 Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Sat, 8 Aug 2026 12:35:06 +0200 Subject: [PATCH 3/3] Fold the startWildBattle coverage into mod_world_tests mod_world_tests is already the mod.world suite and already the T3 tier, with the same off-the-world refusal pattern the standalone file was duplicating. Reuses its liveWorld fixture instead of standing up a second one. --- tests/mod_world_tests.lua | 70 ++++++++++++ tests/parity_world_start_wild_battle.lua | 130 ----------------------- 2 files changed, 70 insertions(+), 130 deletions(-) delete mode 100644 tests/parity_world_start_wild_battle.lua diff --git a/tests/mod_world_tests.lua b/tests/mod_world_tests.lua index 60aefb97..32732000 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -893,6 +893,76 @@ do check(value == nil and err == "no overworld", "npc() off the world") value, err = api:queueScript({}) check(value == nil and err == "no overworld", "queueScript() off the world") + value, err = api:startWildBattle("PIDGEY", 5) + check(value == nil and err == "no overworld", "startWildBattle() off the world") +end + +-- startWildBattle: what regresses is the handoff, not the battle. A mod that +-- builds a BattleState and pushes it itself still fights and still levels; it +-- silently loses onFinish -> afterBattle (evolutions, blackout-on-loss) and +-- pushBattle (entry wipe, battle theme). Shipped mods have hit exactly this. +do + -- the real dataset, not fixture(): a battle reaches for type_chart, items, + -- battle_anims and more, and this block only reads + local data = Data + local state, game = liveWorld(data) + -- the handoff runs through these three, so each needs the live game + for _, fn in ipairs({ "pushBattle", "isDungeonTransitionMap", "afterBattle" }) do + check(bindGame(OW[fn], game), fn .. " binds Game") + end + state:setMap("PALLET_TOWN", 5, 6, "down", { via = "boot" }) + + local Pokemon = require("src.pokemon.Pokemon") + local api = WorldAPI.new(game, "tester") + + local value, err = api:startWildBattle("NOT_A_MON", 5) + check(value == nil and err:find("unknown species", 1, true), + "an unknown species refuses and names it") + -- Pokemon.new writes the level through into the stat calc and the exp curve + -- verbatim, so a fraction has to be refused rather than rounded downstream + for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do + check(api:startWildBattle("PIDGEY", lv) == nil, + "level " .. tostring(lv) .. " refuses") + end + + local caterpie = Pokemon.new(data, "CATERPIE", 6) + game.save.party = { caterpie } + check(api:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") + + -- pushBattle pushes the entry transition, which pushes the battle from its + -- own callback; awardExp is the BattleState marker (screenId would not work, + -- only Screens.push stamps that and pushBattle pushes the battle directly) + check(game.stack:top() ~= nil and game.stack:top().awardExp == nil, + "the entry transition goes on first") + -- overworld() resolves the world from UNDER the battle, so a second call + -- while one is up has to refuse rather than stack another + check(api:startWildBattle("PIDGEY", 5) == nil, + "a battle already running refuses") + + local battle + for _ = 1, 400 do + local t = game.stack:top() + if t and t.awardExp then battle = t break end + if t and t.update then t:update(1 / 60) else break end + end + check(battle ~= nil, "the transition hands off to the battle") + + battle.participants = { [caterpie] = true } + battle:awardExp() + check(caterpie.level >= 7, "the mon levels past its evolution threshold") + check(battle.leveledUp and battle.leveledUp[caterpie], + "awardExp records the level-up for EvolveAfterBattle") + + game.stack:pop() + battle.onFinish("win") + for _ = 1, 12 do + local t = game.stack:top() + if not t or t.screenId == "EvolutionState" then break end + game.stack:pop() + if t.onDone then t.onDone() end + end + check(game.stack:top() and game.stack:top().screenId == "EvolutionState", + "the win reaches the evolution screen") end do diff --git a/tests/parity_world_start_wild_battle.lua b/tests/parity_world_start_wild_battle.lua deleted file mode 100644 index d9ee6cec..00000000 --- a/tests/parity_world_start_wild_battle.lua +++ /dev/null @@ -1,130 +0,0 @@ --- mod.world:startWildBattle. What regresses is the handoff, not the battle: --- a mod that pushes its own BattleState still fights and still levels, it just --- loses onFinish -> afterBattle (evolutions, blackout-on-loss) and pushBattle --- (entry wipe, battle theme), silently. Shipped mods have hit exactly this. --- --- T3, not the ROM-free tier: the handoff only exists once a real map is up --- (OverworldState:enter binds the module-local Game and MapLoader needs real --- tilesets), and the fixture dataset carries neither. - -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 then Data:load() end -local S = require("tests.harness").suite("parity world startWildBattle") -local check = S.check - -local Game = require("src.core.Game") -local SaveData = require("src.core.SaveData") -local StateStack = require("src.core.StateStack") -local Pokemon = require("src.pokemon.Pokemon") -local WorldAPI = require("src.world.WorldAPI") -local OverworldState = require("src.world.OverworldController") -require("src.render.Font").load(Data) - --- data/generated/audio.lua only exists after an in-app ROM import; the Python --- developer builder has no audio stage, so Music's entry points can be absent. --- setMap plays map music on the way in, which is incidental to the handoff --- under test -- no-op whatever is missing rather than depend on the dataset. --- run_tests.lua dofiles every suite into one process, so this is restored at --- the end: leaving it patched makes later suites pass that otherwise fail. -local Music = require("src.core.Music") -local musicSaved = {} -for _, fn in ipairs({ "playMap", "setSurfing", "playBattle" }) do - -- `or false` so a genuinely-absent entry point is still recorded and gets - -- restored to nil; storing the nil directly would drop the key entirely - musicSaved[fn] = Music[fn] or false - Music[fn] = Music[fn] or function() end -end -local function restoreMusic() - for fn, orig in pairs(musicSaved) do Music[fn] = orig or nil end -end - -local function freshWorld() - Game.data = Data - Game.save = SaveData.newGame() - Game.stack = StateStack; StateStack:init() - Game.renderer = { worldViewSize = function() return 160, 144 end } - Game.overworld = OverworldState - OverworldState:enter("ROUTE_1", 5, 5, "down") - return WorldAPI.new(Game, "testmod") -end - --- The assertions run under pcall so the Music patch is handed back even when --- one of them throws: run_tests.lua dofiles the later suites into this same --- process, and a Music left stubbed lets their own music checks pass. -local function body() - -- ----- argument handling: every failure is a nil + reason, never a throw - - local world = WorldAPI.new({ data = Data }, "testmod") - local ok, err = world:startWildBattle("PIDGEY", 5) - check(ok == nil, "no overworld up refuses") - check(err == "no overworld", "and says so") - - world = freshWorld() - Game.save.party = { Pokemon.new(Data, "CATERPIE", 6) } - - ok, err = world:startWildBattle("NOT_A_MON", 5) - check(ok == nil, "an unknown species refuses") - check(err and err:find("unknown species", 1, true), "and names the species") - - -- 5.5 too: Pokemon.new writes the level through into the stat calc and the - -- exp curve verbatim, so a fraction has to be refused, not rounded - for _, lv in ipairs({ 0, 101, "nope", 5.5 }) do - check(world:startWildBattle("PIDGEY", lv) == nil, - "level " .. tostring(lv) .. " refuses") - end - - -- ----- the handoff: a level-up evolution is offered after the win - - world = freshWorld() - local caterpie = Pokemon.new(Data, "CATERPIE", 6) - Game.save.party = { caterpie } - - check(world:startWildBattle("PIDGEY", 25) == true, "a wild battle starts") - - -- pushBattle pushes the transition, which pushes the battle from its - -- callback. awardExp is the BattleState marker the drain loop below - -- identifies it by; screenId would NOT work here -- only Screens.push - -- stamps that, and pushBattle pushes the battle straight onto the stack, - -- so `screenId ~= "BattleState"` holds even with the transition skipped. - local top = Game.stack:top() - check(top ~= nil, "something was pushed") - check(top.awardExp == nil, "the entry transition goes on first") - - -- overworld() resolves the world from under the battle, so a second call - -- while one is up has to refuse rather than stack another - check(world:startWildBattle("PIDGEY", 5) == nil, - "a battle already running refuses") - - local battle - for _ = 1, 400 do - local t = Game.stack:top() - if t and t.awardExp then battle = t break end - if t and t.update then t:update(1 / 60) else break end - end - check(battle ~= nil, "the transition hands off to the battle") - - battle.participants = { [caterpie] = true } - battle:awardExp() - check(caterpie.level >= 7, "the mon levels past its evolution threshold") - check(battle.leveledUp and battle.leveledUp[caterpie], - "awardExp records the level-up for EvolveAfterBattle") - - Game.stack:pop() - battle.onFinish("win") - for _ = 1, 12 do - local t = Game.stack:top() - if not t or t.screenId == "EvolutionState" then break end - Game.stack:pop() - if t.onDone then t.onDone() end - end - check(Game.stack:top() and Game.stack:top().screenId == "EvolutionState", - "the win reaches the evolution screen") -end - -local ran, runErr = pcall(body) -restoreMusic() -if not ran then error(runErr, 0) end -S.finish()