feat: reconstruct battle checkpoints from data

This commit is contained in:
MaxTomahawk
2026-08-07 16:27:54 +02:00
parent 67b6dcc293
commit 85c6fde443
5 changed files with 402 additions and 4 deletions
+182 -1
View File
@@ -3,6 +3,7 @@
-- facade in Loader.
local BattleCheckpoint = {}
local BattleState = require("src.battle.BattleState")
local BATTLER_FIELDS = {
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
@@ -42,13 +43,193 @@ local function indexSet(set, party)
end
local function captureBattler(battler, index, copy)
local out = { index = index }
local out = {
index = index,
curStatsFromMon = battler.curStats == battler.mon.stats,
curTypesFromDefinition = battler.curTypes == battler.def.types,
curMovesFromMon = battler.curMoves == battler.mon.moves,
}
for _, field in ipairs(BATTLER_FIELDS) do
if battler[field] ~= nil then out[field] = battler[field] end
end
return copy(out)
end
local function integer(value, min, max)
return type(value) == "number" and value % 1 == 0
and value >= (min or -math.huge) and value <= (max or math.huge)
end
local function validateMoveList(data, moves)
if type(moves) ~= "table" then return false end
for _, move in ipairs(moves) do
if type(move) ~= "table" or type(move.id) ~= "string"
or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then
return false
end
end
return true
end
local function validateMon(data, mon)
return type(mon) == "table" and type(mon.species) == "string"
and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100)
and type(mon.hp) == "number" and type(mon.stats) == "table"
and validateMoveList(data, mon.moves)
end
local function validateBattler(data, battler, maxIndex)
if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then
return false
end
if battler.stages ~= nil then
if type(battler.stages) ~= "table" then return false end
for _, stage in pairs(battler.stages) do
if not integer(stage, -6, 6) then return false end
end
end
return validateMoveList(data, battler.curMoves)
and type(battler.curStats) == "table" and type(battler.curTypes) == "table"
end
local function clone(value, copy)
if type(value) ~= "table" then return value end
return assert(copy(value))
end
function BattleCheckpoint.validate(game, checkpoint)
local model = checkpoint.runtime and checkpoint.runtime.battle
local rngState = checkpoint.rng and checkpoint.rng.love
if type(model) ~= "table" or type(model.origin) ~= "table"
or type(rngState) ~= "string" or rngState == "" then
return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing."
end
local expectedOrigin = model.kind == "wild" and "wild_encounter"
or model.kind == "trainer" and "trainer_encounter" or nil
if not expectedOrigin or model.origin.kind ~= expectedOrigin
or model.origin.map ~= checkpoint.runtime.overworld.map then
return nil, "battle_origin_unsupported",
"Battle continuation data is unsupported 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."
end
if model.kind == "wild" then
if not validateMon(game.data, model.enemyMon)
or not validateBattler(game.data, model.enemy, 1) then
return nil, "invalid_content", "Wild opponent state is invalid."
end
else
local trainer = game.data.trainers and game.data.trainers[model.oppClass]
if type(trainer) ~= "table" or not integer(model.partyIndex, 1)
or type(model.enemyParty) ~= "table" or #model.enemyParty == 0
or not integer(model.enemyIndex, 1, #model.enemyParty)
or not validateBattler(game.data, model.enemy, #model.enemyParty) then
return nil, "invalid_content", "Trainer battle identity or roster is invalid."
end
for _, mon in ipairs(model.enemyParty) do
if not validateMon(game.data, mon) then
return nil, "invalid_content", "Trainer opponent state is invalid."
end
end
end
for _, indices in ipairs({ model.participants, model.leveledUp }) do
if type(indices) ~= "table" then
return nil, "invalid_checkpoint", "Battle party reference set is missing."
end
for _, index in ipairs(indices) do
if not integer(index, 1, #party) then
return nil, "invalid_checkpoint", "Battle party reference is invalid."
end
end
end
return true
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
end
end
target.curStats = captured.curStatsFromMon and target.mon.stats
or assert(copy(captured.curStats))
target.curTypes = captured.curTypesFromDefinition and target.def.types
or assert(copy(captured.curTypes))
target.curMoves = captured.curMovesFromMon and target.mon.moves
or assert(copy(captured.curMoves))
return target
end
local function restoreIndexSet(indices, party)
local out = {}
for _, index in ipairs(indices or {}) do out[party[index]] = true end
return next(out) and out or nil
end
function BattleCheckpoint.restore(game, checkpoint, copy)
local model = checkpoint.runtime.battle
local battle
if model.kind == "trainer" then
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
battle.enemyParty = assert(copy(model.enemyParty))
battle.enemyIndex = model.enemyIndex
else
battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level)
end
battle.player = BattleState.makeBattler(game.data,
game.save.party[model.player.index], true, game.save)
applyBattler(battle.player, model.player, copy)
local enemyMon
if model.kind == "trainer" then
enemyMon = battle.enemyParty[model.enemy.index]
else
enemyMon = assert(copy(model.enemyMon))
end
battle.enemy = BattleState.makeBattler(game.data, enemyMon, false)
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
end
battle.kind = model.kind
battle.checkpointOrigin = assert(copy(model.origin))
battle.participants = restoreIndexSet(model.participants, game.save.party)
battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party)
battle.sides = assert(copy(model.sides))
battle.sides[1].battlers = { battle.player }
battle.sides[2].battlers = { battle.enemy }
battle.field = assert(copy(model.field))
battle.field.sides = battle.sides
battle.phase, battle.queue = "menu", {}
battle.frame = 0
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
battle.player.shownHP, battle.player.shownStatus =
battle.player.mon.hp, battle.player.mon.status
battle.enemy.shownHP, battle.enemy.shownStatus =
battle.enemy.mon.hp, battle.enemy.mon.status
local ow = game.overworld
if not ow or type(ow.restoreBattleContinuation) ~= "function"
or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then
error("battle continuation reconstruction is unavailable", 0)
end
if type(game.restoreCheckpointBattle) ~= "function" then
error("game has no battle checkpoint reconstruction path", 0)
end
game:restoreCheckpointBattle(battle)
local setState = love and love.math and love.math.setRandomState
if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end
setState(checkpoint.rng.love)
return battle
end
local function captureExtensions(battle, copy)
local sides = {}
for i = 1, 2 do
+46 -3
View File
@@ -81,6 +81,15 @@ local function inspectBattle(ow, battle)
return refusal("battle", "battle_phase_busy",
"Wait for an ordinary player decision before creating a checkpoint.")
end
for _, battler in ipairs({ battle.player, battle.enemy }) do
if battler.shownHP ~= battler.mon.hp
or battler.shownStatus ~= battler.mon.status
or battler.drainFloor ~= nil or battler.drainHold ~= nil
or battler.faintQueued then
return refusal("battle", "battle_phase_busy",
"Wait for battle status and HP presentation to settle.")
end
end
return { canCapture = true, canRestore = true, kind = "battle" }
end
@@ -221,8 +230,8 @@ local function validate(game, checkpoint)
if checkpoint.format ~= Checkpoint.FORMAT then
return nil, "unsupported_format", "This checkpoint format is not supported."
end
if checkpoint.kind ~= "overworld" then
return nil, "unsupported_runtime_kind", "Only overworld checkpoints are supported."
if checkpoint.kind ~= "overworld" and checkpoint.kind ~= "battle" then
return nil, "unsupported_runtime_kind", "This checkpoint runtime kind is not supported."
end
local copy, copyErr = dataCopy(checkpoint)
@@ -288,6 +297,13 @@ local function validate(game, checkpoint)
return nil, "invalid_content",
"Checkpoint references unavailable or invalid game content."
end
if copy.kind == "battle" then
local battleOk, battleCode, battleMessage = BattleCheckpoint.validate(game, copy)
if not battleOk then return nil, battleCode, battleMessage end
elseif copy.runtime.battle ~= nil or copy.rng ~= nil then
return nil, "invalid_checkpoint",
"Overworld checkpoint contains unexpected battle state."
end
return copy
end
@@ -305,6 +321,9 @@ local function apply(game, checkpoint, options)
error("game has no checkpoint reconstruction path", 0)
end
game:restoreCheckpointSave(save)
if checkpoint.kind == "battle" then
BattleCheckpoint.restore(game, checkpoint, dataCopy)
end
end
local function equalData(a, b)
@@ -313,6 +332,28 @@ local function equalData(a, b)
return okA and okB and encodedA == encodedB
end
local function firstDifference(a, b, path)
path = path or "$"
if type(a) ~= type(b) then return path .. " (type)" end
if type(a) ~= "table" then
if a ~= b then return path end
return nil
end
for key, value in pairs(a) do
if b[key] == nil and value ~= nil then
return path .. "." .. tostring(key) .. " (missing)"
end
local found = firstDifference(value, b[key], path .. "." .. tostring(key))
if found then return found end
end
for key, value in pairs(b) do
if a[key] == nil and value ~= nil then
return path .. "." .. tostring(key) .. " (unexpected)"
end
end
return nil
end
function Checkpoint.restore(game, checkpoint)
local capability = Checkpoint.inspect(game)
if not capability.canRestore then
@@ -329,7 +370,9 @@ function Checkpoint.restore(game, checkpoint)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and equalData(restored, validated) then return true end
err = "restored state did not match checkpoint: " .. tostring(verifyCode)
err = restored and ("restored state differed at "
.. tostring(firstDifference(validated, restored) or "canonical encoding"))
or ("restored state could not be captured: " .. tostring(verifyCode))
end
local rolledBack, rollbackErr = pcall(apply, game, rollback, options)