Files
gen1recomp/tests/modkit/cases/title_playthrough_context.lua
T
2026-08-14 21:42:45 +01:00

325 lines
15 KiB
Lua

-- A tool can persist a checkpoint before the first normal Pokémon save. After
-- a restart the title runtime is deliberately a fresh save skeleton, so it
-- needs a non-allocating binding to the already-selected playthrough -- not a
-- call to the normal active-playthrough storage methods, which would mint an id.
--
-- This is a public SDK contract test. The fixture never reaches into storage
-- paths or launcher slot internals.
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod title playthrough context")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local SaveSerializer = require("src.core.SaveSerializer")
local Version = require("src.core.Version")
local GameMethods = require("src.core.Game")
local StateStack = require("src.core.StateStack")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
local realFs = love.filesystem
local function memfs(files)
return {
read = function(path) return files[path] end,
write = function(path, body) files[path] = body return true end,
remove = function(path) files[path] = nil return true end,
createDirectory = function() return true end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
return nil
end,
load = function(path)
if not files[path] then return nil, "no file: " .. path end
return load(files[path], path)
end,
getDirectoryItems = function(path)
local prefix, seen, out = path .. "/", {}, {}
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then
local child = key:sub(#prefix + 1):match("^[^/]+")
if child and not seen[child] then seen[child] = true; out[#out + 1] = child end
end
end
table.sort(out)
return out
end,
}
end
local files = {
["mods/probe/manifest.json"] =
'{"id":"probe","name":"probe","version":"1.0.0",'
.. '"entry":"main.lua","api":2,"profile":"content"}',
-- mod.exports, not _G: a mod's globals are its own (src/mods/Sandbox.lua)
["mods/probe/main.lua"] = [[
return function(mod)
local out = mod.exports
out.storage = mod.storage
out.checkpoints = mod.checkpoints
out.restoreCount = 0
mod.events:on("checkpoint.restored", function(ev)
out.restoreCount = out.restoreCount + 1
out.restoreKind = ev.kind
end)
end
]],
}
local fs = memfs(files)
love.filesystem = fs
-- Production storage and checkpoint resume share the same engine persistence
-- backend. Route the test's default SaveData lookup to this fixture backend so
-- the restart path exercises that shared mapping rather than host test files.
local originalLoadOptions = SaveData.loadOptions
SaveData.loadOptions = function(injectedFs)
return originalLoadOptions(injectedFs or fs)
end
local active = { save = SaveData.newGame({ version = "red" }) }
local loader = Loader.new({ fs = fs })
loader.game = active
T.check(loader:load({}) == true, "title-context fixture mod loads")
local probe = loader.exports.probe or {}
local storage = probe.storage
T.check(type(storage) == "table", "loader exposes the public storage facade")
if type(storage) == "table" then
local written, writeCode, writeMessage = storage:write(active, "history/index", {
format = 1, newest = "q0001",
})
T.check(written == true,
"a fresh playthrough can durably store tool history: "
.. tostring(writeCode or writeMessage))
local originalId = active.save.meta and active.save.meta.playthroughId
T.check(type(originalId) == "string" and originalId ~= "",
"first tool persistence allocates the opaque active playthrough identity")
local nonTitleSelected, nonTitleCode = storage:selected(active)
T.check(nonTitleSelected == nil and nonTitleCode == "not_at_title",
"selected-playthrough storage cannot be used from active gameplay")
-- Simulate a fresh process/title session. The normal save was never written:
-- only the engine-owned slot/playthrough mapping and this mod's durable data
-- exist. The title skeleton must remain unmodified by browsing.
SaveData.resetSlotState()
local title = {
save = SaveData.newGame({ version = "red" }),
stack = {
states = { { screenId = "TitleState" } },
top = function(self) return self.states[#self.states] end,
},
}
T.check(title.save.meta.playthroughId == nil,
"title starts from an unbound fresh skeleton before normal SAVE")
T.check(type(storage.selected) == "function",
"public storage exposes a read-only selected-playthrough binding at title")
if type(storage.selected) == "function" then
local selected, selectedCode, selectedMessage = storage:selected(title)
T.check(type(selected) == "table",
"title resolves the selected existing playthrough: "
.. tostring(selectedCode or selectedMessage))
if type(selected) == "table" then
T.same(selected:context(), {
engineVersion = Version.engine,
gameVersion = "red",
playthroughId = originalId,
}, "selected binding reports the durable playthrough without exposing a slot path")
T.same(selected:read("history/index"), { format = 1, newest = "q0001" },
"title reads only this mod's selected-playthrough durable history")
T.check(selected:write("history/title-operation", { allowed = true }) == true,
"title binding supports safe same-namespace durable operations")
T.same(selected:read("history/title-operation"), { allowed = true },
"title durable operation remains scoped to the selected playthrough")
T.check(type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function",
"selected storage exposes opaque byte methods")
if type(selected.writeBytes) == "function"
and type(selected.readBytes) == "function" then
local titleBytes = "TITLE\0\255-cache"
T.check(selected:writeBytes("history/title-bytes", titleBytes) == true,
"title binding writes opaque bytes in the selected namespace")
T.eq(selected:readBytes("history/title-bytes"), titleBytes,
"title binding reads opaque bytes in the selected namespace")
end
end
T.check(title.save.meta.playthroughId == nil,
"opening title history never allocates or adopts a playthrough identity")
end
local function makeRuntime(save, title)
local stack = setmetatable({ states = {} }, { __index = StateStack })
local game
local overworld = {
map = { id = "PALLET_TOWN" },
player = { cellX = 3, cellY = 6, facing = "down", surfing = false },
scriptMoves = {}, pendingScripts = {}, parallelRunners = {}, parallelQueue = {},
runner = { isRunning = function() return false end },
}
function overworld:captureSave(target)
target.player.map, target.player.x, target.player.y = self.map.id,
self.player.cellX, self.player.cellY
target.player.facing, target.player.surfing = self.player.facing,
self.player.surfing and true or false
end
function overworld:enter(mapId, x, y, facing)
self.map = { id = mapId }
self.player = { cellX = x, cellY = y, facing = facing,
surfing = game.save.player.surfing and true or false }
self.scriptMoves, self.pendingScripts = {}, {}
self.parallelRunners, self.parallelQueue = {}, {}
self.runner = { isRunning = function() return false end }
end
game = setmetatable({
save = save, stack = stack, overworld = overworld,
data = {
pokemon = {}, moves = { TACKLE = { pp = 35 } }, items = { POTION = {} },
constants = { fallbackMove = "TACKLE" },
field = { boot = { startMap = "PALLET_TOWN", startX = 3, startY = 6 } },
maps = { PALLET_TOWN = { id = "PALLET_TOWN", width = 10, height = 9 } },
},
}, { __index = GameMethods })
stack.states[1] = title and { screenId = "TitleState" } or overworld
if title then
-- The failure-injection path needs the same title recovery contract as a
-- real Game without constructing renderer-owned title content.
function game:makeTitleState() return { screenId = "TitleState" } end
end
return game
end
local runtime = makeRuntime(active.save, false)
local checkpoints = probe.checkpoints
T.check(type(checkpoints) == "table", "loader exposes the public checkpoint facade")
local checkpoint = checkpoints and checkpoints:capture(runtime)
T.check(type(checkpoint) == "table",
"a fresh playthrough can capture a stable overworld checkpoint")
T.check(type(checkpoints and checkpoints.ensureNormalSave) == "function",
"public checkpoints expose an idempotent first-save anchor")
local normalWrites = 0
local writeSave = runtime.writeSave
function runtime:writeSave()
normalWrites = normalWrites + 1
return writeSave(self)
end
local anchored, anchorCode, anchorMessage =
checkpoints:ensureNormalSave(runtime, checkpoint)
T.check(anchored == true,
"first persisted checkpoint can anchor normal progress: "
.. tostring(anchorCode or anchorMessage))
T.eq(normalWrites, 1,
"first checkpoint creates exactly one normal Pokemon save")
local anchoredAgain, againCode = checkpoints:ensureNormalSave(runtime, checkpoint)
T.check(anchoredAgain == true and againCode == "already_exists",
"later checkpoints leave the established normal save independent")
T.eq(normalWrites, 1,
"idempotent anchor never rewrites the established normal save")
local normalBytes = files["save.lua"]
T.check(type(normalBytes) == "string" and normalBytes ~= "",
"first checkpoint anchor is durably represented before restart")
local anchoredAt = SaveSerializer.decode(normalBytes).meta.savedAt
-- Model a durable checkpoint captured by the previous shipped engine.
-- RFC 0004 treats engineVersion as compatibility metadata, not runtime state.
checkpoint.identity.engineVersion = "0.1.79"
SaveData.resetSlotState()
local titleRuntime = makeRuntime(SaveData.newGame({ version = "red" }), true)
titleRuntime.save.options = { volume = 9, bindings = {} }
T.check(type(checkpoints and checkpoints.resume) == "function",
"public checkpoints expose validated title-session resume")
if type(checkpoints and checkpoints.resume) == "function" and checkpoint then
local resumed, resumeCode, resumeMessage = checkpoints:resume(titleRuntime, checkpoint)
T.check(resumed == true,
"title resumes the durable checkpoint: " .. tostring(resumeCode or resumeMessage))
T.eq(titleRuntime.save.meta.playthroughId, originalId,
"title bootstrap retains the checkpoint's original playthrough identity")
T.eq(titleRuntime.save.options.volume, 9,
"title bootstrap preserves current options rather than rewinding them")
T.eq(SaveData.selectedNormalSaveInfo({
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"title bootstrap never rewrites the first normal save")
local recaptured = checkpoints:capture(titleRuntime)
T.eq(recaptured and recaptured.identity
and recaptured.identity.engineVersion, Version.engine,
"cross-version resume recaptures the running engine version")
if recaptured and recaptured.identity then
recaptured.identity.engineVersion = checkpoint.identity.engineVersion
end
T.same(recaptured, checkpoint,
"bootstrapped overworld differentially recaptures the selected checkpoint")
T.eq(probe.restoreCount, 1,
"a successfully verified title resume emits checkpoint.restored exactly once")
T.eq(probe.restoreKind, "overworld",
"title resume lifecycle reports the reconstructed checkpoint kind")
-- Force a failure after restoreCheckpointSave has already installed the
-- checkpoint's canonical save and overworld. Title has no live checkpoint
-- rollback, so it must rebuild a clean title session.
SaveData.resetSlotState()
local failingTitle = makeRuntime(SaveData.newGame({ version = "red" }), true)
failingTitle.save.options = { volume = 7, bindings = {} }
local restoreCheckpointSave = failingTitle.restoreCheckpointSave
function failingTitle:restoreCheckpointSave(loaded)
restoreCheckpointSave(self, loaded)
error("forced title reconstruction failure")
end
local failed, failureCode = checkpoints:resume(failingTitle, checkpoint)
T.check(failed == false and failureCode == "resume_failed",
"failed title reconstruction reports a recoverable bootstrap failure")
T.eq(failingTitle.stack:top().screenId, "TitleState",
"failed title reconstruction returns to a usable title session")
T.check(failingTitle.save.meta.playthroughId == nil,
"failed title reconstruction restores the unbound title skeleton")
T.eq(failingTitle.save.options.volume, 7,
"failed title reconstruction retains current title options")
T.eq(SaveData.selectedNormalSaveInfo({
version = "red", meta = { playthroughId = originalId },
}, fs).savedAt, anchoredAt,
"failed title reconstruction never rewrites the normal Pokémon save")
T.eq(probe.restoreCount, 1,
"failed title reconstruction emits no additional restored lifecycle event")
end
-- A title policy may compare its own durable checkpoint chronology with the
-- ordinary CONTINUE target, but it must never receive that save's contents,
-- slot path, or a way to open another playthrough. This fixture writes the
-- canonical normal save directly to model an already-completed vanilla SAVE.
active.save.meta.savedAt = 4321
T.check(SaveData.save(active.save) == true,
"fixture updates the selected normal save chronology")
SaveData.resetSlotState()
local titleWithNormalSave = {
save = SaveData.newGame({ version = "red" }),
stack = { states = { { screenId = "TitleState" } } },
}
local selectedWithNormal, normalCode, normalMessage = storage:selected(titleWithNormalSave)
T.check(type(selectedWithNormal) == "table",
"legacy-to-slot migration keeps the selected playthrough identity: "
.. tostring(normalCode or normalMessage))
if type(selectedWithNormal) == "table" then
T.eq(selectedWithNormal:context().normalSavedAt, 4321,
"title selected context exposes only matching normal-save chronology")
end
T.check(titleWithNormalSave.save.meta.playthroughId == nil,
"normal-save chronology lookup does not bind the fresh title skeleton")
local explicitNewGame = SaveData.newGame({ version = "red" })
local freshContext = storage:context({ save = explicitNewGame })
T.check(freshContext and freshContext.playthroughId ~= originalId,
"an explicit New Game receives a distinct identity and cannot inherit old history")
end
Runtime.events, Runtime.hooks = savedEvents, savedHooks
Runtime.currentMod = nil
SaveData.resetSlotState()
SaveData.loadOptions = originalLoadOptions
love.filesystem = realFs
T.finish()