mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-25 15:01:11 +02:00
ebf44f20d3
A custom cart pairs an identity (title, shell colour, label art) with a base game, a list of mods pinned to exact builds with their option values frozen, a load order, and a seal. It ships no code of its own: every mod it names is a separately published mod, which is what keeps a cart auditable before it runs and reproducible after an author's repo disappears. Format and storage: - src/carts/CartManifest.lua parses and validates cart.json, canonicalises it for hashing and reads/writes the .g1rcart bundle. The bundle is a data-only serialised table read through SaveSerializer, so an imported cart can never execute code. Canonical strings are length-prefixed because option keys and values are author-controlled and could otherwise forge a record boundary and collide two different carts onto one hash. - Pins name a public source: a GitHub release with its sha256, a GameBanana file id with its md5, or "local" for a capture that only exists on this install. A local pin is unpublishable by construction, which is what makes "build it here, publish later" possible without inventing a hash. - Label art rides alongside the manifest rather than inside its identity, so re-arting a cart does not tell every player their run is out of date. src/core/Base64.lua decodes it; strict, with no whitespace tolerance. Saves: - Cart playthroughs live in the cart's own slot namespace (saves/cart_<id>/), so a cart's file never sits beside a vanilla one and uninstalling a cart never orphans a save. Every save records the cart build it was made under. The seal: - A sealed cart loads its pinned list, in its order, with its options, and nothing else. A pinned mod with no frozen options gets an empty bucket so unfrozen keys fall to schema defaults, identical for everyone; otherwise two players on one cart quietly run different games. - A sealed cart refuses to load when a pin is missing or installed at another version. Playing a subset of the cart is the exact dishonesty the seal exists to prevent, so the refusal loads nothing at all. - Breaking the seal is permanent, marked per save slot, and downgrades that playthrough to open behaviour. It cannot be cleared through any public API. Launcher: - A game's page carries a Custom Carts control and a picker; choosing a cart turns the page into that cart's page, with its own cartridge, title and save slots. The rail of five games never grows and a cart id never reaches imp.tab or imp.panelVersion. - Loader.planCart runs before boot so a refusal is visible on the page instead of being discovered as an error after launch. - Save as cart captures the enabled mods for a game and names, before the player confirms, every mod that could only be pinned to this install and whether the result can be shared at all. Authoring: - tools/cartkit.py scaffolds, validates, pins and packs a cart, and installs a release workflow. Its writer is byte-identical to the engine's serialiser.
270 lines
12 KiB
Lua
270 lines
12 KiB
Lua
|
|
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
|
|
|
|
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
|
|
|
|
local function fresh()
|
|
local files = {}
|
|
love.filesystem = memfs(files)
|
|
SaveData.resetSlotState()
|
|
GameVersion.set("red")
|
|
return files
|
|
end
|
|
|
|
local function options(files)
|
|
return SaveSerializer.decode(files["options.lua"] or "") or {}
|
|
end
|
|
|
|
local function plainSave(name, hash)
|
|
return {
|
|
version = "red",
|
|
meta = hash and { cartHash = hash } or nil,
|
|
player = { name = name, map = "PALLET_TOWN", x = 1, y = 1 },
|
|
pokedex = { seen = {}, owned = {} },
|
|
inventory = {},
|
|
playTime = 0,
|
|
}
|
|
end
|
|
|
|
do
|
|
fresh()
|
|
T.eq(SaveData.getCart(), nil, "no cart is active by default")
|
|
T.eq(SaveData.setCart("nuzlocke", "abc123"), "nuzlocke", "setCart returns the id")
|
|
T.eq(SaveData.getCart(), "nuzlocke", "getCart reads the active cart back")
|
|
T.eq(SaveData.getCartHash(), "abc123", "the build hash rides along")
|
|
T.eq(SaveData.setCart(nil), nil, "setCart(nil) returns to vanilla play")
|
|
T.eq(SaveData.getCart(), nil, "and getCart says so")
|
|
T.eq(SaveData.getCartHash(), nil, "clearing the cart clears its build hash")
|
|
|
|
T.eq(SaveData.setCart("../evil"), nil, "a path-climbing cart id is refused")
|
|
T.eq(SaveData.setCart(".."), nil, "so is a bare parent reference")
|
|
T.eq(SaveData.setCart("has spaces"), nil, "so is a non-word id")
|
|
T.eq(SaveData.setCart(42), nil, "so is a non-string id")
|
|
T.eq(SaveData.getCart(), nil, "none of them become the active cart")
|
|
end
|
|
|
|
do
|
|
local files = fresh()
|
|
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "a cart starts with no slots")
|
|
T.eq(SaveData.activeCartSlot("nuzlocke"), nil, "and no active slot")
|
|
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), nil, "and no stamped hash")
|
|
T.eq(files["options.lua"], nil, "listing an empty cart writes no options file")
|
|
|
|
T.eq(#SaveData.listCartSlots("../evil"), 0, "an unusable cart id lists nothing")
|
|
T.eq(SaveData.createCartSlot("../evil"), nil, "and can allocate no slot")
|
|
local ok, err = SaveData.deleteCartSlot("../evil", "slot1")
|
|
T.check(not ok, "and deleting from it fails")
|
|
T.check(tostring(err):find("unknown cart", 1, true) ~= nil,
|
|
"with a user-presentable reason")
|
|
end
|
|
|
|
do
|
|
local files = fresh()
|
|
T.eq(SaveData.createCartSlot("nuzlocke"), "slot1", "first cart slot is slot1")
|
|
T.eq(SaveData.createCartSlot("nuzlocke"), "slot2", "ids increment as they do per version")
|
|
T.eq(SaveData.setActiveCartSlot("nuzlocke", "slot2"), "slot2",
|
|
"setActiveCartSlot returns the chosen id")
|
|
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot2", "and the choice is live")
|
|
|
|
local opts = options(files)
|
|
T.eq(opts.cartSlots.nuzlocke.active, "slot2", "the active id persists in options.lua")
|
|
T.eq(opts.cartSlots.nuzlocke.list[1], "slot1", "the slot list persists with it")
|
|
T.eq(opts.cartSlots.nuzlocke.list[2], "slot2", "in allocation order")
|
|
T.eq(opts.saveSlots, nil, "no per-version registry is created by cart work")
|
|
|
|
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", " Hardcore "),
|
|
"renameCartSlot labels a registered slot")
|
|
T.eq(options(files).cartSlots.nuzlocke.names.slot1, "Hardcore",
|
|
"the label is trimmed and persisted")
|
|
T.eq(SaveData.listCartSlots("nuzlocke")[1].label, "Hardcore",
|
|
"listCartSlots carries the label")
|
|
T.check(SaveData.renameCartSlot("nuzlocke", "slot1", ""), "an empty name clears it")
|
|
T.eq(options(files).cartSlots.nuzlocke.names, nil,
|
|
"the names table leaves the registry once empty")
|
|
|
|
local bad, badErr = SaveData.renameCartSlot("nuzlocke", "slot99", "x")
|
|
T.check(not bad, "renaming an unregistered cart slot fails")
|
|
T.check(tostring(badErr):find("not registered", 1, true) ~= nil,
|
|
"unknown-slot rename error is user-presentable")
|
|
|
|
T.check(SaveData.writeCartSlot("nuzlocke", "slot2", plainSave("NUZ")),
|
|
"seed the active cart slot")
|
|
T.check(files["saves/cart_nuzlocke/slot2.lua"] ~= nil,
|
|
"the bytes land in the cart's own directory")
|
|
|
|
T.check(SaveData.deleteCartSlot("nuzlocke", "slot2"), "deleteCartSlot removes it")
|
|
T.eq(files["saves/cart_nuzlocke/slot2.lua"], nil, "the slot file is gone")
|
|
opts = options(files)
|
|
T.eq(#opts.cartSlots.nuzlocke.list, 1, "the id is dropped from the registry")
|
|
T.eq(opts.cartSlots.nuzlocke.active, "slot1",
|
|
"active falls back to the remaining slot")
|
|
T.eq(SaveData.activeCartSlot("nuzlocke"), "slot1", "and the live cache follows")
|
|
|
|
T.check(SaveData.deleteCartSlot("nuzlocke", "slot1"), "deleting the last slot works")
|
|
T.eq(#SaveData.listCartSlots("nuzlocke"), 0, "the cart is empty again")
|
|
T.eq(options(files).cartSlots.nuzlocke.active, nil, "active clears with the list")
|
|
end
|
|
|
|
do
|
|
local files = fresh()
|
|
T.eq(SaveData.createCartSlot("alpha"), "slot1", "alpha allocates its own slot1")
|
|
T.eq(SaveData.createCartSlot("beta"), "slot1", "beta allocates its own slot1")
|
|
T.check(SaveData.writeCartSlot("alpha", "slot1", plainSave("AAA", "aaa111")),
|
|
"seed alpha's slot")
|
|
T.check(SaveData.writeCartSlot("beta", "slot1", plainSave("BBB", "bbb222")),
|
|
"seed beta's slot")
|
|
|
|
T.eq(#SaveData.listCartSlots("alpha"), 1, "alpha lists only its own slot")
|
|
T.eq(#SaveData.listCartSlots("beta"), 1, "beta lists only its own slot")
|
|
T.eq(SaveData.listCartSlots("alpha")[1].name, "AAA", "alpha reads its own save")
|
|
T.eq(SaveData.listCartSlots("beta")[1].name, "BBB", "beta reads its own save")
|
|
T.check(files["saves/cart_alpha/slot1.lua"] ~= files["saves/cart_beta/slot1.lua"],
|
|
"two carts with the same slot id write different files")
|
|
T.eq(SaveData.slotCartHash("alpha", "slot1"), "aaa111", "alpha's build stamp")
|
|
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "beta's build stamp")
|
|
|
|
T.check(SaveData.deleteCartSlot("alpha", "slot1"), "delete alpha's only slot")
|
|
T.eq(#SaveData.listCartSlots("alpha"), 0, "alpha is empty")
|
|
T.eq(#SaveData.listCartSlots("beta"), 1, "beta is untouched")
|
|
T.check(files["saves/cart_beta/slot1.lua"] ~= nil, "beta's file survives")
|
|
T.eq(SaveData.slotCartHash("beta", "slot1"), "bbb222", "as does its stamp")
|
|
end
|
|
|
|
do
|
|
local files = fresh()
|
|
T.eq(SaveData.createSlot("red"), "slot1", "red allocates a version slot")
|
|
T.eq(SaveData.createCartSlot("red"), "slot1",
|
|
"a cart may even be named after a version")
|
|
T.check(SaveData.writeSlot("red", "slot1", plainSave("VANILLA")),
|
|
"seed the version slot")
|
|
T.check(SaveData.writeCartSlot("red", "slot1", plainSave("CARTRED")),
|
|
"seed the cart slot")
|
|
|
|
T.eq(#SaveData.listSlots("red"), 1, "the version lists only its own slot")
|
|
T.eq(SaveData.listSlots("red")[1].name, "VANILLA", "with the vanilla save in it")
|
|
T.eq(#SaveData.listCartSlots("red"), 1, "the cart lists only its own slot")
|
|
T.eq(SaveData.listCartSlots("red")[1].name, "CARTRED", "with the cart save in it")
|
|
T.check(files["saves/red/slot1.lua"] ~= nil, "the version path is saves/red/")
|
|
T.check(files["saves/cart_red/slot1.lua"] ~= nil, "the cart path is saves/cart_red/")
|
|
T.eq(SaveData.listSlots("red")[1].cartHash, nil,
|
|
"a version slot carries no cart hash")
|
|
|
|
T.check(SaveData.deleteCartSlot("red", "slot1"), "uninstall-style cart slot delete")
|
|
T.eq(#SaveData.listSlots("red"), 1, "the vanilla slot is still registered")
|
|
T.check(files["saves/red/slot1.lua"] ~= nil, "and its file is not orphaned")
|
|
T.eq(options(files).saveSlots.red.list[1], "slot1",
|
|
"the version registry is independent of the cart one")
|
|
end
|
|
|
|
do
|
|
local files = fresh()
|
|
SaveData.setCart("nuzlocke", "abc123")
|
|
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
|
|
"with no cart slot yet, the flat path follows the version suffix scheme")
|
|
|
|
SaveData.createCartSlot("nuzlocke")
|
|
SaveData.setActiveCartSlot("nuzlocke", "slot1")
|
|
T.eq(SaveData.saveFilename("red"), "saves/cart_nuzlocke/slot1.lua",
|
|
"the active cart slot owns the save path")
|
|
|
|
local save = SaveData.newGame()
|
|
save.player.name = "NUZ"
|
|
T.check(SaveData.save(save, {}), "an in-game save under a cart writes")
|
|
T.check(files["saves/cart_nuzlocke/slot1.lua"] ~= nil, "into the cart's slot")
|
|
T.eq(files["save.lua"], nil, "never into the base game's flat file")
|
|
T.eq(files["saves/red/slot1.lua"], nil, "never into the base game's slot")
|
|
T.eq(#SaveData.listSlots("red"), 0, "and the base game still has no slots")
|
|
|
|
local loaded = SaveData.load("red")
|
|
T.check(loaded and loaded.player.name == "NUZ", "load reads the cart's slot back")
|
|
T.eq(loaded.meta.cartHash, "abc123", "the save records the build it was made under")
|
|
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
|
|
"and the registry mirrors it for a listing with no save decode")
|
|
T.eq(SaveData.listCartSlots("nuzlocke")[1].cartHash, "abc123",
|
|
"listCartSlots surfaces the stamp")
|
|
end
|
|
|
|
do
|
|
fresh()
|
|
SaveData.setCart("nuzlocke", "abc123")
|
|
SaveData.createCartSlot("nuzlocke")
|
|
SaveData.setActiveCartSlot("nuzlocke", "slot1")
|
|
T.check(SaveData.save(SaveData.newGame(), {}), "first save under build abc123")
|
|
|
|
local loaded = SaveData.load("red")
|
|
T.check(SaveData.save(loaded, {}), "a re-save rebuilds meta")
|
|
T.eq(SaveData.load("red").meta.cartHash, "abc123",
|
|
"buildMeta carries the stamp instead of dropping it")
|
|
|
|
SaveData.setCartHash("def456")
|
|
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "abc123",
|
|
"installing an update does not restamp the slot")
|
|
loaded = SaveData.load("red")
|
|
T.eq(loaded.meta.cartHash, "abc123", "nor the in-progress save")
|
|
T.check(SaveData.save(loaded, {}), "save under the new build")
|
|
T.eq(SaveData.load("red").meta.cartHash, "def456", "now the save carries it")
|
|
T.eq(SaveData.slotCartHash("nuzlocke", "slot1"), "def456", "and so does the registry")
|
|
|
|
local ok, err = SaveData.setSlotCartHash("nuzlocke", "slot9", "zzz")
|
|
T.check(not ok, "an unregistered slot cannot be stamped")
|
|
T.check(tostring(err):find("not registered", 1, true) ~= nil,
|
|
"with a user-presentable reason")
|
|
end
|
|
|
|
do
|
|
local files = fresh()
|
|
SaveData.createSlot("red")
|
|
SaveData.setActiveSlot("red", "slot1")
|
|
local save = SaveData.newGame()
|
|
save.player.name = "VANILLA"
|
|
T.check(SaveData.save(save, {}), "a vanilla save with no cart set")
|
|
|
|
local bytes = files["saves/red/slot1.lua"]
|
|
T.check(bytes:find("cartHash", 1, true) == nil, "records no cartHash")
|
|
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
|
|
"and options.lua grows no cart registry")
|
|
T.eq(options(files).cartSlots, nil, "which decodes as absent, not empty")
|
|
|
|
local loaded = SaveData.load("red")
|
|
T.check(SaveData.save(loaded), "re-save the migrated shape once")
|
|
local before = files["saves/red/slot1.lua"]
|
|
SaveData.setCart("nuzlocke", "abc123")
|
|
T.eq(SaveData.saveFilename("red"), "save_cart_nuzlocke.lua",
|
|
"the cart takes over the path while it is active")
|
|
SaveData.setCart(nil)
|
|
T.eq(SaveData.saveFilename("red"), "saves/red/slot1.lua",
|
|
"and hands it straight back")
|
|
T.check(SaveData.save(loaded), "re-save after the cart round trip")
|
|
T.eq(files["saves/red/slot1.lua"], before, "the vanilla bytes are identical")
|
|
T.check(files["options.lua"]:find("cartSlots", 1, true) == nil,
|
|
"and options.lua still carries no cart registry")
|
|
|
|
local again = SaveData.load("red")
|
|
T.check(again and again.player.name == "VANILLA", "the vanilla save still loads")
|
|
T.eq(again.meta.cartHash, nil, "with no cart stamp on it")
|
|
end
|
|
|
|
love.filesystem = realFS
|
|
|
|
T.finish("cart_saves")
|