diff --git a/src/world/WorldAPI.lua b/src/world/WorldAPI.lua index e0a5d9db..566afc9d 100644 --- a/src/world/WorldAPI.lua +++ b/src/world/WorldAPI.lua @@ -8,6 +8,7 @@ local Logger = require("src.core.Logger") local Assets = require("src.render.Assets") local MapLoader = require("src.world.MapLoader") +local Party = require("src.pokemon.Party") local Runtime = require("src.mods.Runtime") local WorldAPI = {} @@ -265,6 +266,49 @@ 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 + -- 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 ~= 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) + 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/mod_world_tests.lua b/tests/mod_world_tests.lua index 08319cb4..4deb2640 100644 --- a/tests/mod_world_tests.lua +++ b/tests/mod_world_tests.lua @@ -916,6 +916,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