From a710c64909ef149f198640a6f72733db4161e0f1 Mon Sep 17 00:00:00 2001 From: Jakub Lisicki Date: Sat, 8 Aug 2026 12:23:05 +0200 Subject: [PATCH] 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()