diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b81041e3..86b2ab18 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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. diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index bd5bda56..b424034f 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -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)) diff --git a/src/core/Game.lua b/src/core/Game.lua index 27d585b0..5226dd16 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 8f12c267..1ac89816 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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 -- ------------------------------------------------------------------------- diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua index 98c3512b..13f7b16a 100644 --- a/tests/engine/battle_checkpoint_capture.lua +++ b/tests/engine/battle_checkpoint_capture.lua @@ -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 diff --git a/tests/engine/battle_checkpoint_continuation.lua b/tests/engine/battle_checkpoint_continuation.lua new file mode 100644 index 00000000..3219119f --- /dev/null +++ b/tests/engine/battle_checkpoint_continuation.lua @@ -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() diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua index 46ccc934..95f23c80 100644 --- a/tests/engine/battle_checkpoint_restore.lua +++ b/tests/engine/battle_checkpoint_restore.lua @@ -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()