-- Save-slot backend (src/core/SaveData.lua): legacy migration, the slot -- registry in options.lua, listSlots/setActiveSlot/createSlot, and the -- active-slot resolution behind saveNames/save/load. Self-contained: it -- installs the love stub only for a swappable in-memory filesystem, the -- same way tests/mod_save_tests isolates its save round-trips. package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.harness") love = love or require("tests.love_stub") local SaveSerializer = require("src.core.SaveSerializer") local SaveData = require("src.core.SaveData") local GameVersion = require("src.core.GameVersion") local realFS = love.filesystem -- an isolated love.filesystem: keys are full paths, so "saves/red/slot1.lua" -- needs no directory support (createDirectory is deliberately absent, which -- is exactly what the ensureParentDir no-op path handles) local function memfs(files) return { files = files, 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, getInfo = function(path) if files[path] then return { type = "file" } end return nil end, } end -- a fresh filesystem + cleared process globals: each scenario is a first boot local function fresh() local files = {} love.filesystem = memfs(files) SaveData.resetSlotState() GameVersion.set("red") return files end -- a minimal but fully decodable Red save local function legacySave(name, dexOwned, badges, playTime) local owned = {} for _, id in ipairs(dexOwned or {}) do owned[id] = true end local inv = {} for _, id in ipairs(badges or {}) do inv[id] = true end return { version = "red", player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 }, pokedex = { seen = {}, owned = owned }, inventory = inv, playTime = playTime or 0, } end -- ---------------------------------------------- slotSummary (pure) do local name, meta = SaveData.slotSummary( legacySave("ASH", { "PIKACHU", "PIDGEY", "RATTATA" }, { "BOULDERBADGE", "CASCADEBADGE" }, 3661)) T.eq(name, "ASH", "slotSummary reads the player name") T.eq(meta.dexCount, 3, "slotSummary counts owned dex entries") T.eq(meta.badges, 2, "slotSummary counts vanilla badges from inventory") T.eq(meta.timeText, "1:01", "slotSummary formats playTime as H:MM") local n2, m2 = SaveData.slotSummary(nil) T.eq(n2, nil, "slotSummary of an empty slot has no name") T.eq(m2, nil, "slotSummary of an empty slot has no meta") -- A Gen 2 (Gold) save stores playTime as a { hours, minutes, seconds, -- frames } table, not a seconds count. The launcher lists EVERY version's -- slots, so a math.floor on that table crashed the whole launcher the moment -- a Gold save existed -- and dropped its CONTINUE row. slotSummary reads -- both shapes now. local gName, gMeta = SaveData.slotSummary({ player = { name = "GOLD" }, playTime = { hours = 3, minutes = 35, seconds = 40, frames = 45 }, pokedex = { owned = { CYNDAQUIL = true, PIDGEY = true } }, }) T.eq(gName, "GOLD", "slotSummary reads a Gen 2 save's name") T.eq(gMeta.dexCount, 2, "and its dex count") T.eq(gMeta.timeText, "3:35", "and formats the Gen 2 { hours, minutes, seconds } playTime without crashing") end -- ---------------------------------------------- legacy migration happy path do local files = fresh() files["save.lua"] = SaveSerializer.encode( legacySave("RED", { "BULBASAUR", "CHARMANDER" }, { "BOULDERBADGE" }, 7325)) local slots = SaveData.listSlots("red") T.eq(#slots, 1, "legacy save migrates into exactly one slot") T.eq(slots[1].id, "slot1", "the migrated slot is slot1") T.eq(slots[1].exists, true, "the migrated slot reports a save present") T.eq(slots[1].name, "RED", "the migrated slot surfaces the player name") T.eq(slots[1].meta.badges, 1, "migrated slot meta carries the badge count") T.eq(slots[1].meta.dexCount, 2, "migrated slot meta carries the dex count") T.eq(slots[1].meta.timeText, "2:02", "migrated slot meta carries the time") T.eq(files["save.lua"], nil, "the flat legacy file is removed after migration") T.check(files["saves/red/slot1.lua"] ~= nil, "the slot file now holds the save") local opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.active, "slot1", "options registers slot1 as active") T.eq(opts.saveSlots.red.list[1], "slot1", "options lists the migrated slot") -- load() now resolves the active slot and reads the migrated save local loaded = SaveData.load("red") T.check(loaded and loaded.player.name == "RED", "load reads the active slot") end -- ---------------------------------------------- migration idempotence do local files = fresh() files["save.lua"] = SaveSerializer.encode(legacySave("ONCE", { "MEW" }, {}, 0)) SaveData.listSlots("red") -- first boot: migrates local slotBytes = files["saves/red/slot1.lua"] -- a second boot: registry exists, no flat file, so nothing re-migrates SaveData.resetSlotState() local slots = SaveData.listSlots("red") T.eq(#slots, 1, "a re-boot does not duplicate the migrated slot") T.eq(files["save.lua"], nil, "no flat file reappears on re-boot") T.eq(files["saves/red/slot1.lua"], slotBytes, "the slot bytes are untouched") local opts = SaveSerializer.decode(files["options.lua"]) T.eq(#opts.saveSlots.red.list, 1, "the registry still lists exactly one slot") end -- ---------------------------------------------- mixed real / empty slots do local files = fresh() files["save.lua"] = SaveSerializer.encode( legacySave("REAL", { "EEVEE" }, { "BOULDERBADGE" }, 60)) SaveData.listSlots("red") -- slot1 = the migrated real save local empty = SaveData.createSlot("red") T.eq(empty, "slot2", "createSlot allocates slot2 alongside the migrated slot1") local slots = SaveData.listSlots("red") T.eq(#slots, 2, "both the real and empty slots are listed") T.eq(slots[1].exists, true, "the migrated slot still reports a save") T.eq(slots[1].name, "REAL", "the real slot keeps its name") T.eq(slots[2].exists, false, "the freshly created slot is empty") T.eq(slots[2].name, nil, "an empty slot has no name") T.eq(slots[2].meta, nil, "an empty slot has no meta") end -- ---------------------------------------------- setActiveSlot persistence do local files = fresh() SaveData.createSlot("red") -- slot1 SaveData.createSlot("red") -- slot2 SaveData.setActiveSlot("red", "slot2") local opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.active, "slot2", "setActiveSlot persists the active id") T.eq(opts.saveSlots.red.list[1], "slot1", "the slot list is preserved") T.eq(opts.saveSlots.red.list[2], "slot2", "the target slot stays in the list") -- selecting a slot that was never registered adds it SaveData.setActiveSlot("red", "slot7") opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.active, "slot7", "an unregistered active slot is added") T.eq(opts.saveSlots.red.list[3], "slot7", "the added slot lands in the list") end -- ---------------------------------------------- createSlot id allocation do fresh() T.eq(SaveData.createSlot("red"), "slot1", "first slot is slot1") T.eq(SaveData.createSlot("red"), "slot2", "second slot is slot2") T.eq(SaveData.createSlot("red"), "slot3", "ids increment past the highest") end -- ---------------------------------------------- deleteSlot do local files = fresh() local a = SaveData.createSlot("red") local b = SaveData.createSlot("red") SaveData.setActiveSlot("red", b) local save = SaveData.newGame() save.player.name = "KEEP" T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save") save.player.name = "GONE" T.check(SaveData.writeSlot("red", b, save), "seed slot2 with a save") local ok, err = SaveData.deleteSlot("red", b) T.check(ok, "deleteSlot removes the active slot: " .. tostring(err)) T.eq(files["saves/red/slot2.lua"], nil, "slot2's file is gone") T.check(files["saves/red/slot1.lua"] ~= nil, "the other slot's file stays") local opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.active, a, "active falls back to the remaining slot") T.eq(#opts.saveSlots.red.list, 1, "the deleted id is dropped from the list") T.eq(opts.saveSlots.red.list[1], a, "only slot1 remains registered") ok = SaveData.deleteSlot("red", a) T.check(ok, "deleting the last slot succeeds") opts = SaveSerializer.decode(files["options.lua"]) T.eq(#opts.saveSlots.red.list, 0, "the registry list is empty") T.eq(opts.saveSlots.red.active, nil, "active clears when no slots remain") T.eq(#SaveData.listSlots("red"), 0, "listSlots reports an empty install") local bad, badErr = SaveData.deleteSlot("red", "slot99") T.check(not bad, "deleting an unknown slot fails") T.check(tostring(badErr):find("not registered", 1, true) ~= nil, "unknown-slot error is user-presentable") end -- ---------------------------------------------- saveNames follows the slot do local files = fresh() SaveData.createSlot("red") -- slot1 SaveData.createSlot("red") -- slot2 SaveData.setActiveSlot("red", "slot2") local save = SaveData.newGame() save.player.name = "SLOT2" T.check(SaveData.save(save), "save writes to the active slot") T.check(files["saves/red/slot2.lua"] ~= nil, "bytes land in slot2's file") T.eq(files["saves/red/slot1.lua"], nil, "slot1 is untouched by a slot2 save") T.eq(files["save.lua"], nil, "no flat file is written once a slot is active") local loaded = SaveData.load("red") T.check(loaded and loaded.player.name == "SLOT2", "load reads back from slot2") end -- ---------------------------------------------- renameSlot (#205) do local files = fresh() local a = SaveData.createSlot("red") local b = SaveData.createSlot("red") local save = SaveData.newGame() save.player.name = "ASH" T.check(SaveData.writeSlot("red", a, save), "seed slot1 with a save") T.check(SaveData.renameSlot("red", a, "Nuzlocke"), "renameSlot labels a registered slot") local opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.names[a], "Nuzlocke", "the label persists in the options registry") local slots = SaveData.listSlots("red") T.eq(slots[1].label, "Nuzlocke", "listSlots carries the custom label") T.eq(slots[1].name, "ASH", "the player name still comes through separately") T.eq(slots[2].label, nil, "an unlabeled slot has no label") T.check(SaveData.renameSlot("red", b, " "), "whitespace-only clears") T.check(SaveData.renameSlot("red", a, ""), "an empty name clears the label") opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.names and opts.saveSlots.red.names[a], nil, "cleared labels leave the registry") T.eq(SaveData.listSlots("red")[1].label, nil, "the row is unlabeled again") -- trimming + delete cleanup T.check(SaveData.renameSlot("red", a, " Victory run "), "renameSlot trims the label") T.eq(SaveData.listSlots("red")[1].label, "Victory run", "the stored label is trimmed") T.check(SaveData.deleteSlot("red", a), "delete the labeled slot") opts = SaveSerializer.decode(files["options.lua"]) T.eq(opts.saveSlots.red.names[a], nil, "deleteSlot drops the label too") local bad, badErr = SaveData.renameSlot("red", "slot99", "x") T.check(not bad, "renaming an unknown slot fails") T.check(tostring(badErr):find("not registered", 1, true) ~= nil, "unknown-slot rename error is user-presentable") end -- ---------------------------------------------- a version with no slots do local files = fresh() local slots = SaveData.listSlots("red") T.eq(#slots, 0, "a fresh install with no legacy save lists no slots") -- with nothing registered, save/load use the flat legacy path, exactly -- as they did before slots existed local save = SaveData.newGame() save.player.name = "FLAT" T.check(SaveData.save(save), "a slotless version saves to the flat file") T.check(files["save.lua"] ~= nil, "the flat save.lua is written") T.eq(files["saves/red/slot1.lua"], nil, "no slot directory is created") local loaded = SaveData.load("red") T.check(loaded and loaded.player.name == "FLAT", "load reads the flat file") T.eq(SaveData.saveFilename("red"), "save.lua", "saveFilename still resolves the flat name with no slot in use") end love.filesystem = realFS T.finish("save_slots")