feat: capture data-only battle checkpoints

This commit is contained in:
MaxTomahawk
2026-08-07 16:22:43 +02:00
parent 24d9f279ec
commit 67b6dcc293
3 changed files with 306 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
-- Semantic, data-only capture for settled single-player battle checkpoints.
-- Reconstruction lives here too; public mods only see the opaque checkpoint
-- facade in Loader.
local BattleCheckpoint = {}
local BATTLER_FIELDS = {
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
"charging", "chargeReady", "invulnerable", "mustRecharge", "thrashMove",
"thrashTurns", "thrashAnnounced", "rageMove", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted",
}
local BATTLE_FIELDS = {
"oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex",
"moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay",
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
"endBattleText",
}
local function partyIndex(party, mon)
for index, candidate in ipairs(party or {}) do
if candidate == mon then return index end
end
end
local function indexSet(set, party)
local out = {}
for mon, present in pairs(set or {}) do
if present then
local index = partyIndex(party, mon)
if index then out[#out + 1] = index end
end
end
table.sort(out)
return out
end
local function captureBattler(battler, index, copy)
local out = { index = index }
for _, field in ipairs(BATTLER_FIELDS) do
if battler[field] ~= nil then out[field] = battler[field] end
end
return copy(out)
end
local function captureExtensions(battle, copy)
local sides = {}
for i = 1, 2 do
local side = battle.sides and battle.sides[i] or {}
local encoded, err = copy({
index = i,
screens = side.screens or {},
hazards = side.hazards or {},
tokens = side.tokens or {},
})
if not encoded then return nil, err end
sides[i] = encoded
end
local field, err = copy({
weather = battle.field and battle.field.weather or nil,
tokens = battle.field and battle.field.tokens or {},
})
if not field then return nil, err end
return sides, field
end
function BattleCheckpoint.capture(game, battle, progress, copy)
local getState = love and love.math and love.math.getRandomState
local setState = love and love.math and love.math.setRandomState
if type(getState) ~= "function" or type(setState) ~= "function" then
return nil, "rng_state_unavailable",
"This runtime cannot preserve deterministic battle randomness."
end
local ok, rngState = pcall(getState)
if not ok or type(rngState) ~= "string" or rngState == "" then
return nil, "rng_state_unavailable",
"The gameplay random-number state could not be captured."
end
local origin, originErr = copy(battle.checkpointOrigin)
if not origin then
return nil, "battle_origin_unsupported",
"The battle completion path is not data-only: " .. tostring(originErr)
end
local sides, fieldOrErr = captureExtensions(battle, copy)
if not sides then
return nil, "battle_extension_unsafe",
"Battle extension state is not data-only: " .. tostring(fieldOrErr)
end
local field = fieldOrErr
local liveParty = game.save.party
local playerIndex = partyIndex(liveParty, battle.player.mon)
if not playerIndex then
return nil, "battle_state_invalid",
"The active player battler is not in the current party."
end
local model = {
kind = battle.kind,
origin = origin,
player = captureBattler(battle.player, playerIndex, copy),
participants = indexSet(battle.participants, liveParty),
leveledUp = indexSet(battle.leveledUp, liveParty),
sides = sides,
field = field,
}
if battle.kind == "trainer" then
model.enemyParty = copy(battle.enemyParty)
model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy)
else
model.enemyMon = copy(battle.enemy.mon)
model.enemy = captureBattler(battle.enemy, 1, copy)
end
for _, fieldName in ipairs(BATTLE_FIELDS) do
if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end
end
model = copy(model)
if not model then
return nil, "battle_state_invalid",
"Battle state contains non-serializable runtime data."
end
local player = progress.player
return {
overworld = {
map = player.map, x = player.x, y = player.y,
facing = player.facing, surfing = player.surfing and true or false,
},
battle = model,
}, { love = rngState }
end
return BattleCheckpoint
+20
View File
@@ -5,6 +5,7 @@ local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState") local BattleState = require("src.battle.BattleState")
local BattleCheckpoint = require("src.core.BattleCheckpoint")
local Checkpoint = {} local Checkpoint = {}
@@ -172,6 +173,25 @@ function Checkpoint.capture(game)
.. tostring(err) .. tostring(err)
end end
if capability.kind == "battle" then
local battle = game.stack:top()
local runtime, rngOrCode, battleMessage =
BattleCheckpoint.capture(game, battle, progress, dataCopy)
if not runtime then return nil, rngOrCode, battleMessage end
return {
format = Checkpoint.FORMAT,
kind = "battle",
identity = {
engineVersion = Version.engine,
gameVersion = game.save.version,
playthroughId = game.save.meta.playthroughId,
},
save = progress,
runtime = runtime,
rng = rngOrCode,
}
end
local player = game.overworld.player local player = game.overworld.player
return { return {
format = Checkpoint.FORMAT, format = Checkpoint.FORMAT,
+146
View File
@@ -0,0 +1,146 @@
-- Data-only capture of a settled battle checkpoint, including deterministic
-- gameplay RNG and normalized object-reference sets.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint capture")
local Fixtures = require("tests.modkit").fixtures
local BattleState = require("src.battle.BattleState")
local Checkpoint = require("src.core.Checkpoint")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local oldGet, oldSet = love.math.getRandomState, love.math.setRandomState
local randomState = "fixture-rng-A"
love.math.getRandomState = function() return randomState end
love.math.setRandomState = function(state) randomState = state end
local function makeGame(kind)
local save = SaveData.newGame()
save.meta.playthroughId = "battle-playthrough"
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.party = {
Pokemon.new(Data, "FIXMON_A", 20),
Pokemon.new(Data, "FIXMON_C", 15),
}
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
function overworld:captureSave(target)
target.player.map = self.map.id
target.player.x, target.player.y = self.player.cellX, self.player.cellY
target.player.facing = self.player.facing
target.player.surfing = self.player.surfing and true or false
end
local game = { data = Data, save = save, stack = stack, overworld = overworld }
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",
event = "EVENT_BEAT_TRAINER_1",
}
else
battle = BattleState.newWild(game, "FIXMON_B", 12)
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
end
battle.phase, battle.queue = "menu", {}
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
end
local game, battle = makeGame("wild")
battle.turnCount = 7
battle.runAttempts = 2
battle.payDay = 45
battle.player.stages.attack = 2
battle.player.confusedTurns = 3
battle.player.curTypes = { "FIRE", "FLYING" }
battle.enemy.mon.hp = battle.enemy.mon.hp - 4
battle.enemy.stages.defense = -1
battle.participants = { [game.save.party[1]] = true,
[game.save.party[2]] = true }
battle.leveledUp = { [game.save.party[2]] = true }
battle.sideToxic = { enemy = 3 }
battle.sides[1].screens.reflect = { turns = 2 }
battle.field.weather = { id = "fixture-rain", turns = 4 }
local snapshot, code, message = Checkpoint.capture(game)
T.check(snapshot ~= nil, "settled battle captures: " .. tostring(code or message))
T.eq(snapshot and snapshot.kind, "battle", "checkpoint kind is battle")
if snapshot and snapshot.kind == "battle" then
T.eq(snapshot.rng.love, "fixture-rng-A", "LÖVE RNG state is captured")
T.same(snapshot.runtime.overworld,
{ map = "FIX_TOWN", x = 2, y = 3, facing = "left", surfing = false },
"return overworld point is captured")
T.same(snapshot.runtime.battle.origin,
{ kind = "wild_encounter", map = "FIX_TOWN" },
"semantic continuation origin is data-only")
T.eq(snapshot.runtime.battle.turnCount, 7, "turn count is captured")
T.eq(snapshot.runtime.battle.runAttempts, 2, "escape attempts are captured")
T.eq(snapshot.runtime.battle.player.stages.attack, 2,
"player stat stages are captured")
T.eq(snapshot.runtime.battle.player.confusedTurns, 3,
"player volatile status is captured")
T.same(snapshot.runtime.battle.player.curTypes, { "FIRE", "FLYING" },
"transformed battle types are captured")
T.eq(snapshot.runtime.battle.enemy.stages.defense, -1,
"enemy stat stages are captured")
T.same(snapshot.runtime.battle.participants, { 1, 2 },
"Pokemon-keyed participants normalize to party indices")
T.same(snapshot.runtime.battle.leveledUp, { 2 },
"Pokemon-keyed level-up set normalizes to party indices")
T.same(snapshot.runtime.battle.sides[1].screens.reflect, { turns = 2 },
"data-only side extensions are captured")
T.same(snapshot.runtime.battle.field.weather,
{ id = "fixture-rain", turns = 4 },
"data-only field extensions are captured")
local encoded = SaveSerializer.encode(snapshot)
T.check(type(encoded) == "string" and #encoded > 0,
"battle checkpoint passes the canonical data-only serializer")
snapshot.save.money = 1
snapshot.runtime.battle.player.stages.attack = -6
T.check(game.save.money ~= 1, "checkpoint progress is detached")
T.eq(battle.player.stages.attack, 2, "checkpoint battle state is detached")
end
local trainerGame, trainer = makeGame("trainer")
trainer.enemyIndex = 1
trainer.aiUses = 2
local trainerSnapshot = Checkpoint.capture(trainerGame)
T.eq(trainerSnapshot and trainerSnapshot.kind, "battle",
"ordinary trainer battle captures")
if trainerSnapshot and trainerSnapshot.kind == "battle" then
T.eq(trainerSnapshot.runtime.battle.oppClass, "OPP_FIX_YOUNGSTER",
"trainer class is captured")
T.eq(trainerSnapshot.runtime.battle.partyIndex, 1,
"trainer roster index is captured")
T.eq(#trainerSnapshot.runtime.battle.enemyParty, #trainer.enemyParty,
"complete enemy roster is captured")
end
local extensionGame, extensionBattle = makeGame("wild")
extensionBattle.field.tokens[1] = { id = "callback-token", onExpire = function() end }
local unsafe, unsafeCode = Checkpoint.capture(extensionGame)
T.check(unsafe == nil and unsafeCode == "battle_extension_unsafe",
"callback-bearing battle extensions are rejected, not stripped")
love.math.getRandomState = nil
local rngGame = makeGame("wild")
local noRng, rngCode = Checkpoint.capture(rngGame)
T.check(noRng == nil and rngCode == "rng_state_unavailable",
"battle capture fails closed without serializable gameplay RNG")
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
T.finish()