Files
gen1recomp/tests/save_editor_gen3_persistence_tests.lua
bryanthaboi dee935a4ea CLOSES #2430
2026-09-23 11:58:32 -04:00

342 lines
17 KiB
Lua

package.path = './?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua;' .. package.path
_G.love = require('tests.love_stub')
require('tests.game3_cache').mountOrSkip('save_editor_gen3_persistence_tests', 'pokemon/meta.lua')
local P = require('src.core.game3.pokemon')
P.install(nil)
local E = require('src.core.game3.battle.experience')
local Schema = require('src.core.game3.save_schema_firered')
local Bag = require('src.core.game3.bag')
local Storage = require('src.core.game3.storage')
local SD = require('src.core.SaveData')
local IO = require('SaveIO')
local App = require('tools.save-editor.App')
local Ops = require('Ops')
local Gen = require('Gen')
local Copy = require('src.mods.Merge').deepCopy
local function eq(a, b, label)
if type(a) == 'table' and type(b) == 'table' then
for k, v in pairs(a) do eq(v, b[k], label .. '.' .. tostring(k)) end
for k in pairs(b) do assert(a[k] ~= nil, label .. ': extra ' .. tostring(k)) end
else assert(a == b, label .. ': ' .. tostring(a) .. ' ~= ' .. tostring(b)) end
end
local function mon(id)
local m = { species = id, speciesId = id, level = 20, personality = 123,
ivs = { hp = 20, atk = 14, def = 12, spe = 18, spa = 24, spd = 6 },
evs = { hp = 10, atk = 8, def = 6, spe = 4, spa = 2, spd = 0 },
moves = { 33, 45 }, pp = { 30, 35 }, maxPp = { 35, 40 }, ppBonusesPacked = 5,
heldItem = 13, otId = 123, otSecretId = 321, otName = 'OTHER', nickname = 'KEPT',
metLocation = 88, metLevel = 5, pokeball = 2, friendship = 133, pokerus = 17,
custom = { sentinel = 'kept' } }
E.syncExpToLevel(m); P.applyStats(m); m.hp = m.maxHp - 3
return m
end
local files = {}
local function load(native)
local path = os.tmpname()
files[#files + 1] = path
local f = assert(io.open(path, 'wb')); f:write(SD.encode(native)); f:close()
App.load(path, { version = 'firered', embedded = true })
return App.getState(), path
end
local function saved(path)
assert(App.save())
return assert(IO.load(path))
end
local function award(session)
return E.awardFoe({ playerParty = session.party, player = { mon = session.party[1], partyIndex = 1 },
wild = true, session = session }, { species = 113, level = 40, mon = { species = 113, level = 40 } },
{ partyIndices = { 1 }, getOpts = function() return {} end })
end
local function native(id)
local session = Schema.newGame({ name = 'RED' })
session.party = { mon(id or 25), mon(6) }
session.storage.boxes[1].mons[7] = mon(9)
session.storage.boxes[1].name, session.storage.boxes[1].wallpaper = 'KEEP', 8
session.storage.currentBox = 3
for _, pair in ipairs({ { 13, 5 }, { 68, 2 }, { 4, 10 }, { 360, 1 }, { 289, 1 }, { 139, 3 } }) do
assert(Bag.add(session.bag, pair[1], pair[2]))
end
session.storage.items = { { id = 13, qty = 999 }, { id = 68, qty = 120 }, { id = 4, qty = 10 } }
return SD.decode(SD.encode(Schema.toSaveTable(session)))
end
local metadata = { 'personality', 'ivs', 'evs', 'moves', 'pp', 'maxPp', 'ppBonusesPacked', 'heldItem',
'otId', 'otSecretId', 'otName', 'nickname', 'metLocation', 'metLevel', 'pokeball', 'friendship', 'pokerus', 'custom' }
if os.getenv('POKEPORT_SAVE_EDITOR_ITEMS_ONLY') ~= '1' then
local species = { 25, 4 }
local groups = {}
for id = 1, 412 do
if P.isInternalSpecies(id) then
local gr = P.growthRate(id)
if gr ~= nil and not groups[gr] then groups[gr] = id; species[#species + 1] = id end
end
end
local groupCount = 0
for _ in pairs(groups) do groupCount = groupCount + 1 end
assert(groupCount == 6, 'real pack includes all six growth groups')
for _, id in ipairs(species) do
for _, level in ipairs({ 0, 15, 21 }) do
local source = native(id)
local before = Copy(source.party[1])
local S, path = load(source)
eq(S.save.party[1].maxHp, before.maxHp, 'hydrate stats')
eq(S.save.party[1].hp, before.hp, 'hydrate preserves current HP')
if level > 0 then assert(Ops.setLevel(S, S.save.party[1], level)) end
S.save.party[1].exp = require('src.core.game3.summary_data').expForLevel(P.growthRate(id), S.save.party[1].level + 1) - 1
local output = saved(path)
eq(output.party[1].species, id, 'numeric species persistence')
for _, key in ipairs(metadata) do eq(output.party[1][key], before[key], 'mon metadata ' .. key) end
local reloaded = Schema.fromSaveTable(output)
local m = reloaded.party[1]
local stats = P.calcStats(id, m.level, m.ivs, m.evs, m.personality)
eq(m.maxHp, stats.maxHp, 'runtime stats')
local oldExp, oldLevel = m.exp, m.level
local rewards = award(reloaded)
eq(#rewards, 1, 'actual award count')
assert(m.exp > oldExp and m.level > oldLevel, 'actual battle EXP and level crossing')
App.unload()
end
end
print('PASS native_editor_award_all_growth_groups')
do
local source = native()
source.party[1].species, source.party[1].maxHp, source.party[1].hp = 'PIKACHU', 999, 7
source.storage.boxes[1].mons[7].species = 'BLASTOISE'
local loaded = Schema.fromSaveTable(source)
eq(loaded.party[1].species, 25, 'legacy named party repair')
eq(loaded.party[1].hp, 7, 'legacy repair no healing')
eq(loaded.storage.boxes[1].mons[7].species, 9, 'legacy named box repair')
assert(#award(loaded) == 1, 'legacy repaired mon receives award')
print('PASS legacy_named_species_repair')
end
do
local S, path = load(native())
local m = S.save.party[1]
assert(Ops.setMove(S, m, 1, 57))
eq(P.moveIdAt(m, 1), 57, 'numeric move replacement')
eq(m.pp[1], P.movePp(57), 'new move PP')
eq(m.ppBonusesPacked, 4, 'reset replaced slot bonus only')
eq(m.moves[2], 45, 'unaffected move slot')
eq(m.pp[2], 35, 'unaffected PP slot')
assert(Ops.clearMove(S, m, 1)); eq(m.pp[1], nil, 'clear parallel PP')
assert(Ops.setMove(S, m, 1, 57))
eq(P.moveIdAt(Schema.fromSaveTable(saved(path)).party[1], 1), 57, 'saved native move')
assert(S.save.boxes[1][7], 'sparse slot exposed')
S.selectedParty, S.selectedBox, S.selectedBoxSlot = 2, 1, 2
assert(Ops.deposit(S))
eq(S.save.storage.boxes[1].mons[2].species, 6, 'deposit exact empty slot')
eq(S.save.storage.boxes[1].mons[7].species, 9, 'untouched sparse slot')
S.selectedBoxSlot = 7; assert(Ops.withdraw(S))
eq(S.save.storage.boxes[1].mons[7], nil, 'withdraw exact sparse slot')
local output = saved(path)
eq(output.storage.boxes[1].name, 'KEEP', 'box name')
eq(output.storage.boxes[1].wallpaper, 8, 'box wallpaper')
eq(output.storage.currentBox, 3, 'current box preserved')
eq(Schema.fromSaveTable(output).storage.boxes[1].mons[2].species, 6, 'runtime sparse deposit')
App.unload()
print('PASS native_moves_sparse_box_roundtrip')
end
end
do
local source = native()
source.extra = { keep = true }
source.bag.pockets.ITEMS[1].custom = 'slot metadata'
source.storage.items[2].custom = 'PC metadata'
source.storage.custom = 'storage metadata'
source.bag.pockets.ITEMS[#source.bag.pockets.ITEMS + 1] = { id = 2001, qty = 17, custom = 'foreign' }
source.storage.items[#source.storage.items + 1] = { id = 2002, qty = 77, custom = 'foreign PC' }
local S, path = load(source)
local repeated = Copy(S.save)
Gen.hydrateSave(S.data, S.save); eq(S.save, repeated, 'idempotent hydration')
local boxRef = S.save.storage.boxes[1].mons[7]
assert(Ops.addToBag(S, 'RARE_CANDY')); assert(Ops.bagAdjust(S, '68', 1))
eq(S.save.inventory.pockets, nil, 'clean flat projection')
assert(Ops.pcAdjust(S, 'RARE_CANDY', -1)); assert(Ops.addToPc(S, 68))
eq(S.save.storage.boxes[1].mons[7], boxRef, 'item edit keeps selected mon identity')
eq(S.save.pcItems['68'], 120, 'PC 120 decrement and add')
eq(S.save.inventory['68'], 4, 'same ID aliases combined')
local output = saved(path)
local expected = Copy(source.bag.pockets); expected.ITEMS[2].qty = 4
eq(output.bag.pockets, expected, 'full untouched pocket and metadata preservation')
eq(output.storage.items, source.storage.items, 'full PC order quantities metadata')
eq(output.extra, source.extra, 'unknown save fields')
eq(output.storage.custom, source.storage.custom, 'storage metadata')
local loaded = Schema.fromSaveTable(Copy(output))
eq(Bag.get(loaded.bag, 68), 4, 'runtime candy')
eq(loaded.storage.items[2].qty, 120, 'runtime PC above 99')
App.unload(); App.load(path, { version = 'firered', embedded = true }); S = App.getState()
assert(Ops.bagMax(S, 'RARE_CANDY')); assert(Ops.pcMax(S, '68'))
local before = Copy(S.save); S.dirty = false
assert(not Ops.addToBag(S, 68)); eq(S.save, before, 'bag 999 rejection full rollback'); eq(S.dirty, false, 'failed bag not dirty')
assert(not Ops.addToPc(S, 'RARE_CANDY')); eq(S.save, before, 'PC 999 rejection full rollback')
assert(Ops.bagAdjust(S, 68, -1)); assert(Ops.pcAdjust(S, 68, -1))
eq(S.save.inventory['68'], 998, 'bag 999 decrement'); eq(S.save.pcItems['68'], 998, 'PC 999 decrement')
assert(Ops.bagMaxAll(S)); assert(Ops.pcMaxAll(S))
for _, slot in ipairs(S.save.bag.pockets.KEY_ITEMS) do eq(slot.qty, 1, 'containers not inflated') end
assert(Ops.bagDrop(S, 68)); assert(Ops.pcDrop(S, 68))
local final = Schema.fromSaveTable(saved(path))
eq(Bag.get(final.bag, 68), 0, 'drop persisted')
final.storage.items[1].qty = 998
assert(Storage.depositItem(final, 'ITEMS', 1, 1)); assert(Storage.withdrawItem(final, 1, 1))
App.unload()
print('PASS native_bag_pc_atomic_preservation')
end
do
local source = native()
source.bag = Bag.new(); source.storage.items = {}
source.inventory = { stacks = { POTION = 999 } }; source.pcItems = { POTION = 999 }
local S, path = load(source)
eq(S.save.inventory, {}, 'native empty bag authoritative'); eq(S.save.pcItems, {}, 'native empty PC authoritative')
saved(path); App.unload()
source = native(); source.bag.pockets.ITEMS = {}
for i = 1, 42 do source.bag.pockets.ITEMS[i] = { id = 2000 + i, qty = 1 } end
S, path = load(source)
local before = Copy(S.save)
assert(not Ops.addToBag(S, 68)); eq(S.save, before, 'full pocket rollback'); eq(S.dirty, false, 'full rejection dirty')
App.unload()
source = native(); source.storage = nil
source.pc = { items = { { id = 68, qty = 120 } }, mons = { mon(9) }, custom = true }
S, path = load(source); assert(Ops.pcAdjust(S, 68, -1))
local output = saved(path)
eq(output.storage.items[1].qty, 119, 'legacy PC migration quantity')
eq(output.storage.boxes[1].mons[1].species, 9, 'legacy PC mons retained')
eq(output.pc.custom, true, 'legacy unknown field')
App.unload()
print('PASS native_empty_legacy_full_pocket')
end
do
local A = require('Game3Adapter')
local source = native()
local old = mon(6); old.nickname = 'OLDDEPOSIT'
source.boxes = { { [7] = old, [8] = Copy(source.storage.boxes[1].mons[7]) } }
local S, path = load(source)
local function count(save, nickname)
local n = 0
for _, box in pairs(save.storage.boxes) do
for _, m in pairs(box.mons) do if m.nickname == nickname then n = n + 1 end end
end
return n
end
eq(S.save.storage.boxes[1].mons[7].species, 9, 'collision native preserved')
eq(count(S.save, 'OLDDEPOSIT'), 1, 'collision deposit relocated')
eq(count(S.save, 'KEPT'), 1, 'serialized mirror deduplicated')
local before = Copy(S.save)
Gen.ensureBoxes(S.save); Gen.hydrateSave(S.data, S.save)
eq(S.save, before, 'legacy coexistence repeated hydration')
local output = saved(path)
eq(count(output, 'OLDDEPOSIT'), 1, 'old editor no-op save retains deposit')
eq(count(Schema.fromSaveTable(output), 'OLDDEPOSIT'), 1, 'runtime retains old deposit')
App.unload(); App.load(path, { version = 'firered', embedded = true })
eq(count(saved(path), 'OLDDEPOSIT'), 1, 'second no-op save no duplication')
App.unload()
source = native()
for b = 1, 14 do
source.storage.boxes[b] = source.storage.boxes[b] or { mons = {} }
for s = 1, 30 do source.storage.boxes[b].mons[s] = mon(9) end
end
source.boxes = { { old } }
before = Copy(source)
local ok, err = pcall(A.ensureStorage, source)
assert(not ok and tostring(err):find('native storage is full', 1, true))
eq(source, before, 'full collision leaves all recoverable data intact')
S, path = load(source)
assert(S.loadError and not S.allowSave and not App.save(), 'full collision disables App save')
eq(assert(IO.load(path)), source, 'failed App load leaves file intact')
App.unload()
source.storage.boxes[14].mons[30] = nil
S, path = load(source)
eq(count(saved(path), 'OLDDEPOSIT'), 1, 'last free slot collision migration')
App.unload()
print('PASS legacy_native_boxes_collision_noop_full_storage')
end
do
local A = require('Game3Adapter')
for _, mode in ipairs({ 'minus', 'drop', 'max' }) do
local source = native()
local slots = { { id = 13, qty = 700, custom = 'before' }, { id = 68, qty = 120, custom = 'target' },
{ id = 2001, qty = 77, custom = 'middle' }, { id = 'RARE_CANDY', qty = 1 },
{ id = '68', qty = 2 }, { id = 2002, qty = 88, custom = 'after' } }
source.bag.pockets.ITEMS, source.storage.items = Copy(slots), Copy(slots)
local S, path = load(source)
eq(S.save.inventory['68'], 123, 'duplicate bag total')
eq(S.save.pcItems['68'], 123, 'duplicate PC total')
local untouched = Copy(S.save)
assert(not A.change(S.data, S.save, false, { { id = 68, qty = 122 }, { id = 13, qty = 1000 } }))
eq(S.save, untouched, 'duplicate bag batch rejection rollback')
assert(not A.change(S.data, S.save, true, { { id = 68, qty = 122 }, { id = 13, qty = 1000 } }))
eq(S.save, untouched, 'duplicate PC batch rejection rollback')
local expected = mode == 'minus' and 122 or mode == 'max' and 999 or 0
for _, prefix in ipairs({ 'bag', 'pc' }) do
if mode == 'minus' then assert(Ops[prefix .. 'Adjust'](S, 'RARE_CANDY', -1))
elseif mode == 'drop' then assert(Ops[prefix .. 'Drop'](S, '68'))
else assert(Ops[prefix .. 'Max'](S, 68)) end
end
local expectedSlots = { Copy(slots[1]), Copy(slots[3]), Copy(slots[6]) }
if expected > 0 then
local target = Copy(slots[2]); target.qty = expected
table.insert(expectedSlots, 2, target)
end
eq(S.save.bag.pockets.ITEMS, expectedSlots, 'bag duplicate ' .. mode)
eq(S.save.storage.items, expectedSlots, 'PC duplicate ' .. mode)
local output = saved(path)
eq(output.bag.pockets.ITEMS, expectedSlots, 'persist bag duplicate ' .. mode)
eq(output.storage.items, expectedSlots, 'persist PC duplicate ' .. mode)
App.unload(); App.load(path, { version = 'firered', embedded = true }); S = App.getState()
eq(S.save.inventory['68'] or 0, expected, 'reload bag aggregate ' .. mode)
eq(S.save.pcItems['68'] or 0, expected, 'reload PC aggregate ' .. mode)
if mode == 'max' then
untouched = Copy(S.save)
assert(not Ops.addToBag(S, 68) and not Ops.addToPc(S, 'RARE_CANDY'))
eq(S.save, untouched, 'duplicate capped rejection unchanged')
end
App.unload()
end
print('PASS duplicate_bag_pc_alias_minus_drop_max_reload_rollback')
end
do
local source = native()
local S, path = load(source)
S.editingMon, S.dirty, S._quitArmed, S._openArmed = S.save.party[1], true, true, true
assert(App.reload())
eq(S.editingMon, nil, 'reload clears mon editor')
eq(S.dirty, false, 'reload clears dirty')
eq(S._quitArmed, false, 'reload disarms quit')
eq(S._openArmed, false, 'reload disarms open')
eq(S.save.inventory['68'], 2, 'actual reload bag projection')
eq(S.save.pcItems['68'], 120, 'actual reload PC projection')
assert(S.save.boxes[1] == S.save.storage.boxes[1].mons, 'reload native box alias')
local output = saved(path)
eq(output.bag.pockets, source.bag.pockets, 'reload no-op bag')
eq(output.storage.items, source.storage.items, 'reload no-op PC')
eq(output.storage.boxes[1].mons[7].species, 9, 'reload no-op native sparse mon')
assert(App.reload())
assert(Ops.addToBag(S, 'RARE_CANDY'))
assert(Ops.pcAdjust(S, '68', -1))
S.selectedParty, S.selectedBox, S.selectedBoxSlot = 2, 1, 2
assert(Ops.deposit(S))
output = saved(path)
local runtime = Schema.fromSaveTable(Copy(output))
eq(Bag.get(runtime.bag, 68), 3, 'reload subsequent bag edit')
eq(runtime.storage.items[2].qty, 119, 'reload subsequent PC edit')
eq(runtime.storage.boxes[1].mons[2].species, 6, 'reload subsequent deposit')
eq(runtime.storage.boxes[1].mons[7].species, 9, 'reload untouched native mon')
for b = 1, 14 do
source.storage.boxes[b] = source.storage.boxes[b] or { mons = {} }
for s = 1, 30 do source.storage.boxes[b].mons[s] = mon(9) end
end
source.boxes = { { mon(6) } }
local f = assert(io.open(path, 'wb')); f:write(SD.encode(source)); f:close()
assert(not App.reload() and S.loadError and not S.allowSave, 'reload collision disables save')
assert(not App.save())
eq(assert(IO.load(path)), source, 'reload collision leaves disk intact')
App.unload()
print('PASS actual_app_reload_native_noop_edits_collision')
end
for _, path in ipairs(files) do os.remove(path) end
print('PASS save_editor_gen3_native_persistence')