diff --git a/docs/modding.md b/docs/modding.md index 0b9422d3..8bbb7dde 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index 0df1c421..54e928de 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -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 diff --git a/docs/rfcs/0005-battle-runtime-checkpoints.md b/docs/rfcs/0005-battle-runtime-checkpoints.md new file mode 100644 index 00000000..6735f5d2 --- /dev/null +++ b/docs/rfcs/0005-battle-runtime-checkpoints.md @@ -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. diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index b81041e3..86b2ab18 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -98,6 +98,15 @@ function BattleState:bgMode() return "white" end +-- Resume a semantic checkpoint directly at the command menu. Unlike enter(), +-- this deliberately does not replay the battle transition, intro queues, +-- cries, happiness changes, or battle-start events. +function BattleState:resumeCheckpoint() + self.isOpaque = self:bgMode() ~= "world" + require("src.core.Music").playBattle(self.data, + self.musicKind or self:computeMusicKind()) +end + -- How far to dim the overworld behind a "world" background, 0..1. Enough -- that the battle reads as the foreground rather than competing with a fully -- lit map behind it. diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua new file mode 100644 index 00000000..912ea1b2 --- /dev/null +++ b/src/core/BattleCheckpoint.lua @@ -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 diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index f09962f6..f6827fa8 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -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) diff --git a/src/core/Game.lua b/src/core/Game.lua index 27d585b0..5226dd16 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1143,4 +1143,15 @@ function Game:restoreCheckpointSave(loaded) { via = "checkpoint", checkpoint = true }) end +-- Install a reconstructed battle without calling BattleState:enter(), whose +-- transition, intro queues and battle-start side effects already happened in +-- the checkpointed timeline. +function Game:restoreCheckpointBattle(battle) + if self.stack:top() ~= self.overworld then + error("battle checkpoint requires a reconstructed overworld base", 0) + end + self.stack.states[#self.stack.states + 1] = battle + if battle.resumeCheckpoint then battle:resumeCheckpoint() end +end + return Game diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 8f12c267..1ac89816 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3059,6 +3059,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText if theme then require("src.core.Music").play(Game.data, theme) end end local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty) + battle.checkpointOrigin = { + kind = "trainer_encounter", + map = self.map.id, + npcId = npc.id, + trainerClass = d.trainerClass, + partyIndex = d.trainerParty or 1, + event = header and header.event or nil, + } -- PrintEndBattleText (home/trainers.asm:341) is called from -- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle -- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer @@ -3596,6 +3604,10 @@ function OverworldState:onStepComplete() end local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(Game, enc.species, enc.level) + battle.checkpointOrigin = { + kind = "wild_encounter", + map = self.map.id, + } -- map.ghostBattles: unidentifiable without the named item (the -- Pokemon Tower's Silph Scope) local ghost = Map.ghostBattles(self.map.def) @@ -4000,6 +4012,39 @@ function OverworldState:afterBattle(result, battle) end end +-- Rebind the data-only continuation attached to a supported battle checkpoint. +-- The overworld was reconstructed first, so transient input/NPC freezes from +-- the original encounter are intentionally not resumed. +function OverworldState:restoreBattleContinuation(battle, origin) + local game = battle and battle.game + if not game or type(origin) ~= "table" or not self.map + or origin.map ~= self.map.id then + return false + end + if origin.kind == "wild_encounter" and battle.kind == "wild" then + battle.onFinish = function(result) self:afterBattle(result, battle) end + return true + end + if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer" + or origin.trainerClass ~= battle.oppClass + or origin.partyIndex ~= (battle.partyIndex or 1) + or type(origin.npcId) ~= "string" then + return false + end + battle.onFinish = function(result) + if result == "win" then + game.save.defeatedTrainers[origin.npcId] = true + if origin.event then game.save.flags[origin.event] = true end + self:checkVictoryRewards(battle.oppClass, battle.partyIndex) + end + self:afterBattle(result, battle) + self.engaging = false + local npc = self.npcPool and self.npcPool[origin.npcId] + if npc then npc.frozen = false end + end + return true +end + -- ------------------------------------------------------------------------- -- warps -- ------------------------------------------------------------------------- diff --git a/tests/engine/battle_checkpoint_boundary.lua b/tests/engine/battle_checkpoint_boundary.lua new file mode 100644 index 00000000..e8d1bf5f --- /dev/null +++ b/tests/engine/battle_checkpoint_boundary.lua @@ -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() diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua new file mode 100644 index 00000000..84319a6b --- /dev/null +++ b/tests/engine/battle_checkpoint_capture.lua @@ -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() diff --git a/tests/engine/battle_checkpoint_continuation.lua b/tests/engine/battle_checkpoint_continuation.lua new file mode 100644 index 00000000..3219119f --- /dev/null +++ b/tests/engine/battle_checkpoint_continuation.lua @@ -0,0 +1,102 @@ +-- Engine-owned battle continuations replace unserializable onFinish closures +-- after a persistent checkpoint reconstructs the overworld and battle. + +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness").suite("battle checkpoint continuation") +local GameMethods = require("src.core.Game") +local OverworldState = require("src.world.OverworldController") + +local function fakeOverworld() + local npc = { id = "FIX_TOWN_obj_1", frozen = true } + local ow = setmetatable({ + map = { id = "FIX_TOWN" }, + npcPool = { [npc.id] = npc }, + engaging = true, + }, { __index = OverworldState }) + ow.afterBattle = function(self, result, battle) + self.after = { result = result, battle = battle } + end + ow.checkVictoryRewards = function(self, class, party) + self.reward = { class = class, party = party } + end + return ow, npc +end + +local wildOw = fakeOverworld() +local wildGame = { save = { defeatedTrainers = {}, flags = {} } } +local wild = { game = wildGame, kind = "wild" } +T.check(wildOw:restoreBattleContinuation(wild, + { kind = "wild_encounter", map = "FIX_TOWN" }) == true, + "ordinary wild continuation binds") +wild.onFinish("run") +T.same(wildOw.after, { result = "run", battle = wild }, + "wild continuation returns through canonical afterBattle") + +local trainerOw, trainerNpc = fakeOverworld() +local trainerGame = { save = { defeatedTrainers = {}, flags = {} } } +local trainer = { + game = trainerGame, kind = "trainer", + oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, +} +local trainerOrigin = { + kind = "trainer_encounter", map = "FIX_TOWN", + npcId = trainerNpc.id, trainerClass = trainer.oppClass, partyIndex = 1, + event = "EVENT_BEAT_FIX_TRAINER", +} +T.check(trainerOw:restoreBattleContinuation(trainer, trainerOrigin) == true, + "ordinary trainer continuation binds") +trainer.onFinish("win") +T.check(trainerGame.save.defeatedTrainers[trainerNpc.id] == true, + "trainer win stamps the stable object id") +T.check(trainerGame.save.flags.EVENT_BEAT_FIX_TRAINER == true, + "trainer win stamps the header event") +T.same(trainerOw.reward, + { class = "OPP_FIX_YOUNGSTER", party = 1 }, + "trainer win runs canonical victory rewards") +T.same(trainerOw.after, { result = "win", battle = trainer }, + "trainer win returns through canonical afterBattle") +T.check(trainerOw.engaging == false and trainerNpc.frozen == false, + "reconstructed trainer completion leaves overworld input unfrozen") + +local lossOw, lossNpc = fakeOverworld() +local lossGame = { save = { defeatedTrainers = {}, flags = {} } } +local lossBattle = { + game = lossGame, kind = "trainer", + oppClass = "OPP_FIX_YOUNGSTER", partyIndex = 1, +} +T.check(lossOw:restoreBattleContinuation(lossBattle, trainerOrigin) == true, + "trainer loss continuation binds") +lossBattle.onFinish("lose") +T.eq(lossGame.save.defeatedTrainers[lossNpc.id], nil, + "trainer loss does not stamp the trainer defeated") +T.eq(lossGame.save.flags.EVENT_BEAT_FIX_TRAINER, nil, + "trainer loss does not stamp the header event") +T.eq(lossOw.reward, nil, "trainer loss does not grant victory rewards") + +local mismatchOw = fakeOverworld() +T.check(mismatchOw:restoreBattleContinuation(trainer, { + kind = "trainer_encounter", map = "OTHER_MAP", npcId = trainerNpc.id, + trainerClass = trainer.oppClass, partyIndex = 1, +}) == false, "continuation from another map is rejected") +T.check(mismatchOw:restoreBattleContinuation(trainer, { + kind = "trainer_encounter", map = "FIX_TOWN", npcId = trainerNpc.id, + trainerClass = "OPP_OTHER", partyIndex = 1, +}) == false, "mismatched trainer identity is rejected") + +local ow = {} +local stack = { states = { ow } } +function stack:top() return self.states[#self.states] end +local game = setmetatable({ overworld = ow, stack = stack }, { __index = GameMethods }) +local entered, resumed = false, false +local battle = { + enter = function() entered = true end, + resumeCheckpoint = function() resumed = true end, +} +game:restoreCheckpointBattle(battle) +T.check(game.stack:top() == battle, "reconstructed battle is installed on stack") +T.check(resumed == true, "checkpoint-specific battle resume path runs") +T.check(entered == false, "ordinary battle intro is not replayed") + +T.finish() diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua new file mode 100644 index 00000000..1ea08ea1 --- /dev/null +++ b/tests/engine/battle_checkpoint_restore.lua @@ -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() diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index a1277b1e..adea5f90 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -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()