From 2ce612d5b14be1dbf4e542674bf6c48ad19b1e16 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 16:40:14 +0200 Subject: [PATCH] test: prove deterministic battle checkpoint fidelity --- src/core/BattleCheckpoint.lua | 101 ++++++++++++++++++- tests/engine/battle_checkpoint_capture.lua | 20 ++++ tests/engine/battle_checkpoint_restore.lua | 109 ++++++++++++++++++++- 3 files changed, 225 insertions(+), 5 deletions(-) diff --git a/src/core/BattleCheckpoint.lua b/src/core/BattleCheckpoint.lua index b424034f..912ea1b2 100644 --- a/src/core/BattleCheckpoint.lua +++ b/src/core/BattleCheckpoint.lua @@ -4,16 +4,37 @@ 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", - "charging", "chargeReady", "invulnerable", "mustRecharge", "thrashMove", - "thrashTurns", "thrashAnnounced", "rageMove", "focusEnergy", "leechSeeded", + "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 = { @@ -52,6 +73,15 @@ local function captureBattler(battler, index, copy) 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 @@ -82,12 +112,19 @@ 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 @@ -97,6 +134,21 @@ local function clone(value, copy) 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 @@ -111,6 +163,10 @@ function BattleCheckpoint.validate(game, checkpoint) 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 @@ -150,6 +206,18 @@ function BattleCheckpoint.validate(game, checkpoint) 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 @@ -169,6 +237,9 @@ local function applyBattler(target, captured, copy) 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 @@ -201,6 +272,17 @@ function BattleCheckpoint.restore(game, checkpoint, copy) 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) @@ -209,6 +291,7 @@ function BattleCheckpoint.restore(game, checkpoint, copy) 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) @@ -299,13 +382,24 @@ function BattleCheckpoint.capture(game, battle, progress, copy) 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) @@ -313,6 +407,9 @@ function BattleCheckpoint.capture(game, battle, progress, copy) 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 diff --git a/tests/engine/battle_checkpoint_capture.lua b/tests/engine/battle_checkpoint_capture.lua index 13f7b16a..84319a6b 100644 --- a/tests/engine/battle_checkpoint_capture.lua +++ b/tests/engine/battle_checkpoint_capture.lua @@ -67,9 +67,18 @@ 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 } @@ -89,6 +98,8 @@ if snapshot and snapshot.kind == "battle" then { 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") @@ -96,8 +107,17 @@ if snapshot and snapshot.kind == "battle" then "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 }, diff --git a/tests/engine/battle_checkpoint_restore.lua b/tests/engine/battle_checkpoint_restore.lua index 95f23c80..d3aa32dd 100644 --- a/tests/engine/battle_checkpoint_restore.lua +++ b/tests/engine/battle_checkpoint_restore.lua @@ -8,11 +8,15 @@ 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 @@ -111,15 +115,51 @@ 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 expectedNext = love.math.random(1, 1000000) +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)) @@ -132,17 +172,40 @@ if restored then 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") - T.eq(love.math.random(1, 1000000), expectedNext, - "the exact next gameplay RNG result repeats after reload") + 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" }, @@ -171,6 +234,46 @@ if restored then "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