feat: reconstruct standard battle continuations

This commit is contained in:
MaxTomahawk
2026-08-07 16:32:42 +02:00
parent 85c6fde443
commit 44a7910c69
7 changed files with 196 additions and 8 deletions
+9
View File
@@ -98,6 +98,15 @@ function BattleState:bgMode()
return "white"
end
-- Resume a semantic checkpoint directly at the command menu. Unlike enter(),
-- this deliberately does not replay the battle transition, intro queues,
-- cries, happiness changes, or battle-start events.
function BattleState:resumeCheckpoint()
self.isOpaque = self:bgMode() ~= "world"
require("src.core.Music").playBattle(self.data,
self.musicKind or self:computeMusicKind())
end
-- How far to dim the overworld behind a "world" background, 0..1. Enough
-- that the battle reads as the foreground rather than competing with a fully
-- lit map behind it.
+16 -2
View File
@@ -111,6 +111,12 @@ function BattleCheckpoint.validate(game, checkpoint)
return nil, "battle_origin_unsupported",
"Battle continuation data is unsupported or inconsistent."
end
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
or model.origin.trainerClass ~= model.oppClass
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
return nil, "battle_origin_unsupported",
"Trainer continuation data is incomplete or inconsistent."
end
local party = checkpoint.save.party
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
return nil, "invalid_content", "Player battle state is invalid."
@@ -150,7 +156,11 @@ end
local function applyBattler(target, captured, copy)
for _, field in ipairs(BATTLER_FIELDS) do
if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then
target[field] = captured[field] ~= nil and clone(captured[field], copy) or nil
if captured[field] ~= nil then
target[field] = clone(captured[field], copy)
else
target[field] = nil
end
end
end
target.curStats = captured.curStatsFromMon and target.mon.stats
@@ -192,7 +202,11 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
applyBattler(battle.enemy, model.enemy, copy)
for _, field in ipairs(BATTLE_FIELDS) do
battle[field] = model[field] ~= nil and clone(model[field], copy) or nil
if model[field] ~= nil then
battle[field] = clone(model[field], copy)
else
battle[field] = nil
end
end
battle.kind = model.kind
battle.checkpointOrigin = assert(copy(model.origin))
+11
View File
@@ -1143,4 +1143,15 @@ function Game:restoreCheckpointSave(loaded)
{ via = "checkpoint", checkpoint = true })
end
-- Install a reconstructed battle without calling BattleState:enter(), whose
-- transition, intro queues and battle-start side effects already happened in
-- the checkpointed timeline.
function Game:restoreCheckpointBattle(battle)
if self.stack:top() ~= self.overworld then
error("battle checkpoint requires a reconstructed overworld base", 0)
end
self.stack.states[#self.stack.states + 1] = battle
if battle.resumeCheckpoint then battle:resumeCheckpoint() end
end
return Game
+45
View File
@@ -3059,6 +3059,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
if theme then require("src.core.Music").play(Game.data, theme) end
end
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
battle.checkpointOrigin = {
kind = "trainer_encounter",
map = self.map.id,
npcId = npc.id,
trainerClass = d.trainerClass,
partyIndex = d.trainerParty or 1,
event = header and header.event or nil,
}
-- PrintEndBattleText (home/trainers.asm:341) is called from
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
@@ -3596,6 +3604,10 @@ function OverworldState:onStepComplete()
end
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(Game, enc.species, enc.level)
battle.checkpointOrigin = {
kind = "wild_encounter",
map = self.map.id,
}
-- map.ghostBattles: unidentifiable without the named item (the
-- Pokemon Tower's Silph Scope)
local ghost = Map.ghostBattles(self.map.def)
@@ -4000,6 +4012,39 @@ function OverworldState:afterBattle(result, battle)
end
end
-- Rebind the data-only continuation attached to a supported battle checkpoint.
-- The overworld was reconstructed first, so transient input/NPC freezes from
-- the original encounter are intentionally not resumed.
function OverworldState:restoreBattleContinuation(battle, origin)
local game = battle and battle.game
if not game or type(origin) ~= "table" or not self.map
or origin.map ~= self.map.id then
return false
end
if origin.kind == "wild_encounter" and battle.kind == "wild" then
battle.onFinish = function(result) self:afterBattle(result, battle) end
return true
end
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
or origin.trainerClass ~= battle.oppClass
or origin.partyIndex ~= (battle.partyIndex or 1)
or type(origin.npcId) ~= "string" then
return false
end
battle.onFinish = function(result)
if result == "win" then
game.save.defeatedTrainers[origin.npcId] = true
if origin.event then game.save.flags[origin.event] = true end
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
end
self:afterBattle(result, battle)
self.engaging = false
local npc = self.npcPool and self.npcPool[origin.npcId]
if npc then npc.frozen = false end
end
return true
end
-- -------------------------------------------------------------------------
-- warps
-- -------------------------------------------------------------------------
+2 -1
View File
@@ -46,7 +46,8 @@ local function makeGame(kind)
if kind == "trainer" then
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.checkpointOrigin = {
kind = "trainer_encounter", map = "FIX_TOWN", npc = "TRAINER_1",
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
event = "EVENT_BEAT_TRAINER_1",
}
else
@@ -0,0 +1,102 @@
-- Engine-owned battle continuations replace unserializable onFinish closures
-- after a persistent checkpoint reconstructs the overworld and battle.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint continuation")
local GameMethods = require("src.core.Game")
local OverworldState = require("src.world.OverworldController")
local function fakeOverworld()
local npc = { id = "FIX_TOWN_obj_1", frozen = true }
local ow = setmetatable({
map = { id = "FIX_TOWN" },
npcPool = { [npc.id] = npc },
engaging = true,
}, { __index = OverworldState })
ow.afterBattle = function(self, result, battle)
self.after = { result = result, battle = battle }
end
ow.checkVictoryRewards = function(self, class, party)
self.reward = { class = class, party = party }
end
return ow, npc
end
local wildOw = fakeOverworld()
local wildGame = { save = { defeatedTrainers = {}, flags = {} } }
local wild = { game = wildGame, kind = "wild" }
T.check(wildOw:restoreBattleContinuation(wild,
{ kind = "wild_encounter", map = "FIX_TOWN" }) == true,
"ordinary wild continuation binds")
wild.onFinish("run")
T.same(wildOw.after, { result = "run", battle = wild },
"wild continuation returns through canonical afterBattle")
local trainerOw, trainerNpc = fakeOverworld()
local trainerGame = { save = { defeatedTrainers = {}, flags = {} } }
local trainer = {
game = trainerGame, kind = "trainer",
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
}
local trainerOrigin = {
kind = "trainer_encounter", map = "FIX_TOWN",
npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1,
event = "EVENT_BEAT_FIX_TRAINER",
}
T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true,
"ordinary trainer continuation binds")
trainer.onFinish("win")
T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true,
"trainer win stamps the stable object id")
T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true,
"trainer win stamps the header event")
T.same(trainerOw.reward,
{ class = "OPP_FIX_YOUNGSTER", party = 1 },
"trainer win runs canonical victory rewards")
T.same(trainerOw.after, { result = "win", battle = trainer },
"trainer win returns through canonical afterBattle")
T.check(trainerOw.engaging == false and trainerNpc.frozen == false,
"reconstructed trainer completion leaves overworld input unfrozen")
local lossOw, lossNpc = fakeOverworld()
local lossGame = { save = { defeatedTrainers = {}, flags = {} } }
local lossBattle = {
game = lossGame, kind = "trainer",
oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
}
T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true,
"trainer loss continuation binds")
lossBattle.onFinish("lose")
T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil,
"trainer loss does not stamp the trainer defeated")
T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil,
"trainer loss does not stamp the header event")
T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards")
local mismatchOw = fakeOverworld()
T.check(mismatchOw:restoreBattleContinuation(trainer, {
kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id,
trainerClass = trainer.oppClass, partyIndex = 1,
}) == false, "continuation from another map is rejected")
T.check(mismatchOw:restoreBattleContinuation(trainer, {
kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id,
trainerClass = "OPP_OTHER", partyIndex = 1,
}) == false, "mismatched trainer identity is rejected")
local ow = {}
local stack = { states = { ow } }
function stack:top() return self.states[#self.states] end
local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods })
local entered, resumed = false, false
local battle = {
enter = function() entered = true end,
resumeCheckpoint = function() resumed = true end,
}
game:restoreCheckpointBattle(battle)
T.check(game.stack:top() == battle, "reconstructed battle is installed on stack")
T.check(resumed == true, "checkpoint-specific battle resume path runs")
T.check(entered == false, "ordinary battle intro is not replayed")
T.finish()
+11 -5
View File
@@ -8,6 +8,8 @@ local T = require("tests.harness").suite("battle checkpoint restore")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local GameMethods = require("src.core.Game")
local Music = require("src.core.Music")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
@@ -15,6 +17,8 @@ local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local oldRandom = love.math.random
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
local oldPlayBattle = Music.playBattle
Music.playBattle = function() end
local rng = 12345
love.math.getRandomState = function() return tostring(rng) end
love.math.setRandomState = function(state) rng = assert(tonumber(state)) end
@@ -59,7 +63,9 @@ local function makeGame(kind)
end
return true
end
local game = { data = Data, save = save, stack = stack, overworld = overworld }
local game = setmetatable(
{ data = Data, save = save, stack = stack, overworld = overworld },
{ __index = GameMethods })
function game:restoreCheckpointSave(loaded)
self.save = loaded
self.overworld.map = { id = loaded.player.map }
@@ -73,15 +79,13 @@ local function makeGame(kind)
self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {}
self.stack.states = { self.overworld }
end
function game:restoreCheckpointBattle(battle)
self.stack.states[#self.stack.states + 1] = battle
end
stack.states[1] = overworld
local battle
if kind == "trainer" then
battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.checkpointOrigin = {
kind = "trainer_encounter", map = "FIX_TOWN", npc = "TRAINER_1",
kind = "trainer_encounter", map = "FIX_TOWN", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 1,
event = "EVENT_BEAT_TRAINER_1",
}
else
@@ -89,6 +93,7 @@ local function makeGame(kind)
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
end
battle.phase, battle.queue = "menu", {}
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
@@ -168,4 +173,5 @@ end
love.math.random = oldRandom
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
Music.playBattle = oldPlayBattle
T.finish()