mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
test: prove deterministic battle checkpoint fidelity
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user