Merge pull request #986 from MaxTomahawk/feat/mod-battle-checkpoints

feat: add persistent battle safe-point checkpoints
This commit is contained in:
bryanthaboi
2026-08-08 05:46:34 -04:00
committed by GitHub
13 changed files with 1610 additions and 20 deletions
+12 -9
View File
@@ -181,17 +181,20 @@ end
local ok, code, message = mod.checkpoints:restore(game, checkpoint)
```
Checkpoint format 1 supports settled overworld control only: the overworld must
be topmost, the player stationary on a tile, and no transition, menu, script,
queued script movement, or partial field animation may be active. Refusals carry
a stable `reason` and readable `message`. Capture excludes global options and
runtime objects. Restore validates format, game/playthrough identity, content,
and coordinates before mutation; preserves current options; suppresses normal
map-entry/save-load side effects; verifies a recapture; and rolls back in memory
if reconstruction fails. Callers that need crash recovery should durably capture
Checkpoint format 1 supports settled overworld control and proven battle
player-decision safe points. Battle checkpoints are limited to ordinary
single-player wild/trainer origins with no suspended script; link, Safari,
ghost, demo, scripted, animation, message, queue, and forced-action phases fail
closed. New checkpoints preserve gameplay RNG, while legacy overworld records
without RNG remain loadable. Capture excludes global options and runtime
objects. Restore validates format, game/playthrough identity, content,
coordinates, battle relationships, continuation, and RNG before mutation;
preserves current options; suppresses normal map-entry/save-load/intro side
effects; verifies a recapture; and rolls back runtime plus RNG in memory if
reconstruction fails. Callers that need crash recovery should durably capture
their own recovery checkpoint before restore.
See RFC 0003 and RFC 0004 for exact contracts and error codes.
See RFC 0003, RFC 0004, and RFC 0005 for exact contracts and error codes.
## Developer console
+5 -4
View File
@@ -104,10 +104,11 @@ runtime exception, not process termination.
## Runtime boundary and future kinds
Format 1 intentionally rejects battles, menus, transitions, animations, and
suspended/queued scripts. Future battle or explicit script-checkpoint kinds must
have separate inventories, validation, reconstruction, deterministic RNG, and
differential tests; they are not implied by this RFC.
This RFC's original Level A contract intentionally rejects battles, menus,
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
adds a separately inventoried `battle` kind with deterministic RNG and
differential reconstruction tests; it does not broaden script or arbitrary-frame
support implied here.
## Migration note for existing mods
@@ -0,0 +1,133 @@
# RFC 0005 — Persistent battle safe-point checkpoints
## Status
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
## Motivation
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
private engine access. A battle is a different runtime: its queue can hold Lua
functions and UI factories, its controller contains renderer objects and live
references, completion is currently an `onFinish` closure, and scripted battles
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
a record that is neither data-only nor process-independent.
The engine can instead expose a narrow semantic safe point. This gives all mods
the strongest persistent battle checkpoint the current architecture can prove,
without claiming mid-animation or suspended-script support.
## API delta
No new facade is added. The existing additive `mod.checkpoints` API gains a
second format-1 runtime kind.
### Capability
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
wild or trainer battle is settled at the player command menu:
```lua
{ canCapture = true, canRestore = true, kind = "battle" }
```
The action/message queue, waits, UI, animations, HP/status presentation, and
faint processing must be settled. The player must actually control the menu.
The underlying overworld must have no running/queued script or scripted move,
and the battle must carry an engine-owned semantic continuation descriptor.
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
closure continuations remain rejected.
### Capture
A battle checkpoint remains detached and data-only:
```lua
{
format = 1,
kind = "battle",
identity = { engineVersion = "...", gameVersion = "red",
playthroughId = "..." },
save = { -- canonical dynamic progress, excluding global options },
runtime = {
overworld = { map = "ROUTE_1", x = 7, y = 8,
facing = "left", surfing = false },
battle = { -- normalized semantic model and continuation },
},
rng = { love = "..." },
}
```
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
trainer AI state, battle ruleset identity, side/field extension data, and
normalized pointer relationships such as multi-turn move slots and Mimic
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
controller objects are reconstructed or excluded.
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
invalid live reference relationships fail with `battle_state_invalid`. Nothing
is silently stripped.
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
format-1 overworld checkpoints without `rng` remain loadable and leave the
current stream untouched.
### Restore
Battle restore validates the detached save, map, content references, ruleset,
roster indices, move references, continuation identity, and RNG before live
mutation. The engine then:
1. reconstructs the saved overworld return point without entry side effects;
2. creates a fresh `BattleState` from current content registries;
3. applies the normalized battle model and rebuilds object-reference relations;
4. binds an engine-owned wild/trainer completion continuation;
5. installs the battle directly at the settled menu without replaying its intro;
6. restores the RNG after reconstruction has finished; and
7. recaptures and compares the complete checkpoint.
The pre-operation checkpoint is the transaction rollback. A failed post-install
RNG restore is covered: both battle runtime and RNG are reconstructed back to
their original values.
## Continuation decision
Ordinary random wild battles resume through `OverworldState:afterBattle`.
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
trainer class/party, and optional header event; a win reapplies the same defeated
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
NPC freeze state are normalized instead of reviving the old closure.
`Commands.start_battle` is deliberately unsupported: its completion closure
mutates script context and resumes a coroutine whose program counter and Lua
stack cannot be serialized. Existing script rejection remains the correct safe
contract until a separate semantic ScriptRunner checkpoint RFC exists.
## Migration note
**Existing mods require no changes.** The facade and format number are unchanged;
the new kind and RNG field are additive. Overworld-only callers may continue to
filter `capability.kind`. No-mod behavior is unchanged when checkpoints are
unused.
## Verification
- settled/unsafe boundary and every variant refusal;
- data-only wild and trainer capture, including callback-bearing extension
rejection;
- process-independent controller and continuation reconstruction;
- exact differential recapture for wild and trainer states;
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
multi-turn move references, and Mimic restore pointers;
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
raw RNG result after reload;
- corrupt content/continuation rejection before mutation;
- injected post-install failure with full runtime and RNG rollback;
- legacy overworld checkpoint compatibility;
- complete ROM-free engine and public mod-API suites.
+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.
+432
View File
@@ -0,0 +1,432 @@
-- 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 BattleState = require("src.battle.BattleState")
local BUILTIN_RULESETS = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
}
local function rulesets(game)
return game.data.rulesets or BUILTIN_RULESETS
end
local function rulesetId(game, record)
for id, candidate in pairs(rulesets(game)) do
if candidate == record then return id end
end
end
local BATTLER_FIELDS = {
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
"chargeReady", "invulnerable", "mustRecharge",
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
"trapMove", "trapDamage", "fainted",
"aiLayer2",
}
local MOVE_REFERENCE_FIELDS = {
charging = "chargingSlot",
thrashMove = "thrashMoveSlot",
rageMove = "rageMoveSlot",
}
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,
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
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
local reference = battler[field]
if reference ~= nil then
for slot, move in ipairs(battler.curMoves or {}) do
if move == reference then out[slotField] = slot break end
end
if out[slotField] == nil then return nil end
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 type(battler.curMoves) ~= "table" 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
for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do
if battler[slotField] ~= nil
and not integer(battler[slotField], 1, #battler.curMoves) then
return false
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
local function captureMimicRestores(battle)
local out = {}
for _, restore in ipairs(battle.mimicRestores or {}) do
local side = restore.battler == battle.player and "player"
or restore.battler == battle.enemy and "enemy" or nil
local slot
for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do
if move == restore.entry then slot = index break end
end
if not side or not slot or type(restore.id) ~= "string" then return nil end
out[#out + 1] = { side = side, slot = slot, id = restore.id }
end
return out
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
if type(model.rulesetId) ~= "string"
or type(rulesets(game)[model.rulesetId]) ~= "table" then
return nil, "invalid_content", "Battle ruleset is unavailable."
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."
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
if type(model.mimicRestores) ~= "table" then
return nil, "invalid_checkpoint", "Mimic restore state is missing."
end
for _, restore in ipairs(model.mimicRestores) do
local battler = restore.side == "player" and model.player
or restore.side == "enemy" and model.enemy or nil
if not battler or not integer(restore.slot, 1, #battler.curMoves)
or type(restore.id) ~= "string"
or type(game.data.moves[restore.id]) ~= "table" then
return nil, "invalid_content", "Mimic restore state is invalid."
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
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
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))
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil
end
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)
battle.mimicRestores = {}
for _, restore in ipairs(model.mimicRestores or {}) do
local battler = restore.side == "player" and battle.player or battle.enemy
battle.mimicRestores[#battle.mimicRestores + 1] = {
battler = battler,
entry = battler.curMoves[restore.slot],
id = restore.id,
}
end
if #battle.mimicRestores == 0 then battle.mimicRestores = nil end
for _, field in ipairs(BATTLE_FIELDS) do
if model[field] ~= nil then
battle[field] = clone(model[field], copy)
else
battle[field] = nil
end
end
battle.kind = model.kind
battle.ruleset = rulesets(game)[model.rulesetId]
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
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,
rulesetId = rulesetId(game, battle.ruleset),
origin = origin,
player = captureBattler(battle.player, playerIndex, copy),
participants = indexSet(battle.participants, liveParty),
leveledUp = indexSet(battle.leveledUp, liveParty),
sides = sides,
field = field,
mimicRestores = captureMimicRestores(battle),
}
if not model.rulesetId then
return nil, "battle_state_invalid", "Battle ruleset identity is unavailable."
end
if not model.player then
return nil, "battle_state_invalid", "Player move references are inconsistent."
end
if not model.mimicRestores then
return nil, "battle_state_invalid", "Mimic restore state is inconsistent."
end
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
if not model.enemy then
return nil, "battle_state_invalid", "Enemy move references are inconsistent."
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
+155 -6
View File
@@ -4,6 +4,8 @@
local SaveSerializer = require("src.core.SaveSerializer")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local BattleState = require("src.battle.BattleState")
local BattleCheckpoint = require("src.core.BattleCheckpoint")
local Checkpoint = {}
@@ -27,6 +29,70 @@ local function nonempty(value)
return type(value) == "table" and next(value) ~= nil
end
local function scriptsBusy(ow)
return running(ow.runner) or nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves)
end
local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
local function inspectBattle(ow, battle)
if battle.kind == "link" then
return refusal("battle", "link_battle_unsupported",
"Network battles cannot be checkpointed.")
end
if battle.safari or battle.ghost or battle.scopeReveal or battle.demo
or battle.noCatch then
return refusal("battle", "battle_variant_unsupported",
"This battle variant does not have a checkpoint contract.")
end
if battle.kind ~= "wild" and battle.kind ~= "trainer" then
return refusal("battle", "battle_variant_unsupported",
"This battle kind does not have a checkpoint contract.")
end
local origin = battle.checkpointOrigin
local expectedOrigin = battle.kind == "wild" and "wild_encounter"
or "trainer_encounter"
if type(origin) ~= "table" or origin.kind ~= expectedOrigin then
return refusal("battle", "battle_origin_unsupported",
"The battle completion path cannot be reconstructed safely.")
end
if scriptsBusy(ow) then
return refusal("battle", "script_busy",
"A suspended or queued script cannot be checkpointed.")
end
if battle.phase ~= "menu" or nonempty(battle.queue) then
return refusal("battle", "battle_phase_busy",
"Wait for the player command menu before creating a checkpoint.")
end
for _, field in ipairs(BATTLE_BUSY_FIELDS) do
if battle[field] ~= nil and battle[field] ~= false then
return refusal("battle", "battle_phase_busy",
"Wait for the current battle action to finish.")
end
end
if not battle.player or not battle.enemy or battle.player.mon.hp <= 0
or (battle.menuLockedAction and battle:menuLockedAction(battle.player)) then
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
function Checkpoint.inspect(game)
local save = game and game.save
if type(save) ~= "table" or type(save.version) ~= "string" then
@@ -41,6 +107,9 @@ function Checkpoint.inspect(game)
"Only a settled overworld can be checkpointed.")
end
local top = game.stack and game.stack.top and game.stack:top()
if getmetatable(top) == BattleState then
return inspectBattle(ow, top)
end
if top ~= ow then
return refusal("overworld", "screen_busy",
"Close the active menu or screen before creating a checkpoint.")
@@ -57,9 +126,7 @@ function Checkpoint.inspect(game)
return refusal("overworld", "transition_busy",
"Wait for the map transition to finish.")
end
if running(ow.runner) or nonempty(ow.parallelRunners)
or nonempty(ow.pendingScripts) or nonempty(ow.parallelQueue)
or nonempty(ow.scriptMoves) then
if scriptsBusy(ow) then
return refusal("overworld", "script_busy",
"Wait for the active or queued script to finish.")
end
@@ -89,6 +156,27 @@ local function dataCopy(value)
return decoded
end
local function captureRng()
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 end
local ok, state = pcall(getState)
if ok and type(state) == "string" and state ~= "" then
return { love = state }
end
return nil
end
local function restoreRng(rng)
if rng == nil then return end -- legacy format-1 overworld checkpoint
local setState = love and love.math and love.math.setRandomState
if type(rng) ~= "table" or type(rng.love) ~= "string"
or type(setState) ~= "function" then
error("checkpoint RNG restore is unavailable", 0)
end
setState(rng.love)
end
function Checkpoint.capture(game)
local capability = Checkpoint.inspect(game)
if not capability.canCapture then
@@ -115,6 +203,25 @@ function Checkpoint.capture(game)
.. tostring(err)
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
return {
format = Checkpoint.FORMAT,
@@ -132,6 +239,7 @@ function Checkpoint.capture(game)
facing = player.facing,
surfing = player.surfing and true or false,
} },
rng = captureRng(),
}
end
@@ -144,8 +252,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)
@@ -178,6 +286,10 @@ local function validate(game, checkpoint)
or not save.meta or save.meta.playthroughId ~= identity.playthroughId then
return nil, "invalid_checkpoint", "Checkpoint progress identity is inconsistent."
end
if copy.rng ~= nil and (type(copy.rng) ~= "table"
or type(copy.rng.love) ~= "string" or copy.rng.love == "") then
return nil, "invalid_checkpoint", "Checkpoint RNG state is corrupt."
end
if type(runtime.map) ~= "string" or type(runtime.x) ~= "number"
or type(runtime.y) ~= "number" or runtime.x % 1 ~= 0 or runtime.y % 1 ~= 0
or not FACINGS[runtime.facing] or type(runtime.surfing) ~= "boolean" then
@@ -211,6 +323,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 then
return nil, "invalid_checkpoint",
"Overworld checkpoint contains unexpected battle state."
end
return copy
end
@@ -228,6 +347,11 @@ 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)
else
restoreRng(checkpoint.rng)
end
end
local function equalData(a, b)
@@ -236,6 +360,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
@@ -251,8 +397,11 @@ function Checkpoint.restore(game, checkpoint)
local ok, err = pcall(apply, game, validated, options)
if ok then
local restored, verifyCode = Checkpoint.capture(game)
if restored and validated.rng == nil then restored.rng = nil end
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)
+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
-- -------------------------------------------------------------------------
@@ -0,0 +1,85 @@
-- Battle checkpoints are exposed only at a settled, reconstructable player
-- decision boundary. This suite is ROM-free and exercises the public engine
-- checkpoint capability against the fixture battle implementation.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("battle checkpoint boundary")
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 StateStack = require("src.core.StateStack")
local Data = Fixtures.fresh()
local function makeGame()
local save = SaveData.newGame()
save.meta.playthroughId = "battle-playthrough"
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = setmetatable({ states = {} }, { __index = StateStack })
local overworld = {
map = { id = save.player.map },
player = {
cellX = save.player.x, cellY = save.player.y,
facing = save.player.facing, surfing = false,
},
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
local game = { data = Data, save = save, stack = stack, overworld = overworld }
stack.states[1] = overworld
local battle = BattleState.newWild(game, "FIXMON_B", 12)
battle.phase = "menu"
battle.queue = {}
battle.checkpointOrigin = { kind = "wild_encounter" }
battle.onFinish = function() end
stack.states[2] = battle
return game, overworld, battle
end
local game, overworld, battle = makeGame()
T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "battle",
}, "settled standard wild battle is a checkpoint boundary")
local function refused(mutator, code, label)
local game2, ow2, battle2 = makeGame()
mutator(game2, ow2, battle2)
local capability = Checkpoint.inspect(game2)
T.check(capability.canCapture == false and capability.reason == code,
label .. ": " .. tostring(capability.reason))
end
refused(function(_, _, b) b.phase = "messages" end,
"battle_phase_busy", "message phase is rejected")
refused(function(_, _, b) b.queue = { { text = "busy" } } end,
"battle_phase_busy", "nonempty action queue is rejected")
refused(function(_, _, b) b.waitFrames = 1 end,
"battle_phase_busy", "partial wait is rejected")
refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
"battle_phase_busy", "unfinished HP display synchronization is rejected")
refused(function(_, _, b) b.player.mustRecharge = true end,
"battle_phase_busy", "automatic locked action is rejected")
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
"script_busy", "suspended script beneath battle is rejected")
refused(function(_, _, b) b.checkpointOrigin = nil end,
"battle_origin_unsupported", "unknown completion closure is rejected")
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
"battle_variant_unsupported", "Safari battle is rejected")
refused(function(_, _, b) b.ghost = true end,
"battle_variant_unsupported", "ghost battle is rejected")
refused(function(_, _, b) b.demo = true end,
"battle_variant_unsupported", "old-man demo is rejected")
refused(function(_, _, b) b.kind = "link" end,
"link_battle_unsupported", "link battle is rejected")
-- Ordinary overworld behavior remains unchanged by the battle branch.
game.stack.states[2] = nil
T.same(Checkpoint.inspect(game), {
canCapture = true, canRestore = true, kind = "overworld",
}, "settled overworld remains supported")
T.finish()
+168
View File
@@ -0,0 +1,168 @@
-- 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", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 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" }
local originalMoveId = battle.player.curMoves[1].id
battle.player.curMoves[1].id = "FIX_CUT"
battle.player.curMoves[1].mimic = true
battle.mimicRestores = {
{ battler = battle.player, entry = battle.player.curMoves[1], id = originalMoveId },
}
battle.enemy.mon.hp = battle.enemy.mon.hp - 4
battle.enemy.shownHP = battle.enemy.mon.hp
battle.enemy.stages.defense = -1
battle.enemy.aiLayer2 = 1
battle.enemy.thrashMove = battle.enemy.curMoves[1]
battle.enemy.thrashTurns = 2
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.rulesetId, "gen1_faithful",
"battle mechanics ruleset identity 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.same(snapshot.runtime.battle.mimicRestores,
{ { side = "player", slot = 1, id = originalMoveId } },
"Mimic restore pointers normalize to side and move slot")
T.eq(snapshot.runtime.battle.enemy.stages.defense, -1,
"enemy stat stages are captured")
T.eq(snapshot.runtime.battle.enemy.aiLayer2, 1,
"enemy AI selection layer is captured")
T.eq(snapshot.runtime.battle.enemy.thrashMoveSlot, 1,
"move-instance references normalize to move slots")
T.eq(snapshot.runtime.battle.enemy.thrashMove, nil,
"live move-instance references are not serialized as detached copies")
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()
@@ -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()
+315
View File
@@ -0,0 +1,315 @@
-- A battle checkpoint reconstructs a new controller from data, rather than
-- retaining the original table/closure, and restores gameplay RNG exactly.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
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 Damage = require("src.battle.Damage")
local Encounter = require("src.world.Encounter")
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 SaveSerializer = require("src.core.SaveSerializer")
local StateStack = require("src.core.StateStack")
local TrainerAI = require("src.battle.TrainerAI")
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
love.math.random = function(a, b)
rng = (rng * 1103515245 + 12345) % 2147483648
local unit = rng / 2147483648
if a == nil then return unit end
if b == nil then return math.floor(unit * a) + 1 end
return a + math.floor(unit * (b - a + 1))
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.player.facing, save.player.surfing = "left", false
save.party = {
Pokemon.new(Data, "FIXMON_A", 20),
Pokemon.new(Data, "FIXMON_C", 15),
}
-- Strip new-game defaults that are intentionally absent from the tiny
-- fixture registry, then place the sanitized save on a fixture map.
SaveData.validate(save, Data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
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
function overworld:restoreBattleContinuation(battle, origin)
battle.onFinish = function(result)
self.lastRestoredFinish = { result = result, origin = origin.kind }
end
return true
end
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 }
self.overworld.player = {
cellX = loaded.player.x, cellY = loaded.player.y,
facing = loaded.player.facing,
surfing = loaded.player.surfing and true or false,
}
self.overworld.runner = { isRunning = function() return false end }
self.overworld.parallelRunners, self.overworld.pendingScripts = {}, {}
self.overworld.parallelQueue, self.overworld.scriptMoves = {}, {}
self.stack.states = { self.overworld }
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", npcId = "TRAINER_1",
trainerClass = "OPP_FIX_YOUNGSTER", partyIndex = 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.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
stack.states[2] = battle
return game, battle
end
local function settleOverworld(game)
game.stack.states = { game.overworld }
game.save.money = 999999
game.save.party[1].hp = 1
game.overworld.player.cellX = 8
game.overworld.player.facing = "up"
end
local game, originalBattle = makeGame("wild")
originalBattle.turnCount = 4
originalBattle.runAttempts = 1
originalBattle.player.stages.speed = 3
local originalMoveId = originalBattle.player.curMoves[1].id
originalBattle.player.curMoves[1].id = "FIX_CUT"
originalBattle.player.curMoves[1].mimic = true
originalBattle.mimicRestores = {
{ battler = originalBattle.player, entry = originalBattle.player.curMoves[1],
id = originalMoveId },
}
originalBattle.enemy.mon.hp = originalBattle.enemy.mon.hp - 5
originalBattle.enemy.shownHP = originalBattle.enemy.mon.hp
originalBattle.enemy.disabledSlot = 1
originalBattle.enemy.disabledTurns = 2
originalBattle.enemy.aiLayer2 = 1
originalBattle.enemy.thrashTurns = 2
originalBattle.enemy.mon.moves = {
{ id = "FIX_SCRATCH", pp = 35 }, { id = "FIX_CUT", pp = 30 },
}
originalBattle.enemy.curMoves = originalBattle.enemy.mon.moves
originalBattle.enemy.thrashMove = originalBattle.enemy.curMoves[1]
originalBattle.participants = { [game.save.party[1]] = true }
local checkpoint = assert(Checkpoint.capture(game))
local encounterDef = { grass = {
rate = 256, buckets = { 128, 256 },
slots = { { species = "FIXMON_A", level = 4 },
{ species = "FIXMON_B", level = 7 } },
} }
Encounter.load(Data)
local function randomOutcomes(battle)
local damage, detail = Damage.compute(battle.ruleset, battle.player,
battle.enemy, Data.moves.FIX_CUT, { rng = battle.rng })
local hit = Damage.accuracyRoll(battle.ruleset, Data.moves.FIX_CUT,
battle.player, battle.enemy, battle.rng)
local ai = TrainerAI.chooseMove(battle.enemy, battle.rng, nil)
local escaped = battle:runRollVanilla(1, 100)
local encounter = Encounter.roll(encounterDef, love.math.random)
local nextValue = love.math.random(1, 1000000)
return {
damage = damage, critical = detail.crit, hit = hit,
ai = ai.id, escaped = escaped, encounter = encounter,
nextValue = nextValue,
}
end
local expectedOutcomes = randomOutcomes(originalBattle)
settleOverworld(game)
game.save.options.ruleset = "modern_clean"
rng = 777
local restored, code, message = Checkpoint.restore(game, checkpoint)
T.check(restored == true, "battle checkpoint restores: " .. tostring(message or code))
local rebuilt = restored and game.stack:top()
if restored then
T.check(rebuilt ~= originalBattle, "restore creates a new battle controller")
T.eq(getmetatable(rebuilt), BattleState, "restored stack top is a BattleState")
T.eq(rebuilt.phase, "menu", "restored battle resumes at the decision menu")
T.eq(#rebuilt.queue, 0, "restored battle has no stale action queue")
T.eq(rebuilt.turnCount, 4, "turn count roundtrips")
T.eq(rebuilt.runAttempts, 1, "escape state roundtrips")
T.eq(rebuilt.player.stages.speed, 3, "player stages roundtrip")
T.check(rebuilt.mimicRestores and rebuilt.mimicRestores[1]
and rebuilt.mimicRestores[1].battler == rebuilt.player
and rebuilt.mimicRestores[1].entry == rebuilt.player.curMoves[1],
"Mimic restore references are rebuilt against the new battler")
rebuilt:restoreMimicked(rebuilt.player)
T.eq(rebuilt.player.curMoves[1].id, originalMoveId,
"restored Mimic move returns to its canonical id when battle copy leaves")
T.eq(rebuilt.player.curMoves[1].mimic, nil,
"restored Mimic marker clears with the battle copy")
-- Put the checkpointed battle state back before differential recapture.
rebuilt.player.curMoves[1].id = "FIX_CUT"
rebuilt.player.curMoves[1].mimic = true
rebuilt.mimicRestores = {
{ battler = rebuilt.player, entry = rebuilt.player.curMoves[1], id = originalMoveId },
}
T.eq(rebuilt.enemy.disabledSlot, 1, "enemy volatile state roundtrips")
T.eq(rebuilt.enemy.disabledTurns, 2, "enemy volatile duration roundtrips")
T.eq(rebuilt.enemy.aiLayer2, 1, "enemy AI selection layer roundtrips")
T.check(rebuilt.enemy.thrashMove == rebuilt.enemy.curMoves[1],
"multi-turn move references rebuild against the new move list")
T.eq(rebuilt.enemy.mon.hp, checkpoint.runtime.battle.enemyMon.hp,
"enemy Pokemon model roundtrips")
T.eq(game.save.money, checkpoint.save.money, "persistent progress roundtrips")
T.eq(game.save.party[1].hp, checkpoint.save.party[1].hp,
"party model roundtrips")
T.eq(game.save.options.ruleset, "modern_clean",
"current global ruleset option remains untouched")
T.check(rebuilt.ruleset == require("src.battle.rulesets.gen1_faithful"),
"restored battle keeps the mechanics ruleset it was captured with")
T.same(Checkpoint.capture(game), checkpoint,
"capture A, discard, restore A, capture A2 yields normalized A == A2")
local replayed = randomOutcomes(rebuilt)
T.same(replayed, expectedOutcomes,
"damage, critical, accuracy, AI, escape, encounter and next RNG replay exactly")
rebuilt.onFinish("run")
T.same(game.overworld.lastRestoredFinish,
{ result = "run", origin = "wild_encounter" },
"restored battle receives a reconstructed semantic continuation")
end
local partyGame, switchedOriginal = makeGame("wild")
partyGame.save.party[1].hp = 0
local activeMon = partyGame.save.party[2]
activeMon.status = "PAR"
activeMon.moves[1].pp = activeMon.moves[1].pp - 4
switchedOriginal.player = BattleState.makeBattler(
Data, activeMon, true, partyGame.save)
switchedOriginal.sides[1].battlers = { switchedOriginal.player }
switchedOriginal.participants = {
[partyGame.save.party[1]] = true,
[partyGame.save.party[2]] = true,
}
local partyCheckpoint = assert(Checkpoint.capture(partyGame))
settleOverworld(partyGame)
partyGame.save.party[2].status = nil
partyGame.save.party[2].moves[1].pp = 1
restored, code, message = Checkpoint.restore(partyGame, partyCheckpoint)
T.check(restored == true,
"switched/status/PP checkpoint restores: " .. tostring(message or code))
local partyRebuilt = partyGame.stack:top()
if restored then
T.eq(partyGame.save.party[1].hp, 0,
"fainted non-active party member roundtrips")
T.check(partyRebuilt.player.mon == partyGame.save.party[2],
"switched active Pokemon reconstructs against restored party identity")
T.eq(partyRebuilt.player.mon.status, "PAR", "active status roundtrips")
T.eq(partyRebuilt.player.mon.moves[1].pp,
partyCheckpoint.save.party[2].moves[1].pp, "reduced PP roundtrips")
T.check(partyRebuilt.participants[partyGame.save.party[1]] == true
and partyRebuilt.participants[partyGame.save.party[2]] == true,
"participant references rebuild against fainted and active party members")
T.same(Checkpoint.capture(partyGame), partyCheckpoint,
"switch, faint, status and PP differential recapture is exact")
end
local trainerGame, trainerOriginal = makeGame("trainer")
trainerOriginal.turnCount = 6
trainerOriginal.enemy.mon.hp = trainerOriginal.enemy.mon.hp - 3
trainerOriginal.enemy.shownHP = trainerOriginal.enemy.mon.hp
trainerOriginal.aiUses = 1
trainerOriginal.participants = { [trainerGame.save.party[1]] = true,
[trainerGame.save.party[2]] = true }
local trainerCheckpoint = assert(Checkpoint.capture(trainerGame))
settleOverworld(trainerGame)
restored, code, message = Checkpoint.restore(trainerGame, trainerCheckpoint)
T.check(restored == true, "trainer checkpoint restores: " .. tostring(message or code))
local trainerRebuilt = trainerGame.stack:top()
if restored then
T.check(trainerRebuilt ~= trainerOriginal,
"trainer restore is independent of the original controller")
T.eq(trainerRebuilt.oppClass, "OPP_FIX_YOUNGSTER", "trainer class roundtrips")
T.eq(trainerRebuilt.enemyIndex, 1, "enemy roster index roundtrips")
T.eq(trainerRebuilt.aiUses, 1, "trainer AI item budget roundtrips")
T.same(Checkpoint.capture(trainerGame), trainerCheckpoint,
"trainer differential recapture is exact")
end
local function clone(value)
return assert(SaveSerializer.decode(SaveSerializer.encode(value)))
end
local beforeRejected = assert(Checkpoint.capture(trainerGame))
local missingSpecies = clone(trainerCheckpoint)
missingSpecies.runtime.battle.enemyParty[1].species = "MISSING_SPECIES"
restored, code = Checkpoint.restore(trainerGame, missingSpecies)
T.check(restored == false and code == "invalid_content",
"unknown battle content is rejected before mutation")
T.same(Checkpoint.capture(trainerGame), beforeRejected,
"rejected battle content leaves runtime and RNG unchanged")
local badOrigin = clone(trainerCheckpoint)
badOrigin.runtime.battle.origin.npcId = nil
restored, code = Checkpoint.restore(trainerGame, badOrigin)
T.check(restored == false and code == "battle_origin_unsupported",
"incomplete semantic continuation is rejected before mutation")
T.same(Checkpoint.capture(trainerGame), beforeRejected,
"rejected continuation leaves runtime and RNG unchanged")
-- Fail after the new battle has been installed, when its RNG is applied.
-- The transaction must reconstruct the prior battle and restore its RNG.
local workingSetRandomState = love.math.setRandomState
local setCalls = 0
love.math.setRandomState = function(state)
setCalls = setCalls + 1
if setCalls == 1 then error("injected RNG restore failure") end
return workingSetRandomState(state)
end
local beforeFailure = assert(Checkpoint.capture(trainerGame))
local rngBeforeFailure = rng
restored, code = Checkpoint.restore(trainerGame, trainerCheckpoint)
T.check(restored == false and code == "restore_failed",
"post-install RNG failure is returned as a structured restore failure")
T.eq(rng, rngBeforeFailure, "failed battle restore rolls RNG back exactly")
T.same(Checkpoint.capture(trainerGame), beforeFailure,
"failed battle restore rolls the complete runtime back exactly")
love.math.setRandomState = workingSetRandomState
love.math.random = oldRandom
love.math.getRandomState, love.math.setRandomState = oldGet, oldSet
Music.playBattle = oldPlayBattle
T.finish()
+138 -1
View File
@@ -4,10 +4,20 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local oldGetRandomState = love.math.getRandomState
local oldSetRandomState = love.math.setRandomState
local checkpointRngState = "overworld-rng-A"
love.math.getRandomState = function() return checkpointRngState end
love.math.setRandomState = function(state) checkpointRngState = state end
local T = require("tests.harness").suite("mod checkpoints")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local GameMethods = require("src.core.Game")
local BattleState = require("src.battle.BattleState")
local Fixtures = require("tests.modkit").fixtures
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local Version = require("src.core.Version")
@@ -61,7 +71,14 @@ local function baseSave()
moves = { "TACKLE" } } },
flags = { GOT_STARTER = true },
inventory = { POTION = 1 },
pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {},
pcItems = { POTION = 2 },
box = { { species = "BULBASAUR", level = 4, hp = 16,
moves = { "TACKLE" } } },
boxes = { [2] = { { species = "BULBASAUR", level = 3, hp = 14,
moves = { "TACKLE" } } } },
defeatedTrainers = { PALLET_RIVAL = true },
objectToggles = { PALLET_TOWN = { OAK = false } },
itemsTaken = { PALLET_TOWN_POTION = true },
pokedex = { seen = { BULBASAUR = true }, owned = { BULBASAUR = true } },
modData = {},
options = { volume = 4, bindings = {} },
@@ -217,6 +234,19 @@ T.same(snapshot.runtime.overworld,
T.eq(snapshot.save.player.map, "ROUTE_1",
"captured progress is synchronized from the live controller")
T.eq(snapshot.save.options, nil, "global settings are excluded from progress rewind")
T.same(snapshot.rng, { love = "overworld-rng-A" },
"overworld checkpoint carries deterministic gameplay RNG")
local legacy = checkpoints:capture(game)
legacy.rng = nil
checkpointRngState = "legacy-runtime-rng"
local legacyRestored, legacyCode = checkpoints:restore(game, legacy)
T.check(legacyRestored == true,
"legacy format-1 overworld checkpoint without RNG remains loadable: "
.. tostring(legacyCode))
T.eq(checkpointRngState, "legacy-runtime-rng",
"legacy checkpoint leaves the current RNG stream untouched")
checkpointRngState = "overworld-rng-A"
snapshot.save.money = 1
snapshot.runtime.overworld.x = 1
@@ -230,7 +260,17 @@ local original = snapshot
game.save.money = 999999
game.save.flags.GOT_STARTER = nil
game.save.party[1].hp = 1
game.save.inventory.POTION = 99
game.save.pcItems.POTION = nil
game.save.box = {}
game.save.boxes = {}
game.save.defeatedTrainers.PALLET_RIVAL = nil
game.save.objectToggles.PALLET_TOWN.OAK = true
game.save.itemsTaken.PALLET_TOWN_POTION = nil
game.save.pokedex.seen.BULBASAUR = nil
game.save.pokedex.owned.BULBASAUR = nil
game.save.options.volume = 9
checkpointRngState = "overworld-rng-B"
ow.map.id, ow.player.cellX, ow.player.cellY = "PALLET_TOWN", 2, 3
ow.player.facing, ow.player.surfing = "up", false
@@ -242,6 +282,19 @@ T.same(recaptured, original,
"capture A, mutate B, restore A, capture A2 yields normalized A == A2")
T.eq(game.save.options.volume, 9,
"checkpoint restoration preserves current global settings")
T.eq(checkpointRngState, "overworld-rng-A",
"overworld checkpoint restores gameplay RNG")
T.eq(game.save.inventory.POTION, 1, "inventory progress roundtrips")
T.eq(game.save.pcItems.POTION, 2, "PC item progress roundtrips")
T.eq(game.save.box[1].hp, 16, "current box Pokemon roundtrips")
T.eq(game.save.boxes[2][1].hp, 14, "stored box collection roundtrips")
T.eq(game.save.defeatedTrainers.PALLET_RIVAL, true,
"defeated trainer progress roundtrips")
T.eq(game.save.objectToggles.PALLET_TOWN.OAK, false,
"map object toggle progress roundtrips")
T.eq(game.save.itemsTaken.PALLET_TOWN_POTION, true,
"taken-object progress roundtrips")
T.eq(game.save.pokedex.owned.BULBASAUR, true, "Pokedex progress roundtrips")
T.check(game.lastEnterOpts and game.lastEnterOpts.checkpoint == true,
"engine reconstruction is marked to suppress map-entry side effects")
@@ -298,8 +351,92 @@ T.check(not restored and restoreCode == "restore_failed",
T.same(checkpoints:capture(game), beforeFailure,
"failed reconstruction rolls back the complete pre-operation checkpoint")
-- The same public facade must carry a real battle checkpoint end to end. The
-- engine-side fixture is deliberately constructed outside the probe mod; the
-- mod sees and calls only mod.checkpoints.
local function makeBattleGame()
local data = Fixtures.fresh()
local save = SaveData.newGame()
save.meta.playthroughId = "public-battle-playthrough"
save.party = { Pokemon.new(data, "FIXMON_A", 20) }
-- The tiny fixture registry intentionally omits several full-game defaults.
-- Normalize those once, then place the save on its fixture map.
SaveData.validate(save, data)
save.player.map, save.player.x, save.player.y = "FIX_TOWN", 2, 3
save.player.facing, save.player.surfing = "left", false
local stack = setmetatable({ states = {} }, { __index = StateStack })
local battleGame
local battleOw = {
map = { id = "FIX_TOWN" },
player = { cellX = 2, cellY = 3, facing = "left", surfing = false },
runner = { isRunning = function() return false end },
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
}
function battleOw: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
function battleOw:enter(mapId, x, y, facing)
self.map = { id = mapId }
self.player = { cellX = x, cellY = y, facing = facing, surfing = false }
end
function battleOw:restoreBattleContinuation(restoredBattle, origin)
if origin.kind ~= "wild_encounter" or origin.map ~= self.map.id then
return false
end
restoredBattle.onFinish = function() end
return true
end
battleGame = setmetatable({
data = data, save = save, stack = stack, overworld = battleOw,
}, { __index = GameMethods })
stack.states[1] = battleOw
local battle = BattleState.newWild(battleGame, "FIXMON_B", 12)
battle.phase, battle.queue = "menu", {}
battle.checkpointOrigin = { kind = "wild_encounter", map = "FIX_TOWN" }
battle.musicKind = battle:computeMusicKind()
battle.onFinish = function() end
stack.states[2] = battle
return battleGame, battle
end
checkpointRngState = "public-battle-rng-A"
local battleGame, liveBattle = makeBattleGame()
T.same(checkpoints:inspect(battleGame), {
canCapture = true, canRestore = true, kind = "battle",
}, "public mod.checkpoints reports a settled battle boundary")
liveBattle.turnCount = 4
liveBattle.player.stages.attack = 2
local battleSnapshot, battleCaptureCode = checkpoints:capture(battleGame)
T.check(battleSnapshot and battleSnapshot.kind == "battle",
"public mod.checkpoints captures a data-only battle: "
.. tostring(battleCaptureCode))
if battleSnapshot then
battleGame.save.money = 1
liveBattle.turnCount = 99
checkpointRngState = "public-battle-rng-B"
local battleRestored, battleRestoreCode, battleRestoreMessage = checkpoints:restore(
battleGame, battleSnapshot)
T.check(battleRestored == true,
"public mod.checkpoints reconstructs a battle: "
.. tostring(battleRestoreCode) .. " / " .. tostring(battleRestoreMessage))
local restoredBattle = battleGame.stack:top()
T.eq(restoredBattle.turnCount, 4,
"public battle reconstruction restores the exact turn")
T.eq(restoredBattle.player.stages.attack, 2,
"public battle reconstruction restores battler stages")
T.eq(checkpointRngState, "public-battle-rng-A",
"public battle reconstruction restores gameplay RNG")
T.same(checkpoints:capture(battleGame), battleSnapshot,
"public battle capture/restore/capture is a normalized differential roundtrip")
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
_G.MOD_CHECKPOINTS = nil
love.math.getRandomState = oldGetRandomState
love.math.setRandomState = oldSetRandomState
T.finish()