From 726ed1102e0aeced5534674b59bb6dd6d69fc773 Mon Sep 17 00:00:00 2001 From: MaxTomahawk Date: Fri, 7 Aug 2026 14:43:04 +0200 Subject: [PATCH] feat: add opaque playthrough identity --- src/core/Game.lua | 1 + src/core/SaveData.lua | 84 ++++++++++++++- tests/engine/playthrough_identity.lua | 141 ++++++++++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 tests/engine/playthrough_identity.lua diff --git a/src/core/Game.lua b/src/core/Game.lua index 06856b91..b8b452d0 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 882897bb..c12115fd 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -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", diff --git a/tests/engine/playthrough_identity.lua b/tests/engine/playthrough_identity.lua new file mode 100644 index 00000000..291433a1 --- /dev/null +++ b/tests/engine/playthrough_identity.lua @@ -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")