diff --git a/docs/rfcs/0004-runtime-checkpoints.md b/docs/rfcs/0004-runtime-checkpoints.md index 9d3bc175..e9ba649c 100644 --- a/docs/rfcs/0004-runtime-checkpoints.md +++ b/docs/rfcs/0004-runtime-checkpoints.md @@ -79,10 +79,14 @@ format, kind, internal identity consistency, current game/playthrough identity, map availability, integral in-bounds tile, facing, surfing, and synchronized save position. -Validation codes are `invalid_checkpoint`, `unsupported_format`, +Validation codes are `invalid_checkpoint`, `invalid_content`, `unsupported_format`, `unsupported_runtime_kind`, `wrong_game`, `wrong_playthrough`, `invalid_map`, and `invalid_position`, in addition to the capability refusal reasons. +The canonical save validator runs against the detached record. Unlike ordinary +CONTINUE, a checkpoint never accepts a quarantine, remap, reclaim, clamp, or +repair: any such content change returns `invalid_content` before live mutation. + The engine captures an in-memory rollback checkpoint, preserves current global options, then reconstructs semantic overworld state through `Game:restoreCheckpointSave`. Checkpoint entry suppresses normal map exit/entry diff --git a/src/core/Checkpoint.lua b/src/core/Checkpoint.lua index 07427c94..d1082855 100644 --- a/src/core/Checkpoint.lua +++ b/src/core/Checkpoint.lua @@ -195,6 +195,19 @@ local function validate(game, checkpoint) or runtime.x >= width * 2 or runtime.y >= height * 2 then return nil, "invalid_position", "Checkpoint position is outside the map." end + + -- A checkpoint is a strict restoration record, not an ordinary CONTINUE + -- migration. Reuse the canonical save validator on the detached copy, but + -- reject any quarantine, remap, reclaim, clamp, or content repair it would + -- perform instead of silently changing the state the caller selected. + local beforeContent = SaveSerializer.encode(copy.save) + local validOk, report = pcall(SaveData.validate, copy.save, game.data) + local afterOk, afterContent = pcall(SaveSerializer.encode, copy.save) + if not validOk or not afterOk or not SaveData.emptyReport(report) + or afterContent ~= beforeContent then + return nil, "invalid_content", + "Checkpoint references unavailable or invalid game content." + end return copy end diff --git a/tests/modkit/cases/checkpoints.lua b/tests/modkit/cases/checkpoints.lua index 0feeed1c..3dd4aa09 100644 --- a/tests/modkit/cases/checkpoints.lua +++ b/tests/modkit/cases/checkpoints.lua @@ -56,7 +56,8 @@ local function baseSave() name = "RED", rival = "BLUE", id = 7, }, money = 3000, - party = { { species = "BULBASAUR", hp = 19, moves = { "TACKLE" } } }, + party = { { species = "BULBASAUR", level = 5, hp = 19, + moves = { "TACKLE" } } }, flags = { GOT_STARTER = true }, inventory = { POTION = 1 }, pcItems = {}, box = {}, boxes = {}, defeatedTrainers = {}, @@ -99,11 +100,18 @@ local function makeGame() end game = setmetatable({ save = baseSave(), stack = stack, overworld = ow, - data = { maps = { - PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 }, - ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 }, - BROKEN = { id = "BROKEN", width = 10, height = 9 }, - } }, + data = { + pokemon = { BULBASAUR = { dex = 1 } }, + moves = { TACKLE = { pp = 35 } }, + items = { POTION = {} }, + constants = { fallbackMove = "TACKLE" }, + field = { boot = { startMap = "PALLET_TOWN", startX = 5, startY = 6 } }, + maps = { + PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 }, + ROUTE_1 = { id = "ROUTE_1", width = 10, height = 18 }, + BROKEN = { id = "BROKEN", width = 10, height = 9 }, + }, + }, }, { __index = GameMethods }) stack.states[1] = ow return game, ow @@ -261,6 +269,15 @@ T.check(not restored and restoreCode == "invalid_map", T.same(checkpoints:capture(game), beforeRejected, "validation failures leave the live state unchanged") +local invalidGame = makeGame() +local badSpecies = checkpoints:capture(invalidGame) +badSpecies.save.party[1].species = "MISSING_SPECIES" +restored, restoreCode = checkpoints:restore(invalidGame, badSpecies) +T.check(not restored and restoreCode == "invalid_content", + "unknown Pokemon content is rejected before reconstruction") +T.eq(invalidGame.save.party[1].species, "BULBASAUR", + "invalid Pokemon content leaves the live party unchanged") + -- A reconstruction exception rolls back to the exact pre-operation state. local target = checkpoints:capture(game) target.runtime.overworld.map = "BROKEN"