mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
Merge pull request #986 from MaxTomahawk/feat/mod-battle-checkpoints
feat: add persistent battle safe-point checkpoints
This commit is contained in:
+12
-9
@@ -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
|
||||
|
||||
|
||||
@@ -104,10 +104,11 @@ runtime exception, not process termination.
|
||||
|
||||
## Runtime boundary and future kinds
|
||||
|
||||
Format 1 intentionally rejects battles, menus, transitions, animations, and
|
||||
suspended/queued scripts. Future battle or explicit script-checkpoint kinds must
|
||||
have separate inventories, validation, reconstruction, deterministic RNG, and
|
||||
differential tests; they are not implied by this RFC.
|
||||
This RFC's original Level A contract intentionally rejects battles, menus,
|
||||
transitions, animations, and suspended/queued scripts. RFC 0005 subsequently
|
||||
adds a separately inventoried `battle` kind with deterministic RNG and
|
||||
differential reconstruction tests; it does not broaden script or arbitrary-frame
|
||||
support implied here.
|
||||
|
||||
## Migration note for existing mods
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
# RFC 0005 — Persistent battle safe-point checkpoints
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Extends RFC 0004. Engine: `BattleCheckpoint.lua`, `Checkpoint.lua`,
|
||||
`Game.lua`, `BattleState.lua`, and `OverworldController.lua`. Tests:
|
||||
`battle_checkpoint_*.lua`, `checkpoints.lua`, and the existing no-mod suites.
|
||||
|
||||
## Motivation
|
||||
|
||||
RFC 0004 lets a tool capture and reconstruct settled overworld progress without
|
||||
private engine access. A battle is a different runtime: its queue can hold Lua
|
||||
functions and UI factories, its controller contains renderer objects and live
|
||||
references, completion is currently an `onFinish` closure, and scripted battles
|
||||
resume a suspended `ScriptRunner` coroutine. Copying the controller would create
|
||||
a record that is neither data-only nor process-independent.
|
||||
|
||||
The engine can instead expose a narrow semantic safe point. This gives all mods
|
||||
the strongest persistent battle checkpoint the current architecture can prove,
|
||||
without claiming mid-animation or suspended-script support.
|
||||
|
||||
## API delta
|
||||
|
||||
No new facade is added. The existing additive `mod.checkpoints` API gains a
|
||||
second format-1 runtime kind.
|
||||
|
||||
### Capability
|
||||
|
||||
`mod.checkpoints:inspect(game)` returns this only when an ordinary single-player
|
||||
wild or trainer battle is settled at the player command menu:
|
||||
|
||||
```lua
|
||||
{ canCapture = true, canRestore = true, kind = "battle" }
|
||||
```
|
||||
|
||||
The action/message queue, waits, UI, animations, HP/status presentation, and
|
||||
faint processing must be settled. The player must actually control the menu.
|
||||
The underlying overworld must have no running/queued script or scripted move,
|
||||
and the battle must carry an engine-owned semantic continuation descriptor.
|
||||
|
||||
Additional refusal codes are `battle_phase_busy`, `battle_origin_unsupported`,
|
||||
`battle_variant_unsupported`, and `link_battle_unsupported`. Link, Safari,
|
||||
ghost, old-man/demo, fishing, static-object, script-suspended, and mod-created
|
||||
closure continuations remain rejected.
|
||||
|
||||
### Capture
|
||||
|
||||
A battle checkpoint remains detached and data-only:
|
||||
|
||||
```lua
|
||||
{
|
||||
format = 1,
|
||||
kind = "battle",
|
||||
identity = { engineVersion = "...", gameVersion = "red",
|
||||
playthroughId = "..." },
|
||||
save = { -- canonical dynamic progress, excluding global options },
|
||||
runtime = {
|
||||
overworld = { map = "ROUTE_1", x = 7, y = 8,
|
||||
facing = "left", surfing = false },
|
||||
battle = { -- normalized semantic model and continuation },
|
||||
},
|
||||
rng = { love = "..." },
|
||||
}
|
||||
```
|
||||
|
||||
The model carries player/enemy roster indices, dynamic enemy Pokémon, turn and
|
||||
escape state, HP/PP/status/stages/volatiles, participants, level-up tracking,
|
||||
trainer AI state, battle ruleset identity, side/field extension data, and
|
||||
normalized pointer relationships such as multi-turn move slots and Mimic
|
||||
restoration entries. Definitions, sprites, canvases, queues, callbacks, and
|
||||
controller objects are reconstructed or excluded.
|
||||
|
||||
Callback-bearing battle extension tokens fail with `battle_extension_unsafe`;
|
||||
invalid live reference relationships fail with `battle_state_invalid`. Nothing
|
||||
is silently stripped.
|
||||
|
||||
New overworld checkpoints also carry the LÖVE gameplay RNG state. Legacy
|
||||
format-1 overworld checkpoints without `rng` remain loadable and leave the
|
||||
current stream untouched.
|
||||
|
||||
### Restore
|
||||
|
||||
Battle restore validates the detached save, map, content references, ruleset,
|
||||
roster indices, move references, continuation identity, and RNG before live
|
||||
mutation. The engine then:
|
||||
|
||||
1. reconstructs the saved overworld return point without entry side effects;
|
||||
2. creates a fresh `BattleState` from current content registries;
|
||||
3. applies the normalized battle model and rebuilds object-reference relations;
|
||||
4. binds an engine-owned wild/trainer completion continuation;
|
||||
5. installs the battle directly at the settled menu without replaying its intro;
|
||||
6. restores the RNG after reconstruction has finished; and
|
||||
7. recaptures and compares the complete checkpoint.
|
||||
|
||||
The pre-operation checkpoint is the transaction rollback. A failed post-install
|
||||
RNG restore is covered: both battle runtime and RNG are reconstructed back to
|
||||
their original values.
|
||||
|
||||
## Continuation decision
|
||||
|
||||
Ordinary random wild battles resume through `OverworldState:afterBattle`.
|
||||
Ordinary trainer battles use a descriptor containing map id, stable NPC id,
|
||||
trainer class/party, and optional header event; a win reapplies the same defeated
|
||||
flag, event, reward, and `afterBattle` path. Reconstructed overworld input and
|
||||
NPC freeze state are normalized instead of reviving the old closure.
|
||||
|
||||
`Commands.start_battle` is deliberately unsupported: its completion closure
|
||||
mutates script context and resumes a coroutine whose program counter and Lua
|
||||
stack cannot be serialized. Existing script rejection remains the correct safe
|
||||
contract until a separate semantic ScriptRunner checkpoint RFC exists.
|
||||
|
||||
## Migration note
|
||||
|
||||
**Existing mods require no changes.** The facade and format number are unchanged;
|
||||
the new kind and RNG field are additive. Overworld-only callers may continue to
|
||||
filter `capability.kind`. No-mod behavior is unchanged when checkpoints are
|
||||
unused.
|
||||
|
||||
## Verification
|
||||
|
||||
- settled/unsafe boundary and every variant refusal;
|
||||
- data-only wild and trainer capture, including callback-bearing extension
|
||||
rejection;
|
||||
- process-independent controller and continuation reconstruction;
|
||||
- exact differential recapture for wild and trainer states;
|
||||
- HP, PP, status/stages/volatiles, AI layer, participants, enemy roster,
|
||||
multi-turn move references, and Mimic restore pointers;
|
||||
- exact damage, critical, accuracy, random AI, escape, next encounter, and next
|
||||
raw RNG result after reload;
|
||||
- corrupt content/continuation rejection before mutation;
|
||||
- injected post-install failure with full runtime and RNG rollback;
|
||||
- legacy overworld checkpoint compatibility;
|
||||
- complete ROM-free engine and public mod-API suites.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
-- Semantic, data-only capture for settled single-player battle checkpoints.
|
||||
-- Reconstruction lives here too; public mods only see the opaque checkpoint
|
||||
-- facade in Loader.
|
||||
|
||||
local BattleCheckpoint = {}
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local BUILTIN_RULESETS = {
|
||||
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
||||
modern_clean = require("src.battle.rulesets.modern_clean"),
|
||||
}
|
||||
|
||||
local function rulesets(game)
|
||||
return game.data.rulesets or BUILTIN_RULESETS
|
||||
end
|
||||
|
||||
local function rulesetId(game, record)
|
||||
for id, candidate in pairs(rulesets(game)) do
|
||||
if candidate == record then return id end
|
||||
end
|
||||
end
|
||||
|
||||
local BATTLER_FIELDS = {
|
||||
"shownHP", "shownStatus", "stages", "curStats", "curTypes", "curMoves",
|
||||
"sleepTurns", "confusedTurns", "disabledSlot", "disabledTurns",
|
||||
"toxicCounter", "substituteHP", "bideDamage", "bideTurns", "boundTurns",
|
||||
"chargeReady", "invulnerable", "mustRecharge",
|
||||
"thrashTurns", "thrashAnnounced", "focusEnergy", "leechSeeded",
|
||||
"lightScreen", "reflect", "mist", "xAccuracy", "lastMove", "flinched",
|
||||
"skipMove", "hazeStatReset", "drainFloor", "drainHold", "trappingTurns",
|
||||
"trapMove", "trapDamage", "fainted",
|
||||
"aiLayer2",
|
||||
}
|
||||
|
||||
local MOVE_REFERENCE_FIELDS = {
|
||||
charging = "chargingSlot",
|
||||
thrashMove = "thrashMoveSlot",
|
||||
rageMove = "rageMoveSlot",
|
||||
}
|
||||
|
||||
local BATTLE_FIELDS = {
|
||||
"oppClass", "partyIndex", "enemyIndex", "turnCount", "menuIndex",
|
||||
"moveIndex", "moveSwapIndex", "aiUses", "runAttempts", "payDay",
|
||||
"sideToxic", "isGymLeader", "musicKind", "lastBall", "lockedBall",
|
||||
"lowHealthAlarmDisabled", "lowHealthAlarmOn", "victoryMusicPlayed",
|
||||
"endBattleText",
|
||||
}
|
||||
|
||||
local function partyIndex(party, mon)
|
||||
for index, candidate in ipairs(party or {}) do
|
||||
if candidate == mon then return index end
|
||||
end
|
||||
end
|
||||
|
||||
local function indexSet(set, party)
|
||||
local out = {}
|
||||
for mon, present in pairs(set or {}) do
|
||||
if present then
|
||||
local index = partyIndex(party, mon)
|
||||
if index then out[#out + 1] = index end
|
||||
end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
local function captureBattler(battler, index, copy)
|
||||
local out = {
|
||||
index = index,
|
||||
curStatsFromMon = battler.curStats == battler.mon.stats,
|
||||
curTypesFromDefinition = battler.curTypes == battler.def.types,
|
||||
curMovesFromMon = battler.curMoves == battler.mon.moves,
|
||||
}
|
||||
for _, field in ipairs(BATTLER_FIELDS) do
|
||||
if battler[field] ~= nil then out[field] = battler[field] end
|
||||
end
|
||||
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
local reference = battler[field]
|
||||
if reference ~= nil then
|
||||
for slot, move in ipairs(battler.curMoves or {}) do
|
||||
if move == reference then out[slotField] = slot break end
|
||||
end
|
||||
if out[slotField] == nil then return nil end
|
||||
end
|
||||
end
|
||||
return copy(out)
|
||||
end
|
||||
|
||||
local function integer(value, min, max)
|
||||
return type(value) == "number" and value % 1 == 0
|
||||
and value >= (min or -math.huge) and value <= (max or math.huge)
|
||||
end
|
||||
|
||||
local function validateMoveList(data, moves)
|
||||
if type(moves) ~= "table" then return false end
|
||||
for _, move in ipairs(moves) do
|
||||
if type(move) ~= "table" or type(move.id) ~= "string"
|
||||
or type(data.moves[move.id]) ~= "table" or type(move.pp) ~= "number" then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function validateMon(data, mon)
|
||||
return type(mon) == "table" and type(mon.species) == "string"
|
||||
and type(data.pokemon[mon.species]) == "table" and integer(mon.level, 1, 100)
|
||||
and type(mon.hp) == "number" and type(mon.stats) == "table"
|
||||
and validateMoveList(data, mon.moves)
|
||||
end
|
||||
|
||||
local function validateBattler(data, battler, maxIndex)
|
||||
if type(battler) ~= "table" or not integer(battler.index, 1, maxIndex) then
|
||||
return false
|
||||
end
|
||||
if type(battler.curMoves) ~= "table" then return false end
|
||||
if battler.stages ~= nil then
|
||||
if type(battler.stages) ~= "table" then return false end
|
||||
for _, stage in pairs(battler.stages) do
|
||||
if not integer(stage, -6, 6) then return false end
|
||||
end
|
||||
end
|
||||
for _, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
if battler[slotField] ~= nil
|
||||
and not integer(battler[slotField], 1, #battler.curMoves) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return validateMoveList(data, battler.curMoves)
|
||||
and type(battler.curStats) == "table" and type(battler.curTypes) == "table"
|
||||
end
|
||||
|
||||
local function clone(value, copy)
|
||||
if type(value) ~= "table" then return value end
|
||||
return assert(copy(value))
|
||||
end
|
||||
|
||||
local function captureMimicRestores(battle)
|
||||
local out = {}
|
||||
for _, restore in ipairs(battle.mimicRestores or {}) do
|
||||
local side = restore.battler == battle.player and "player"
|
||||
or restore.battler == battle.enemy and "enemy" or nil
|
||||
local slot
|
||||
for index, move in ipairs(restore.battler and restore.battler.curMoves or {}) do
|
||||
if move == restore.entry then slot = index break end
|
||||
end
|
||||
if not side or not slot or type(restore.id) ~= "string" then return nil end
|
||||
out[#out + 1] = { side = side, slot = slot, id = restore.id }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleCheckpoint.validate(game, checkpoint)
|
||||
local model = checkpoint.runtime and checkpoint.runtime.battle
|
||||
local rngState = checkpoint.rng and checkpoint.rng.love
|
||||
if type(model) ~= "table" or type(model.origin) ~= "table"
|
||||
or type(rngState) ~= "string" or rngState == "" then
|
||||
return nil, "invalid_checkpoint", "Battle checkpoint data or RNG is missing."
|
||||
end
|
||||
local expectedOrigin = model.kind == "wild" and "wild_encounter"
|
||||
or model.kind == "trainer" and "trainer_encounter" or nil
|
||||
if not expectedOrigin or model.origin.kind ~= expectedOrigin
|
||||
or model.origin.map ~= checkpoint.runtime.overworld.map then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Battle continuation data is unsupported or inconsistent."
|
||||
end
|
||||
if type(model.rulesetId) ~= "string"
|
||||
or type(rulesets(game)[model.rulesetId]) ~= "table" then
|
||||
return nil, "invalid_content", "Battle ruleset is unavailable."
|
||||
end
|
||||
if model.kind == "trainer" and (type(model.origin.npcId) ~= "string"
|
||||
or model.origin.trainerClass ~= model.oppClass
|
||||
or model.origin.partyIndex ~= (model.partyIndex or 1)) then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"Trainer continuation data is incomplete or inconsistent."
|
||||
end
|
||||
local party = checkpoint.save.party
|
||||
if type(party) ~= "table" or not validateBattler(game.data, model.player, #party) then
|
||||
return nil, "invalid_content", "Player battle state is invalid."
|
||||
end
|
||||
if model.kind == "wild" then
|
||||
if not validateMon(game.data, model.enemyMon)
|
||||
or not validateBattler(game.data, model.enemy, 1) then
|
||||
return nil, "invalid_content", "Wild opponent state is invalid."
|
||||
end
|
||||
else
|
||||
local trainer = game.data.trainers and game.data.trainers[model.oppClass]
|
||||
if type(trainer) ~= "table" or not integer(model.partyIndex, 1)
|
||||
or type(model.enemyParty) ~= "table" or #model.enemyParty == 0
|
||||
or not integer(model.enemyIndex, 1, #model.enemyParty)
|
||||
or not validateBattler(game.data, model.enemy, #model.enemyParty) then
|
||||
return nil, "invalid_content", "Trainer battle identity or roster is invalid."
|
||||
end
|
||||
for _, mon in ipairs(model.enemyParty) do
|
||||
if not validateMon(game.data, mon) then
|
||||
return nil, "invalid_content", "Trainer opponent state is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
for _, indices in ipairs({ model.participants, model.leveledUp }) do
|
||||
if type(indices) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Battle party reference set is missing."
|
||||
end
|
||||
for _, index in ipairs(indices) do
|
||||
if not integer(index, 1, #party) then
|
||||
return nil, "invalid_checkpoint", "Battle party reference is invalid."
|
||||
end
|
||||
end
|
||||
end
|
||||
if type(model.mimicRestores) ~= "table" then
|
||||
return nil, "invalid_checkpoint", "Mimic restore state is missing."
|
||||
end
|
||||
for _, restore in ipairs(model.mimicRestores) do
|
||||
local battler = restore.side == "player" and model.player
|
||||
or restore.side == "enemy" and model.enemy or nil
|
||||
if not battler or not integer(restore.slot, 1, #battler.curMoves)
|
||||
or type(restore.id) ~= "string"
|
||||
or type(game.data.moves[restore.id]) ~= "table" then
|
||||
return nil, "invalid_content", "Mimic restore state is invalid."
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function applyBattler(target, captured, copy)
|
||||
for _, field in ipairs(BATTLER_FIELDS) do
|
||||
if field ~= "curStats" and field ~= "curTypes" and field ~= "curMoves" then
|
||||
if captured[field] ~= nil then
|
||||
target[field] = clone(captured[field], copy)
|
||||
else
|
||||
target[field] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
target.curStats = captured.curStatsFromMon and target.mon.stats
|
||||
or assert(copy(captured.curStats))
|
||||
target.curTypes = captured.curTypesFromDefinition and target.def.types
|
||||
or assert(copy(captured.curTypes))
|
||||
target.curMoves = captured.curMovesFromMon and target.mon.moves
|
||||
or assert(copy(captured.curMoves))
|
||||
for field, slotField in pairs(MOVE_REFERENCE_FIELDS) do
|
||||
target[field] = captured[slotField] and target.curMoves[captured[slotField]] or nil
|
||||
end
|
||||
return target
|
||||
end
|
||||
|
||||
local function restoreIndexSet(indices, party)
|
||||
local out = {}
|
||||
for _, index in ipairs(indices or {}) do out[party[index]] = true end
|
||||
return next(out) and out or nil
|
||||
end
|
||||
|
||||
function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
local model = checkpoint.runtime.battle
|
||||
local battle
|
||||
if model.kind == "trainer" then
|
||||
battle = BattleState.newTrainer(game, model.oppClass, model.partyIndex)
|
||||
battle.enemyParty = assert(copy(model.enemyParty))
|
||||
battle.enemyIndex = model.enemyIndex
|
||||
else
|
||||
battle = BattleState.newWild(game, model.enemyMon.species, model.enemyMon.level)
|
||||
end
|
||||
|
||||
battle.player = BattleState.makeBattler(game.data,
|
||||
game.save.party[model.player.index], true, game.save)
|
||||
applyBattler(battle.player, model.player, copy)
|
||||
local enemyMon
|
||||
if model.kind == "trainer" then
|
||||
enemyMon = battle.enemyParty[model.enemy.index]
|
||||
else
|
||||
enemyMon = assert(copy(model.enemyMon))
|
||||
end
|
||||
battle.enemy = BattleState.makeBattler(game.data, enemyMon, false)
|
||||
applyBattler(battle.enemy, model.enemy, copy)
|
||||
|
||||
battle.mimicRestores = {}
|
||||
for _, restore in ipairs(model.mimicRestores or {}) do
|
||||
local battler = restore.side == "player" and battle.player or battle.enemy
|
||||
battle.mimicRestores[#battle.mimicRestores + 1] = {
|
||||
battler = battler,
|
||||
entry = battler.curMoves[restore.slot],
|
||||
id = restore.id,
|
||||
}
|
||||
end
|
||||
if #battle.mimicRestores == 0 then battle.mimicRestores = nil end
|
||||
|
||||
for _, field in ipairs(BATTLE_FIELDS) do
|
||||
if model[field] ~= nil then
|
||||
battle[field] = clone(model[field], copy)
|
||||
else
|
||||
battle[field] = nil
|
||||
end
|
||||
end
|
||||
battle.kind = model.kind
|
||||
battle.ruleset = rulesets(game)[model.rulesetId]
|
||||
battle.checkpointOrigin = assert(copy(model.origin))
|
||||
battle.participants = restoreIndexSet(model.participants, game.save.party)
|
||||
battle.leveledUp = restoreIndexSet(model.leveledUp, game.save.party)
|
||||
battle.sides = assert(copy(model.sides))
|
||||
battle.sides[1].battlers = { battle.player }
|
||||
battle.sides[2].battlers = { battle.enemy }
|
||||
battle.field = assert(copy(model.field))
|
||||
battle.field.sides = battle.sides
|
||||
battle.phase, battle.queue = "menu", {}
|
||||
battle.frame = 0
|
||||
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
|
||||
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
|
||||
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
|
||||
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
|
||||
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
|
||||
battle.player.shownHP, battle.player.shownStatus =
|
||||
battle.player.mon.hp, battle.player.mon.status
|
||||
battle.enemy.shownHP, battle.enemy.shownStatus =
|
||||
battle.enemy.mon.hp, battle.enemy.mon.status
|
||||
|
||||
local ow = game.overworld
|
||||
if not ow or type(ow.restoreBattleContinuation) ~= "function"
|
||||
or ow:restoreBattleContinuation(battle, battle.checkpointOrigin) ~= true then
|
||||
error("battle continuation reconstruction is unavailable", 0)
|
||||
end
|
||||
if type(game.restoreCheckpointBattle) ~= "function" then
|
||||
error("game has no battle checkpoint reconstruction path", 0)
|
||||
end
|
||||
game:restoreCheckpointBattle(battle)
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(setState) ~= "function" then error("battle RNG restore is unavailable", 0) end
|
||||
setState(checkpoint.rng.love)
|
||||
return battle
|
||||
end
|
||||
|
||||
local function captureExtensions(battle, copy)
|
||||
local sides = {}
|
||||
for i = 1, 2 do
|
||||
local side = battle.sides and battle.sides[i] or {}
|
||||
local encoded, err = copy({
|
||||
index = i,
|
||||
screens = side.screens or {},
|
||||
hazards = side.hazards or {},
|
||||
tokens = side.tokens or {},
|
||||
})
|
||||
if not encoded then return nil, err end
|
||||
sides[i] = encoded
|
||||
end
|
||||
local field, err = copy({
|
||||
weather = battle.field and battle.field.weather or nil,
|
||||
tokens = battle.field and battle.field.tokens or {},
|
||||
})
|
||||
if not field then return nil, err end
|
||||
return sides, field
|
||||
end
|
||||
|
||||
function BattleCheckpoint.capture(game, battle, progress, copy)
|
||||
local getState = love and love.math and love.math.getRandomState
|
||||
local setState = love and love.math and love.math.setRandomState
|
||||
if type(getState) ~= "function" or type(setState) ~= "function" then
|
||||
return nil, "rng_state_unavailable",
|
||||
"This runtime cannot preserve deterministic battle randomness."
|
||||
end
|
||||
local ok, rngState = pcall(getState)
|
||||
if not ok or type(rngState) ~= "string" or rngState == "" then
|
||||
return nil, "rng_state_unavailable",
|
||||
"The gameplay random-number state could not be captured."
|
||||
end
|
||||
|
||||
local origin, originErr = copy(battle.checkpointOrigin)
|
||||
if not origin then
|
||||
return nil, "battle_origin_unsupported",
|
||||
"The battle completion path is not data-only: " .. tostring(originErr)
|
||||
end
|
||||
local sides, fieldOrErr = captureExtensions(battle, copy)
|
||||
if not sides then
|
||||
return nil, "battle_extension_unsafe",
|
||||
"Battle extension state is not data-only: " .. tostring(fieldOrErr)
|
||||
end
|
||||
local field = fieldOrErr
|
||||
|
||||
local liveParty = game.save.party
|
||||
local playerIndex = partyIndex(liveParty, battle.player.mon)
|
||||
if not playerIndex then
|
||||
return nil, "battle_state_invalid",
|
||||
"The active player battler is not in the current party."
|
||||
end
|
||||
|
||||
local model = {
|
||||
kind = battle.kind,
|
||||
rulesetId = rulesetId(game, battle.ruleset),
|
||||
origin = origin,
|
||||
player = captureBattler(battle.player, playerIndex, copy),
|
||||
participants = indexSet(battle.participants, liveParty),
|
||||
leveledUp = indexSet(battle.leveledUp, liveParty),
|
||||
sides = sides,
|
||||
field = field,
|
||||
mimicRestores = captureMimicRestores(battle),
|
||||
}
|
||||
if not model.rulesetId then
|
||||
return nil, "battle_state_invalid", "Battle ruleset identity is unavailable."
|
||||
end
|
||||
if not model.player then
|
||||
return nil, "battle_state_invalid", "Player move references are inconsistent."
|
||||
end
|
||||
if not model.mimicRestores then
|
||||
return nil, "battle_state_invalid", "Mimic restore state is inconsistent."
|
||||
end
|
||||
if battle.kind == "trainer" then
|
||||
model.enemyParty = copy(battle.enemyParty)
|
||||
model.enemy = captureBattler(battle.enemy, battle.enemyIndex, copy)
|
||||
else
|
||||
model.enemyMon = copy(battle.enemy.mon)
|
||||
model.enemy = captureBattler(battle.enemy, 1, copy)
|
||||
end
|
||||
if not model.enemy then
|
||||
return nil, "battle_state_invalid", "Enemy move references are inconsistent."
|
||||
end
|
||||
for _, fieldName in ipairs(BATTLE_FIELDS) do
|
||||
if battle[fieldName] ~= nil then model[fieldName] = battle[fieldName] end
|
||||
end
|
||||
|
||||
model = copy(model)
|
||||
if not model then
|
||||
return nil, "battle_state_invalid",
|
||||
"Battle state contains non-serializable runtime data."
|
||||
end
|
||||
local player = progress.player
|
||||
return {
|
||||
overworld = {
|
||||
map = player.map, x = player.x, y = player.y,
|
||||
facing = player.facing, surfing = player.surfing and true or false,
|
||||
},
|
||||
battle = model,
|
||||
}, { love = rngState }
|
||||
end
|
||||
|
||||
return BattleCheckpoint
|
||||
+155
-6
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3059,6 +3059,14 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
if theme then require("src.core.Music").play(Game.data, theme) end
|
||||
end
|
||||
local battle = BattleState.newTrainer(Game, d.trainerClass, d.trainerParty)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "trainer_encounter",
|
||||
map = self.map.id,
|
||||
npcId = npc.id,
|
||||
trainerClass = d.trainerClass,
|
||||
partyIndex = d.trainerParty or 1,
|
||||
event = header and header.event or nil,
|
||||
}
|
||||
-- PrintEndBattleText (home/trainers.asm:341) is called from
|
||||
-- TrainerBattleVictory (engine/battle/core.asm:942), i.e. ON the battle
|
||||
-- screen once ScrollTrainerPicAfterBattle has brought the beaten trainer
|
||||
@@ -3596,6 +3604,10 @@ function OverworldState:onStepComplete()
|
||||
end
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local battle = BattleState.newWild(Game, enc.species, enc.level)
|
||||
battle.checkpointOrigin = {
|
||||
kind = "wild_encounter",
|
||||
map = self.map.id,
|
||||
}
|
||||
-- map.ghostBattles: unidentifiable without the named item (the
|
||||
-- Pokemon Tower's Silph Scope)
|
||||
local ghost = Map.ghostBattles(self.map.def)
|
||||
@@ -4000,6 +4012,39 @@ function OverworldState:afterBattle(result, battle)
|
||||
end
|
||||
end
|
||||
|
||||
-- Rebind the data-only continuation attached to a supported battle checkpoint.
|
||||
-- The overworld was reconstructed first, so transient input/NPC freezes from
|
||||
-- the original encounter are intentionally not resumed.
|
||||
function OverworldState:restoreBattleContinuation(battle, origin)
|
||||
local game = battle and battle.game
|
||||
if not game or type(origin) ~= "table" or not self.map
|
||||
or origin.map ~= self.map.id then
|
||||
return false
|
||||
end
|
||||
if origin.kind == "wild_encounter" and battle.kind == "wild" then
|
||||
battle.onFinish = function(result) self:afterBattle(result, battle) end
|
||||
return true
|
||||
end
|
||||
if origin.kind ~= "trainer_encounter" or battle.kind ~= "trainer"
|
||||
or origin.trainerClass ~= battle.oppClass
|
||||
or origin.partyIndex ~= (battle.partyIndex or 1)
|
||||
or type(origin.npcId) ~= "string" then
|
||||
return false
|
||||
end
|
||||
battle.onFinish = function(result)
|
||||
if result == "win" then
|
||||
game.save.defeatedTrainers[origin.npcId] = true
|
||||
if origin.event then game.save.flags[origin.event] = true end
|
||||
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
|
||||
end
|
||||
self:afterBattle(result, battle)
|
||||
self.engaging = false
|
||||
local npc = self.npcPool and self.npcPool[origin.npcId]
|
||||
if npc then npc.frozen = false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- -------------------------------------------------------------------------
|
||||
-- warps
|
||||
-- -------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Battle checkpoints are exposed only at a settled, reconstructable player
|
||||
-- decision boundary. This suite is ROM-free and exercises the public engine
|
||||
-- checkpoint capability against the fixture battle implementation.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("battle checkpoint boundary")
|
||||
local Fixtures = require("tests.modkit").fixtures
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Checkpoint = require("src.core.Checkpoint")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.meta.playthroughId = "battle-playthrough"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
|
||||
local stack = setmetatable({ states = {} }, { __index = StateStack })
|
||||
local overworld = {
|
||||
map = { id = save.player.map },
|
||||
player = {
|
||||
cellX = save.player.x, cellY = save.player.y,
|
||||
facing = save.player.facing, surfing = false,
|
||||
},
|
||||
runner = { isRunning = function() return false end },
|
||||
parallelRunners = {}, pendingScripts = {}, parallelQueue = {}, scriptMoves = {},
|
||||
}
|
||||
local game = { data = Data, save = save, stack = stack, overworld = overworld }
|
||||
stack.states[1] = overworld
|
||||
local battle = BattleState.newWild(game, "FIXMON_B", 12)
|
||||
battle.phase = "menu"
|
||||
battle.queue = {}
|
||||
battle.checkpointOrigin = { kind = "wild_encounter" }
|
||||
battle.onFinish = function() end
|
||||
stack.states[2] = battle
|
||||
return game, overworld, battle
|
||||
end
|
||||
|
||||
local game, overworld, battle = makeGame()
|
||||
T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "battle",
|
||||
}, "settled standard wild battle is a checkpoint boundary")
|
||||
|
||||
local function refused(mutator, code, label)
|
||||
local game2, ow2, battle2 = makeGame()
|
||||
mutator(game2, ow2, battle2)
|
||||
local capability = Checkpoint.inspect(game2)
|
||||
T.check(capability.canCapture == false and capability.reason == code,
|
||||
label .. ": " .. tostring(capability.reason))
|
||||
end
|
||||
|
||||
refused(function(_, _, b) b.phase = "messages" end,
|
||||
"battle_phase_busy", "message phase is rejected")
|
||||
refused(function(_, _, b) b.queue = { { text = "busy" } } end,
|
||||
"battle_phase_busy", "nonempty action queue is rejected")
|
||||
refused(function(_, _, b) b.waitFrames = 1 end,
|
||||
"battle_phase_busy", "partial wait is rejected")
|
||||
refused(function(_, _, b) b.enemy.mon.hp = b.enemy.mon.hp - 1 end,
|
||||
"battle_phase_busy", "unfinished HP display synchronization is rejected")
|
||||
refused(function(_, _, b) b.player.mustRecharge = true end,
|
||||
"battle_phase_busy", "automatic locked action is rejected")
|
||||
refused(function(_, ow) ow.runner = { isRunning = function() return true end } end,
|
||||
"script_busy", "suspended script beneath battle is rejected")
|
||||
refused(function(_, _, b) b.checkpointOrigin = nil end,
|
||||
"battle_origin_unsupported", "unknown completion closure is rejected")
|
||||
refused(function(_, _, b) b.safari = { balls = 30, steps = 10 } end,
|
||||
"battle_variant_unsupported", "Safari battle is rejected")
|
||||
refused(function(_, _, b) b.ghost = true end,
|
||||
"battle_variant_unsupported", "ghost battle is rejected")
|
||||
refused(function(_, _, b) b.demo = true end,
|
||||
"battle_variant_unsupported", "old-man demo is rejected")
|
||||
refused(function(_, _, b) b.kind = "link" end,
|
||||
"link_battle_unsupported", "link battle is rejected")
|
||||
|
||||
-- Ordinary overworld behavior remains unchanged by the battle branch.
|
||||
game.stack.states[2] = nil
|
||||
T.same(Checkpoint.inspect(game), {
|
||||
canCapture = true, canRestore = true, kind = "overworld",
|
||||
}, "settled overworld remains supported")
|
||||
|
||||
T.finish()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user