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

386 lines
16 KiB
Lua

package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua"
.. ";./tools/save-editor/panels/?.lua"
love = require("tests.love_stub")
local passed, failed = 0, 0
local function check(cond, msg)
if cond then
passed = passed + 1
else
failed = failed + 1
print("FAIL: " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, msg .. string.format(" (got %s, want %s)", tostring(a), tostring(b)))
end
print("== save editor item move tests (#1951) ==")
local Ops = require("Ops")
local State = require("State")
local Catalog = require("Catalog")
local Gen = require("Gen")
local Bag = require("src.inventory.Bag")
local SaveData = require("src.core.SaveData")
local G3 = require("Game3Adapter")
local function has(list, id)
for _, v in ipairs(list or {}) do if v == id then return true end end
return false
end
local Data = require("src.core.Data")
Data:load()
local function red()
local S = State.new()
S.data = Data
S.cat = Catalog.build(Data)
S.save = SaveData.newGame()
S.save.inventory, S.save.bagOrder, S.save.pcItems = {}, nil, {}
return S
end
local function fillers(S, n, skip)
local out = {}
for _, id in ipairs(S.cat.items) do
if #out >= n then break end
if not Ops.isBadgeId(id) and not (skip and skip[id]) then out[#out + 1] = id end
end
return out
end
check(type(Ops.bagToPc) == "function" and type(Ops.pcToBag) == "function"
and type(Ops.moveCount) == "function", "gen1: move ops exist")
do
local S = red()
Bag.add(S.save, "POTION", 30, S.data)
S.selectedBagId = "POTION"
local ok = Ops.bagToPc and Ops.bagToPc(S, "POTION")
check(ok == true, "gen1: bagToPc succeeds")
eq(S.save.inventory.POTION, nil, "gen1: whole stack leaves the bag")
check(not has(S.save.bagOrder, "POTION"), "gen1: bagOrder drops the moved id")
eq(S.save.pcItems.POTION, 30, "gen1: PC gets the stack")
check(S.dirty == true, "gen1: a move marks dirty")
eq(S.selectedBagId, nil, "gen1: a vanished bag row is deselected")
S.dirty = false
ok = Ops.pcToBag and Ops.pcToBag(S, "POTION")
check(ok == true, "gen1: pcToBag succeeds")
eq(S.save.pcItems.POTION, nil, "gen1: PC stack empties")
eq(S.save.inventory.POTION, 30, "gen1: bag gets the stack back")
check(has(Bag.order(S.save, S.data), "POTION"), "gen1: bag order has the id again")
check(S.dirty == true, "gen1: pcToBag marks dirty")
end
do
local S = red()
Bag.add(S.save, "POTION", 30, S.data)
S.save.pcItems.POTION = 90
eq(Ops.moveCount and Ops.moveCount(S, true, "POTION"), 9, "gen1: move clamps to the 99 stack")
check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == true, "gen1: clamped bagToPc succeeds")
eq(S.save.pcItems.POTION, 99, "gen1: PC tops out at 99")
eq(S.save.inventory.POTION, 21, "gen1: remainder stays in the bag")
check(tostring(S.status):find("21 left in the bag", 1, true) ~= nil,
"gen1: status names the remainder: " .. tostring(S.status))
S.dirty = false
eq(Ops.moveCount and Ops.moveCount(S, true, "POTION"), 0, "gen1: nothing more fits")
check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == false, "gen1: full PC stack refuses")
check(S.dirty == false, "gen1: refusal does not dirty")
end
do
local S = red()
local ids = fillers(S, 51, { POTION = true })
for i = 1, 50 do S.save.pcItems[ids[i]] = 1 end
Bag.add(S.save, "POTION", 5, S.data)
S.dirty = false
check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == false, "gen1: 51st PC stack refused")
eq(S.save.pcItems.POTION, nil, "gen1: refused move leaves PC untouched")
eq(S.save.inventory.POTION, 5, "gen1: refused move leaves the bag untouched")
check(S.dirty == false, "gen1: refused move not dirty")
check(Ops.addToPc(S, ids[51]) == false, "gen1: addToPc refuses a 51st PC id")
eq(S.save.pcItems[ids[51]], nil, "gen1: refused addToPc leaves no entry")
check(Ops.addToPc(S, ids[1]) == true, "gen1: addToPc still grows an existing stack")
end
do
local S = red()
local ids = fillers(S, 20, { POTION = true, ULTRA_BALL = true })
for i = 1, 20 do Bag.add(S.save, ids[i], 1, S.data) end
S.save.pcItems.ULTRA_BALL = 5
S.dirty = false
check(Ops.pcToBag and Ops.pcToBag(S, "ULTRA_BALL") == false, "gen1: full bag refuses pcToBag")
eq(S.save.pcItems.ULTRA_BALL, 5, "gen1: refused pcToBag leaves PC")
eq(S.save.inventory.ULTRA_BALL, nil, "gen1: refused pcToBag adds nothing")
check(tostring(S.status):find("Bag is full", 1, true) ~= nil, "gen1: status says bag is full")
check(S.dirty == false, "gen1: refused pcToBag not dirty")
Bag.remove(S.save, ids[20], 1)
check(Ops.pcToBag and Ops.pcToBag(S, "ULTRA_BALL") == true, "gen1: pcToBag works once a slot frees")
eq(S.save.inventory.ULTRA_BALL, 5, "gen1: bag gets all 5")
end
do
local S = red()
Bag.add(S.save, "BICYCLE", 1, S.data)
check(Ops.bagToPc and Ops.bagToPc(S, "BICYCLE") == true, "gen1: key item deposits")
eq(S.save.pcItems.BICYCLE, 1, "gen1: BICYCLE in PC")
check(Ops.pcToBag and Ops.pcToBag(S, "BICYCLE") == true, "gen1: key item withdraws")
eq(S.save.inventory.BICYCLE, 1, "gen1: BICYCLE back in bag")
S.save.inventory.BOULDERBADGE = true
eq(Ops.moveCount and Ops.moveCount(S, true, "BOULDERBADGE"), 0, "gen1: badges never move")
end
do
local S = red()
Bag.add(S.save, "POTION", 30, S.data)
S.save.pcItems.ANTIDOTE = 4
S.save.pcOrder = { "ANTIDOTE" }
if Ops.bagToPc then Ops.bagToPc(S, "POTION") end
if Ops.pcToBag then Ops.pcToBag(S, "ANTIDOTE") end
local back = assert(SaveData.decode(SaveData.encode(S.save)))
eq(back.pcItems.POTION, 30, "gen1: moved PC stack survives encode")
eq(back.pcItems.ANTIDOTE, nil, "gen1: withdrawn PC stack gone after encode")
eq(back.inventory.ANTIDOTE, 4, "gen1: withdrawn stack in bag after encode")
local GenSave = require("src.save_convert.GenSave")
GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")())
local okE, bytes = pcall(GenSave.encode, back, Data)
check(okE and type(bytes) == "string", "gen1: .sav encode runs: " .. tostring(bytes))
if okE and type(bytes) == "string" then
local okD, dec = pcall(GenSave.decode, bytes, Data)
check(okD and type(dec) == "table", "gen1: .sav decode runs: " .. tostring(dec))
if okD and type(dec) == "table" then
local sav = dec.save or dec
eq(sav.pcItems and sav.pcItems.POTION, 30, "gen1: .sav round trip keeps the moved stack")
eq(sav.pcItems and sav.pcItems.ANTIDOTE, nil, "gen1: .sav round trip drops the stale pcOrder id")
eq(sav.inventory and sav.inventory.ANTIDOTE, 4, "gen1: .sav round trip keeps the withdrawn stack")
end
end
end
do
local GameVersion = require("src.core.GameVersion")
local Save2 = require("src.core.gen2.Save")
local data2 = {
pokemon = {}, moves = {}, maps = {},
items = {
POTION = { pocket = "ITEM", index = 20, name = "POTION" },
MASTER_BALL = { pocket = "BALL", index = 1, name = "MASTER BALL" },
BICYCLE = { pocket = "KEY_ITEM", index = 6, name = "BICYCLE" },
HM_01 = { pocket = "TM_HM", index = 249, name = "HM01" },
},
}
local function gold()
local prior = GameVersion.get()
GameVersion.set("gold")
local S = State.new()
S.data = data2
S.cat = Catalog.build(data2)
S.save = Save2.newGame()
S.version = "gold"
S.save.inventory, S.save.bagOrder, S.save.pcItems = {}, nil, {}
GameVersion.set(prior)
return S
end
local S = gold()
eq(Gen.ofState(S), 2, "gen2: fixture is gen 2")
for i = 1, 20 do Bag.add(S.save, "FILLER_" .. i, 1, S.data) end
S.save.pcItems.MASTER_BALL = 3
S.save.pcItems.POTION = 4
check(Ops.pcToBag and Ops.pcToBag(S, "MASTER_BALL") == true, "gen2: BALL pocket free while ITEM is full")
eq(S.save.inventory.MASTER_BALL, 3, "gen2: MASTER_BALL in bag")
S.dirty = false
check(Ops.pcToBag and Ops.pcToBag(S, "POTION") == false, "gen2: 21st ITEM pocket id refused")
eq(S.save.pcItems.POTION, 4, "gen2: refused ITEM withdraw leaves PC")
check(S.dirty == false, "gen2: refused withdraw not dirty")
S = gold()
S.save.pcItems.POTION = 150
check(Ops.pcToBag and Ops.pcToBag(S, "POTION") == true, "gen2: 150 PC potions withdraw")
eq(S.save.inventory.POTION, 99, "gen2: bag takes one 99 stack")
eq(S.save.pcItems.POTION, 51, "gen2: 51 stay in PC")
S = gold()
Bag.add(S.save, "HM_01", 1, S.data)
check(Ops.bagToPc and Ops.bagToPc(S, "HM_01") == true, "gen2: HM deposits")
eq(S.save.pcItems.HM_01, 1, "gen2: HM_01 in PC")
check(Ops.pcToBag and Ops.pcToBag(S, "HM_01") == true, "gen2: HM withdraws")
eq(S.save.inventory.HM_01, 1, "gen2: HM_01 back in bag")
S = gold()
for i = 1, 49 do S.save.pcItems["FILLER_" .. i] = 1 end
S.save.pcItems.POTION = 99
Bag.add(S.save, "POTION", 5, S.data)
S.dirty = false
check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == false, "gen2: move needing a 51st PC stack refused")
eq(S.save.inventory.POTION, 5, "gen2: refused deposit leaves bag")
S = gold()
Ops.addToPc(S, "BICYCLE")
S.dirty = false
check(Ops.addToPc(S, "BICYCLE") == false, "gen2: second key item add refused")
eq(S.save.pcItems.BICYCLE, 1, "gen2: key item in PC stays x1")
check(S.dirty == false, "gen2: refused key item add not dirty")
Ops.pcAdjust(S, "BICYCLE", 1)
eq(S.save.pcItems.BICYCLE, 1, "gen2: key item + stays x1")
S = gold()
S.save.pcItems.BICYCLE = 3
eq(Ops.moveCount(S, false, "BICYCLE"), 1, "gen2: key item withdraw count is 1")
check(Ops.pcToBag(S, "BICYCLE") == true, "gen2: key item withdraws")
eq(S.save.inventory.BICYCLE, 1, "gen2: key item lands in the bag x1")
eq(S.save.pcItems.BICYCLE, 2, "gen2: key item withdraw takes only 1")
eq(Ops.moveCount(S, false, "BICYCLE"), 0, "gen2: held key item cannot withdraw again")
S = gold()
Bag.add(S.save, "BICYCLE", 1, S.data)
S.save.pcItems.BICYCLE = 1
eq(Ops.moveCount(S, true, "BICYCLE"), 0, "gen2: key item deposit cannot stack past 1")
end
local Game3Cache = require("tests.game3_cache")
if not Game3Cache.mount() then
print("[skip] gen3 item move checks: " .. tostring(Game3Cache.reason))
else
local Schema = require("src.core.game3.save_schema_firered")
local Copy = require("src.mods.Merge").deepCopy
local function deq(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return a == b end
for k, v in pairs(a) do if not deq(v, b[k]) then return false end end
for k in pairs(b) do if a[k] == nil then return false end end
return true
end
local mock = {}
Gen.bindGame3Data(mock)
local function fire()
local save = Schema.newGame({ name = "RED" })
save.bag.pockets.ITEMS, save.bag.pockets.POKE_BALLS = {}, {}
save.bag.pockets.TM_CASE, save.bag.pockets.KEY_ITEMS = {}, {}
save.storage.items = {}
local S = { data = mock, version = "firered", save = save, cat = Catalog.build(mock, save, "firered") }
Gen.hydrateSave(mock, save)
return S
end
eq(G3.PC_ITEMS_COUNT, 30, "gen3: editor PC cap is FRLG PC_ITEMS_COUNT")
local S = fire()
Ops.addToBag(S, "POTION"); Ops.bagAdjust(S, "POTION", 9)
eq(S.save.inventory["13"], 10, "gen3: seeded 10 potions")
check(Ops.bagToPc and Ops.bagToPc(S, "POTION") == true, "gen3: bagToPc succeeds")
eq(S.save.inventory["13"], nil, "gen3: projection bag empty")
eq(S.save.pcItems["13"], 10, "gen3: projection PC 10")
eq(#S.save.bag.pockets.ITEMS, 0, "gen3: native ITEMS pocket emptied")
eq(S.save.storage.items[1] and S.save.storage.items[1].qty, 10, "gen3: native storage slot written")
check(Ops.pcToBag and Ops.pcToBag(S, "13") == true, "gen3: pcToBag succeeds")
eq(S.save.inventory["13"], 10, "gen3: back in bag")
eq(#S.save.storage.items, 0, "gen3: native storage emptied")
S = fire()
for i = 1, 42 do S.save.bag.pockets.ITEMS[i] = { id = 2000 + i, qty = 1 } end
S.save.storage.items = { { id = 13, qty = 5 } }
Gen.hydrateSave(mock, S.save)
local before = Copy(S.save)
S.dirty = false
check(Ops.pcToBag and Ops.pcToBag(S, "13") == false, "gen3: full ITEMS pocket refuses")
check(deq(S.save, before), "gen3: refused withdraw rolls back fully")
check(S.dirty == false, "gen3: refused withdraw not dirty")
S = fire()
for i = 1, 30 do S.save.storage.items[i] = { id = 2000 + i, qty = 1 } end
S.save.bag.pockets.ITEMS = { { id = 13, qty = 3 } }
Gen.hydrateSave(mock, S.save)
before = Copy(S.save)
S.dirty = false
check(Ops.bagToPc and Ops.bagToPc(S, "13") == false, "gen3: 31st PC slot refused")
check(deq(S.save, before), "gen3: refused deposit rolls back fully")
check(Ops.addToPc(S, "POTION") == false, "gen3: addToPc refuses a 31st PC slot")
check(deq(S.save, before), "gen3: refused addToPc rolls back fully")
S = fire()
S.save.storage.items = { { id = 289, qty = 1 } }
Gen.hydrateSave(mock, S.save)
check(Ops.pcToBag and Ops.pcToBag(S, "289") == true, "gen3: TM withdraws")
check(S.save.inventory["364"] == 1, "gen3: TM withdraw adds TM_CASE")
eq(S.save.inventory["289"], 1, "gen3: TM01 in bag")
eq(Ops.moveCount and Ops.moveCount(S, true, "364"), 0, "gen3: TM_CASE itself cannot go to PC")
local out = G3.export(S.save)
check(type(out.bag) == "table" and out.pcItems == nil, "gen3: export drops the flat projection")
local tmSlot = out.bag.pockets.TM_CASE and out.bag.pockets.TM_CASE[1]
eq(tmSlot and tmSlot.id, 289, "gen3: export keeps TM01 in the TM_CASE pocket")
S = fire()
S.save.bag.pockets.ITEMS = { { id = 13, qty = 999 } }
S.save.storage.items = { { id = 13, qty = 990 } }
Gen.hydrateSave(mock, S.save)
eq(Ops.moveCount and Ops.moveCount(S, true, "13"), 9, "gen3: move clamps to the 999 stack")
check(Ops.bagToPc and Ops.bagToPc(S, "13") == true, "gen3: clamped move succeeds")
eq(S.save.pcItems["13"], 999, "gen3: PC tops out at 999")
eq(S.save.inventory["13"], 990, "gen3: remainder stays in the bag")
end
do
local Kit = require("Kit")
local App = require("App")
local oldDim = love.graphics.getDimensions
for _, size in ipairs({ { 1280, 720 }, { 500, 800 } }) do
love.graphics.getDimensions = function() return size[1], size[2] end
local path = os.tmpname() .. "-bug1951.lua"
local seed = SaveData.newGame()
seed.inventory, seed.bagOrder = { POTION = 30 }, { "POTION" }
seed.pcItems = { ULTRA_BALL = 5 }
local f = assert(io.open(path, "wb")); f:write(SaveData.encode(seed)); f:close()
App.load(path, { version = "red" })
local S = App.getState()
S.tab = "items"
local rects = {}
local real = Kit.button
Kit.button = function(x, y, w, h, label, opts)
if label == "PC" or label == "BAG" then
rects[#rects + 1] = { label = label, x = x, y = y, w = w, h = h,
enabled = not (opts and opts.enabled == false) }
end
return real(x, y, w, h, label, opts)
end
local pcBtn, bagBtn
local function capture()
rects = {}
App.draw()
pcBtn, bagBtn = nil, nil
for _, r in ipairs(rects) do
if r.label == "PC" then pcBtn = r elseif r.label == "BAG" then bagBtn = r end
end
end
capture()
if pcBtn and pcBtn.y > size[2] - 60 then
S.itemsScroll = pcBtn.y - 120
capture()
capture()
end
local tag = ("%dx%d"):format(size[1], size[2])
check(pcBtn ~= nil and pcBtn.enabled, tag .. " panel: bag row has an enabled PC button")
check(bagBtn ~= nil and bagBtn.enabled, tag .. " panel: PC row has an enabled BAG button")
if pcBtn then
App.mousepressed(pcBtn.x + pcBtn.w / 2, pcBtn.y + pcBtn.h / 2, 1)
App.draw()
eq(S.save.pcItems.POTION, 30, tag .. " panel: PC click moved the potions")
eq(S.save.inventory.POTION, nil, tag .. " panel: bag row emptied")
check(tostring(S.status):find("Moved 30", 1, true) ~= nil,
tag .. " panel: move status is not clobbered by row select: " .. tostring(S.status))
end
Kit.button = real
App.unload()
os.remove(path)
end
love.graphics.getDimensions = oldDim
end
print(string.format("save editor item move tests: %d passed, %d failed", passed, failed))
if failed > 0 then os.exit(1) end