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
@@ -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()