mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-07 17:31:35 -04:00
264 lines
14 KiB
Lua
264 lines
14 KiB
Lua
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = love or require("tests.love_stub")
|
|
local T = require("tests.harness")
|
|
local Serializer = require("src.core.SaveSerializer")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local CacheFs = require("src.import.CacheFs")
|
|
local Store = require("src.box.Store")
|
|
local Service = require("src.box.Service")
|
|
local Transaction = require("src.box.Transaction")
|
|
local Records = require("src.box.Records")
|
|
local Catalog = require("src.box.Catalog")
|
|
|
|
local tables = {}
|
|
local function put(version, path, value)
|
|
tables[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value)
|
|
end
|
|
for _, version in ipairs(GameVersion.ORDER) do
|
|
if GameVersion.generation(version) == 3 then
|
|
put(version, "data/generated/gba/pokemon/names.lua", { [25] = "PIKACHU" })
|
|
put(version, "data/generated/gba/pokemon/national.lua", { toNational = { [25] = 25 }, toSpecies = { [25] = 25 } })
|
|
put(version, "data/generated/gba/pokemon/move_names.lua", { [84] = "THUNDERSHOCK" })
|
|
put(version, "data/generated/gba/items/pack.lua", { items = { [139] = { name = "ORAN BERRY" } } })
|
|
else
|
|
put(version, "data/generated/pokemon.lua", { PIKACHU = { dex = 25, name = "PIKACHU", types = { "ELECTRIC" } } })
|
|
put(version, "data/generated/moves.lua", { THUNDERSHOCK = { name = "THUNDERSHOCK" } })
|
|
put(version, "data/generated/items.lua", {})
|
|
end
|
|
end
|
|
local readAt = CacheFs.readAt
|
|
CacheFs.readAt = function(path) return tables[path] end
|
|
Catalog.reset()
|
|
|
|
local function filesystem()
|
|
local files, writes, mode = {}, 0, {}
|
|
local fs = {
|
|
read = function(path) return files[path] end,
|
|
getInfo = function(path) return files[path] and { type = "file" } or nil end,
|
|
createDirectory = function() return true end,
|
|
remove = function(path)
|
|
if mode.remove == path then return nil, "remove fault" end
|
|
files[path] = nil; return true
|
|
end,
|
|
write = function(path, body)
|
|
writes = writes + 1
|
|
if writes == mode.at then
|
|
if mode.kind == "partial" then files[path] = body:sub(1, math.floor(#body / 2)) end
|
|
if mode.kind == "crash" then files[path] = body; error("simulated interruption") end
|
|
return nil, "write fault"
|
|
end
|
|
files[path] = body
|
|
return true
|
|
end,
|
|
}
|
|
return fs, files, mode
|
|
end
|
|
|
|
local function fixture(version)
|
|
local generation = GameVersion.generation(version)
|
|
local mon = { species = generation == 3 and 25 or "PIKACHU", nickname = "Sparky", level = 12,
|
|
hp = 31, maxHp = 31, otId = 42, otName = "Player", personality = 987654,
|
|
otSecretId = 92, moves = { generation == 3 and { move = 84, pp = 17 } or "THUNDERSHOCK" },
|
|
dvs = { attack = 7, defense = 3, speed = 9, special = 4 },
|
|
opaque = { raw = "\000\255\127", unknown = { [7] = "keep" } } }
|
|
if generation == 3 then mon.heldItem = 139 end
|
|
local save = { version = version, generation = generation, party = { Store.copy(mon) },
|
|
money = 19374, unknown = { keep = true }, rawImport = "original-template" }
|
|
if generation == 3 then
|
|
save.storage = { currentBox = 1, boxes = { { name = "First", wallpaper = 3, mons = { [4] = mon } } } }
|
|
else save.boxes = { { mon } }; save.currentBox = 1 end
|
|
return save, mon, { where = "box", box = 1, index = generation == 3 and 4 or 1 }
|
|
end
|
|
|
|
for _, version in ipairs(GameVersion.ORDER) do
|
|
local fs, files = filesystem()
|
|
local save, mon, ref = fixture(version)
|
|
local source = { version = version, slotId = "slot1", path = "saves/" .. version .. "/slot1.lua" }
|
|
local original = Serializer.encode(save)
|
|
files[source.path] = original
|
|
local service = assert(Service.open(fs))
|
|
T.eq(Store.count(service.state), 0, version .. " begins with empty Box")
|
|
T.check(service:deposit(source, { ref }, 1, original), version .. " deposits")
|
|
T.eq(Store.count(service.state), 1, version .. " stored exactly once")
|
|
T.same(Store.at(service.state, 1, 1).mon, mon, version .. " preserves the entire native record")
|
|
T.same(Serializer.decode(files[source.path]).unknown, save.unknown, version .. " retains unrelated save fields")
|
|
T.check(service:withdraw(source, { { box = 1, slot = 1 } }, 1), version .. " withdraws")
|
|
T.eq(Store.count(service.state), 0, version .. " leaves no duplicate in Box")
|
|
local after = Serializer.decode(files[source.path])
|
|
local returned = Records.list(after, GameVersion.generation(version), 1)
|
|
returned = returned[1]
|
|
T.same(returned, mon, version .. " returned record is unchanged")
|
|
T.eq(after.money, save.money, version .. " keeps money")
|
|
T.eq(after.rawImport, save.rawImport, version .. " keeps cartridge template")
|
|
T.eq(files[Transaction.PATH], nil, version .. " journal is cleared")
|
|
end
|
|
|
|
for _, kind in ipairs({ "fail", "partial", "crash" }) do
|
|
for at = 1, 6 do
|
|
local fs, files, mode = filesystem()
|
|
local save, mon, ref = fixture("ruby")
|
|
local source = { version = "ruby", slotId = "slot1", path = "saves/ruby/slot1.lua" }
|
|
files[source.path] = Serializer.encode(save)
|
|
local original = files[source.path]
|
|
local service = assert(Service.open(fs))
|
|
mode.at, mode.kind = at, kind
|
|
pcall(service.deposit, service, source, { ref }, 1, original)
|
|
mode.at = nil
|
|
local reopened = Service.open(fs)
|
|
if kind == "crash" and at == 1 then
|
|
T.check(reopened ~= nil, "journal interruption recovers")
|
|
else T.check(reopened ~= nil, kind .. " at " .. at .. " can reopen") end
|
|
if reopened then
|
|
local count = Store.count(reopened.state)
|
|
local native = Serializer.decode(files[source.path])
|
|
local inPC = Records.at(native, 3, ref) ~= nil
|
|
T.check((count == 1 and not inPC) or (count == 0 and inPC), kind .. " at " .. at .. " retains exactly one copy")
|
|
T.eq(files[Transaction.PATH], nil, kind .. " at " .. at .. " completes recovery")
|
|
if count == 1 then
|
|
T.eq(files[source.path .. ".bak"], files[source.path], kind .. " at " .. at .. " keeps game recovery consistent")
|
|
T.eq(files[Store.PATH .. ".bak"], files[Store.PATH], kind .. " at " .. at .. " keeps Box recovery consistent")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
do
|
|
local fs, files = filesystem()
|
|
local save, _, ref = fixture("gold")
|
|
local source = { version = "gold", slotId = "slot1", path = "saves/gold/slot1.lua" }
|
|
local body = Serializer.encode(save); files[source.path] = body
|
|
local service = assert(Service.open(fs))
|
|
files[source.path] = Serializer.encode({ version = "gold", generation = 2, party = {}, boxes = {} })
|
|
T.check(not service:deposit(source, { ref }, 1, body), "stale selection is refused")
|
|
T.eq(Store.count(service.state), 0, "stale selection cannot put a different mon in Box")
|
|
files[source.path] = body
|
|
T.check(not service:deposit(source, { { where = "party", index = 1 } }, 1, body), "last healthy party mon stays")
|
|
T.eq(files[source.path], body, "party refusal is nonmutating")
|
|
T.check(service:deposit(source, { ref }, 1, body), "valid box deposit still succeeds")
|
|
local red = { version = "red", slotId = "slot1", path = "saves/red/slot1.lua" }
|
|
local redSave = fixture("red"); files[red.path] = Serializer.encode(redSave)
|
|
local original = files[red.path]
|
|
T.check(not service:withdraw(red, { { box = 1, slot = 1 } }, 1), "generation conversion is refused")
|
|
T.eq(files[red.path], original, "generation refusal preserves destination")
|
|
T.eq(Store.count(service.state), 1, "generation refusal preserves stored record")
|
|
T.check(service:move(1, 1, 25, 60), "last storage slot is reachable")
|
|
T.check(Store.at(service.state, 25, 60) ~= nil, "move retains entry")
|
|
T.check(service:rename(25, "Rare Pokémon"), "Box can be renamed")
|
|
T.eq(#Store.search(service.state, "THUNDERSHOCK"), 1, "move names are searchable")
|
|
T.eq(#Store.search(service.state, "Rare Pokémon"), 1, "Box names are searchable")
|
|
T.eq(#Store.search(service.state, "type:electric level<50 move:thundershock -egg"), 1,
|
|
"type, level, move and egg filters combine")
|
|
T.eq(#Store.search(service.state, "level>=50"), 0, "numeric level filter excludes lower levels")
|
|
T.eq(#Store.search(service.state, "game:gold species:pikachu"), 1, "game and species filters work")
|
|
T.eq(#Store.search(service.state, "shiny"), 0, "shiny filter respects native metadata")
|
|
end
|
|
|
|
do
|
|
local save = fixture("gold")
|
|
save.party[2] = { species = "PIKACHU", hp = 40, item = "FLOWER_MAIL" }
|
|
save.mail = { party = { [2] = { message = "hello" } }, box = { { message = "keep" } } }
|
|
local after = assert(Records.remove(save, 2, { { where = "party", index = 1 } }))
|
|
T.eq(after.mail.party[1].message, "hello", "remaining party mail shifts with its Pokémon")
|
|
T.same(after.mail.box, save.mail.box, "mailbox stays intact")
|
|
T.eq(save.mail.party[2].message, "hello", "source save is not modified during planning")
|
|
end
|
|
|
|
do
|
|
local fs, files = filesystem()
|
|
files[Store.PATH] = "return { broken = true }"
|
|
T.check(not Service.open(fs), "corrupt Box storage is refused")
|
|
T.eq(files[Store.PATH], "return { broken = true }", "corrupt storage is not overwritten with an empty Box")
|
|
end
|
|
|
|
do
|
|
local fs, files = filesystem()
|
|
local save, _, ref = fixture("emerald")
|
|
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
|
|
local body = Serializer.encode(save); files[source.path] = body
|
|
local service = assert(Service.open(fs))
|
|
local Trade = require("src.online.Trade")
|
|
local previous = Trade.pendingSentAt
|
|
Trade.pendingSentAt = function() return { { species = 25 } } end
|
|
T.check(not service:deposit(source, { ref }, 1, body), "pending outgoing trade blocks deposit")
|
|
T.eq(files[source.path], body, "pending trade refusal preserves the game save")
|
|
T.eq(Store.count(service.state), 0, "pending trade refusal preserves Box")
|
|
Trade.pendingSentAt = previous
|
|
T.check(service:deposit(source, { ref }, 1, body), "deposit succeeds after pending trade finishes")
|
|
local full = Serializer.decode(files[source.path])
|
|
for i = 1, 30 do full.storage.boxes[1].mons[i] = Store.copy(save.party[1]) end
|
|
local fullBody = Serializer.encode(full); files[source.path] = fullBody
|
|
local boxBody = files[Store.PATH]
|
|
T.check(not service:withdraw(source, { { box = 1, slot = 1 } }, 1), "full destination PC box blocks withdrawal")
|
|
T.eq(files[source.path], fullBody, "full PC refusal preserves every destination record")
|
|
T.eq(files[Store.PATH], boxBody, "full PC refusal preserves the stored record")
|
|
end
|
|
|
|
do
|
|
local fs, files, mode = filesystem()
|
|
local save, _, ref = fixture("ruby")
|
|
local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" }
|
|
local body = Serializer.encode(save); files[source.path] = body
|
|
local service = assert(Service.open(fs))
|
|
mode.at, mode.kind = 2, "fail"
|
|
T.check(not service:deposit(source, { ref }, 1, body), "target write fault leaves a recoverable journal")
|
|
T.check(files[Transaction.PATH] ~= nil, "journal remains durable before target recovery")
|
|
mode.at = nil
|
|
local changed = Store.copy(save); changed.money = 1
|
|
local changedBody = Serializer.encode(changed); files[source.path] = changedBody
|
|
local boxBody = files[Store.PATH]
|
|
local recovered = Service.open(fs)
|
|
T.check(recovered and recovered.recoveryNotice, "externally changed save undoes the pending transfer")
|
|
T.eq(files[source.path], changedBody, "undone transfer leaves the changed game intact")
|
|
T.eq(files[Store.PATH], boxBody, "undone transfer restores the Box before image")
|
|
T.eq(Store.count(recovered.state), 0, "undone transfer does not duplicate the Pokémon")
|
|
T.eq(files[Transaction.PATH], nil, "undone transfer no longer blocks boot")
|
|
end
|
|
|
|
do
|
|
local fs, files = filesystem()
|
|
local save, _, ref = fixture("ruby")
|
|
local source = { version = "ruby", slotId = "slot1", path = "saves/cart_ruby.v1-hack/slot1.lua" }
|
|
local body = Serializer.encode(save); files[source.path] = body
|
|
local service = assert(Service.open(fs))
|
|
T.check(service:deposit(source, { ref }, 1, body), "cart ID with dots and hyphens deposits")
|
|
local boxBody, gameBody = files[Store.PATH], files[source.path]
|
|
local nextState = Serializer.decode(boxBody); nextState.boxes[1].name = "next"; nextState.revision = nextState.revision + 1
|
|
local nextSave = Serializer.decode(gameBody); nextSave.money = 5
|
|
local journal = Serializer.encode({ format = 1, jobs = {
|
|
{ path = Store.PATH, before = boxBody, after = Serializer.encode(nextState) },
|
|
{ path = source.path, before = gameBody, after = Serializer.encode(nextSave) } } })
|
|
files[Store.PATH] = Serializer.encode(nextState)
|
|
files[Store.PATH .. ".bak"] = files[Store.PATH]
|
|
files[source.path] = Serializer.encode({ version = "ruby", generation = 3, party = {}, money = 1 })
|
|
files[Transaction.PATH] = journal
|
|
assert(Service.open(fs))
|
|
T.eq(files[Store.PATH], boxBody, "conflict rolls an already written Box back to its before image")
|
|
T.eq(files[Store.PATH .. ".bak"], boxBody, "conflict rolls the Box recovery copy back too")
|
|
files[Transaction.PATH] = journal:sub(1, math.floor(#journal / 2))
|
|
local reopened = Service.open(fs)
|
|
T.check(reopened and reopened.recoveryNotice, "truncated journal is set aside instead of blocking boot")
|
|
T.eq(files[Transaction.PATH], nil, "truncated journal is cleared")
|
|
T.eq(files[Store.PATH], boxBody, "truncated journal leaves targets untouched")
|
|
end
|
|
|
|
do
|
|
local fs, files = filesystem()
|
|
local save, _, ref = fixture("emerald")
|
|
local source = { version = "emerald", slotId = "slot1", path = "saves/emerald/slot1.lua" }
|
|
local body = Serializer.encode(save); files[source.path] = body
|
|
local service = assert(Service.open(fs))
|
|
T.check(service:deposit(source, { ref }, 1, body), "deposit before storage loss")
|
|
local boxBody = files[Store.PATH]
|
|
files[Store.PATH] = nil
|
|
local reopened = assert(Service.open(fs))
|
|
T.eq(Store.count(reopened.state), 1, "missing storage recovers from its backup")
|
|
T.eq(files[Store.PATH], boxBody, "backup is promoted back to storage")
|
|
files[Store.PATH], files[Store.PATH .. ".bak"] = nil, "return {"
|
|
T.check(not Service.open(fs), "missing storage with an unreadable backup is refused")
|
|
T.eq(files[Store.PATH .. ".bak"], "return {", "unreadable backup is not overwritten")
|
|
end
|
|
|
|
CacheFs.readAt = readAt
|
|
Catalog.reset()
|
|
T.finish()
|