mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
feat: add opaque playthrough identity
This commit is contained in:
@@ -1079,6 +1079,7 @@ function Game:restoreSave(loaded, recovered)
|
||||
if ModRuntime.wants("save.loading") then
|
||||
ModRuntime.emit("save.loading", { raw = loaded })
|
||||
end
|
||||
SaveData.ensurePlaythroughId(loaded)
|
||||
-- mod chains replay before validation so a mod repairs its own data
|
||||
-- instead of watching it get quarantined; core steps already ran in
|
||||
-- SaveData.load and skip on the format guard
|
||||
|
||||
+82
-2
@@ -817,6 +817,74 @@ function SaveData.resetSlotState()
|
||||
for k in pairs(slotsChecked) do slotsChecked[k] = nil end
|
||||
end
|
||||
|
||||
-- ------- opaque playthrough identity
|
||||
|
||||
-- An id must never perturb the engine's gameplay RNG: savestate tools need
|
||||
-- repeatable random outcomes, and allocating persistence scope is not gameplay.
|
||||
-- Combine wall/process time, a process-local sequence and a fresh table address
|
||||
-- into four hex words. This is an opaque collision-resistant identifier, not a
|
||||
-- secret or a player-visible value.
|
||||
local playthroughSeq = 0
|
||||
|
||||
local function word(n)
|
||||
return math.floor(tonumber(n) or 0) % 4294967296
|
||||
end
|
||||
|
||||
function SaveData.newPlaythroughId()
|
||||
playthroughSeq = playthroughSeq + 1
|
||||
local address = tostring({}):match("0x(%x+)") or "0"
|
||||
local addressLo = tonumber(address:sub(-8), 16) or 0
|
||||
local clock = math.floor((os.clock() or 0) * 1000000)
|
||||
return ("%08x%08x%08x%08x"):format(
|
||||
word(os.time()), word(clock), word(addressLo), word(playthroughSeq))
|
||||
end
|
||||
|
||||
local function playthroughScope(version)
|
||||
version = version or GameVersion.get()
|
||||
local fs = persistFs(nil)
|
||||
ensureVersionSlots(version, fs)
|
||||
return activeSlotCache[version] or "legacy"
|
||||
end
|
||||
|
||||
local function rememberPlaythroughId(save, opts)
|
||||
local meta = type(save) == "table" and save.meta
|
||||
local id = type(meta) == "table" and meta.playthroughId
|
||||
if type(id) ~= "string" or id == "" then return opts, false end
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version)
|
||||
opts = opts or SaveData.loadOptions()
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
local changed = opts.playthroughIds[version][scope] ~= id
|
||||
opts.playthroughIds[version][scope] = id
|
||||
return opts, changed
|
||||
end
|
||||
|
||||
-- Return an existing save identity or give a pre-identity save a stable one.
|
||||
-- Legacy backfill lives in options.lua until the next normal SAVE stamps the id
|
||||
-- into progress, so installing a tool mod never rewrites the player's checkpoint.
|
||||
function SaveData.ensurePlaythroughId(save)
|
||||
if type(save) ~= "table" then return nil end
|
||||
save.meta = type(save.meta) == "table" and save.meta or {}
|
||||
local id = save.meta.playthroughId
|
||||
if type(id) == "string" and id ~= "" then return id end
|
||||
|
||||
local version = save.version or GameVersion.get()
|
||||
local scope = playthroughScope(version)
|
||||
local opts = SaveData.loadOptions()
|
||||
local byVersion = opts.playthroughIds and opts.playthroughIds[version]
|
||||
id = byVersion and byVersion[scope]
|
||||
if type(id) ~= "string" or id == "" then
|
||||
id = SaveData.newPlaythroughId()
|
||||
opts.playthroughIds = opts.playthroughIds or {}
|
||||
opts.playthroughIds[version] = opts.playthroughIds[version] or {}
|
||||
opts.playthroughIds[version][scope] = id
|
||||
SaveData.saveOptions(opts)
|
||||
end
|
||||
save.meta.playthroughId = id
|
||||
return id
|
||||
end
|
||||
|
||||
-- ------- meta
|
||||
|
||||
-- the version/engine/mod-set stamp every v2 save carries; mods is the
|
||||
@@ -838,6 +906,7 @@ function SaveData.buildMeta(mods, previous)
|
||||
format = Version.saveFormat,
|
||||
engine = Version.engine,
|
||||
savedAt = os.time(),
|
||||
playthroughId = type(previous) == "table" and previous.playthroughId or nil,
|
||||
mods = list,
|
||||
}
|
||||
end
|
||||
@@ -1047,8 +1116,14 @@ function SaveData.save(data, mods)
|
||||
-- write to the file matching this save's own version, not just the active
|
||||
-- one, so Blue/Yellow playthroughs land in save_blue.lua / save_yellow.lua
|
||||
local FILENAME, BACKUP_FILENAME, TMP_FILENAME = saveNames(data.version)
|
||||
SaveData.ensurePlaythroughId(data)
|
||||
if data.options then
|
||||
SaveData.saveOptions(data.options)
|
||||
local opts = rememberPlaythroughId(data, data.options)
|
||||
data.options = opts
|
||||
SaveData.saveOptions(opts)
|
||||
else
|
||||
local opts, changed = rememberPlaythroughId(data)
|
||||
if changed then SaveData.saveOptions(opts) end
|
||||
end
|
||||
if mods ~= nil or data.meta == nil then
|
||||
data.meta = SaveData.buildMeta(mods, data.meta)
|
||||
@@ -1117,6 +1192,7 @@ function SaveData.load(version)
|
||||
return nil
|
||||
end
|
||||
SaveData.runMigrations(data)
|
||||
SaveData.ensurePlaythroughId(data)
|
||||
data.options = SaveData.loadOptions()
|
||||
Logger.info("loaded save")
|
||||
return data, recovered
|
||||
@@ -1427,7 +1503,11 @@ function SaveData.newGame(boot)
|
||||
local x, y = boot.startX or 3, boot.startY or 6
|
||||
local heal = SaveData.defaultHeal(boot)
|
||||
local save = {
|
||||
meta = { format = Version.saveFormat, mods = {} },
|
||||
meta = {
|
||||
format = Version.saveFormat,
|
||||
mods = {},
|
||||
playthroughId = SaveData.newPlaythroughId(),
|
||||
},
|
||||
-- which game this playthrough is (Red vs Blue). Only Red ships today;
|
||||
-- boot carries the choice once Blue support lands.
|
||||
version = boot.version or "red",
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
-- Opaque playthrough identity: New Game uniqueness, save/load persistence,
|
||||
-- stable legacy backfill, and version/slot isolation. No real save directory.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local SaveSerializer = require("src.core.SaveSerializer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local realFS = love.filesystem
|
||||
|
||||
local function memfs(files)
|
||||
return {
|
||||
write = function(path, content) files[path] = content return true end,
|
||||
read = function(path) return files[path] 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,
|
||||
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 function fresh()
|
||||
local files = {}
|
||||
love.filesystem = memfs(files)
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
return files
|
||||
end
|
||||
|
||||
local function legacy(version, name)
|
||||
return {
|
||||
version = version,
|
||||
meta = { format = 4, mods = {} },
|
||||
player = { name = name, map = "PALLET_TOWN", x = 5, y = 6 },
|
||||
flags = {}, inventory = {}, pcItems = {}, party = {}, box = {}, boxes = {},
|
||||
money = 3000, defeatedTrainers = {}, pokedex = { seen = {}, owned = {} },
|
||||
}
|
||||
end
|
||||
|
||||
-- Removing playthroughId generation from New Game must fail these assertions.
|
||||
do
|
||||
fresh()
|
||||
local first = SaveData.newGame({ version = "red" })
|
||||
local second = SaveData.newGame({ version = "red" })
|
||||
T.check(type(first.meta.playthroughId) == "string"
|
||||
and first.meta.playthroughId ~= "",
|
||||
"New Game receives an opaque playthrough id")
|
||||
T.neq(second.meta.playthroughId, first.meta.playthroughId,
|
||||
"separate New Games receive separate playthrough ids")
|
||||
end
|
||||
|
||||
-- Dropping the id from buildMeta or save encoding must fail the roundtrip.
|
||||
do
|
||||
fresh()
|
||||
local save = SaveData.newGame({ version = "red" })
|
||||
local expected = save.meta.playthroughId
|
||||
T.check(SaveData.save(save), "identity fixture saves")
|
||||
local loaded = SaveData.load("red")
|
||||
T.eq(loaded and loaded.meta.playthroughId, expected,
|
||||
"normal save/load preserves the playthrough id")
|
||||
end
|
||||
|
||||
-- Legacy identity is persisted independently: the legacy progress bytes remain
|
||||
-- unchanged, yet two loads resolve the same id before a normal SAVE occurs.
|
||||
do
|
||||
local files = fresh()
|
||||
local raw = legacy("red", "LEGACY")
|
||||
files["save.lua"] = SaveSerializer.encode(raw)
|
||||
|
||||
local first = SaveData.load("red")
|
||||
local id = first and first.meta.playthroughId
|
||||
T.check(type(id) == "string" and id ~= "",
|
||||
"a legacy save receives a playthrough id")
|
||||
|
||||
local slotBytes = files["saves/red/slot1.lua"]
|
||||
local onDisk = slotBytes and SaveSerializer.decode(slotBytes)
|
||||
T.eq(onDisk and onDisk.meta.playthroughId, nil,
|
||||
"legacy backfill does not rewrite normal progress")
|
||||
|
||||
SaveData.resetSlotState()
|
||||
local second = SaveData.load("red")
|
||||
T.eq(second and second.meta.playthroughId, id,
|
||||
"legacy backfill is stable across reload before normal SAVE")
|
||||
end
|
||||
|
||||
-- Reusing names and coordinates cannot merge identities across slots or games.
|
||||
do
|
||||
fresh()
|
||||
local redA = SaveData.createSlot("red")
|
||||
local redB = SaveData.createSlot("red")
|
||||
SaveData.setActiveSlot("red", redA)
|
||||
T.check(SaveData.writeSlot("red", redA, legacy("red", "SAME")),
|
||||
"seed red slot A")
|
||||
local idA = SaveData.load("red").meta.playthroughId
|
||||
|
||||
SaveData.setActiveSlot("red", redB)
|
||||
T.check(SaveData.writeSlot("red", redB, legacy("red", "SAME")),
|
||||
"seed red slot B")
|
||||
local idB = SaveData.load("red").meta.playthroughId
|
||||
|
||||
GameVersion.set("blue")
|
||||
local blue = SaveData.createSlot("blue")
|
||||
SaveData.setActiveSlot("blue", blue)
|
||||
T.check(SaveData.writeSlot("blue", blue, legacy("blue", "SAME")),
|
||||
"seed blue slot")
|
||||
local idBlue = SaveData.load("blue").meta.playthroughId
|
||||
|
||||
T.neq(idA, idB, "two active slots do not share legacy identity")
|
||||
T.neq(idA, idBlue, "Red and Blue do not share legacy identity")
|
||||
T.neq(idB, idBlue, "every version/slot scope is isolated")
|
||||
end
|
||||
|
||||
love.filesystem = realFS
|
||||
SaveData.resetSlotState()
|
||||
GameVersion.set("red")
|
||||
|
||||
T.finish("playthrough_identity")
|
||||
Reference in New Issue
Block a user