diff --git a/src/core/game3/save_schema_firered.lua b/src/core/game3/save_schema_firered.lua index 9a7d5e83..934fce4d 100644 --- a/src/core/game3/save_schema_firered.lua +++ b/src/core/game3/save_schema_firered.lua @@ -347,7 +347,8 @@ function Schema.fromSaveTable(save) engine = save.engine or "game3", version = version, generation = tonumber(save.generation) or 3, - party = save.party or {}, + -- A party saved with gaps (older PC builds) is closed up on load. + party = require("src.core.game3.storage").compactParty(save.party or {}), bag = bag, dex = save.dex or {}, money = save.money or 0, diff --git a/src/core/game3/storage.lua b/src/core/game3/storage.lua index 289f3282..b1fa442e 100644 --- a/src/core/game3/storage.lua +++ b/src/core/game3/storage.lua @@ -32,6 +32,23 @@ local function script_store(session) return (Space and Space.store) or (session and session.store) or nil end +--- Close gaps in the party in place so mons fill slots 1..n in order. +-- pokefirered/src/pokemon.c CompactPartySlots +function Storage.compactParty(party) + if type(party) ~= "table" then return party end + local keys = {} + for k, m in pairs(party) do + local n = tonumber(k) + if n and m ~= nil then keys[#keys + 1] = { n = n, k = k } end + end + table.sort(keys, function(a, b) return a.n < b.n end) + local mons = {} + for i, e in ipairs(keys) do mons[i] = party[e.k] end + for _, e in ipairs(keys) do party[e.k] = nil end + for i, m in ipairs(mons) do party[i] = m end + return party +end + --- Create a fresh Storage instance (14 boxes, 30 slots each, 50-item PC). function Storage.new() local storage = { @@ -240,7 +257,9 @@ function Storage.moveMon(session, srcLoc, srcIdx, destLoc, destIdx, srcBox, dest if not srcMon then return false, "src_empty" end -- Cannot leave party empty if withdrawing/moving away - if srcLoc == "party" and destLoc == "box" and not destMon and #session.party <= 1 then + local partyCount = 0 + for _, m in pairs(session.party) do if m ~= nil then partyCount = partyCount + 1 end end + if srcLoc == "party" and destLoc == "box" and not destMon and partyCount <= 1 then return false, "last_pokemon" end @@ -259,20 +278,6 @@ function Storage.moveMon(session, srcLoc, srcIdx, destLoc, destIdx, srcBox, dest -- Assign to src if srcLoc == "party" then session.party[srcIdx] = destMon - -- Clean up trailing nils in party array if moved without swap - if not destMon and srcIdx > #session.party then - local keys = {} - for k in pairs(session.party) do - if type(k) == "number" then keys[#keys + 1] = k end - end - table.sort(keys) - local newParty = {} - for _, k in ipairs(keys) do - local m = session.party[k] - if m then newParty[#newParty + 1] = m end - end - session.party = newParty - end elseif srcLoc == "box" then local box = storage.boxes[srcBox or storage.currentBox] box.mons[srcIdx] = destMon @@ -297,6 +302,8 @@ function Storage.moveMon(session, srcLoc, srcIdx, destLoc, destIdx, srcBox, dest elseif srcLoc=="party" then Q.event(session,"DepositedMonInPC",{D0=srcName,D1=dstBoxName}) else Q.event(session,"WithdrewMonFromPC",{D0=srcBoxName,D1=srcName}) end + -- A mon moved out of (or into) the middle of the party must not leave a gap. + Storage.compactParty(session.party) return true end diff --git a/src/ui/game3/box_storage_ui.lua b/src/ui/game3/box_storage_ui.lua index c9b60f96..a8fe387b 100644 --- a/src/ui/game3/box_storage_ui.lua +++ b/src/ui/game3/box_storage_ui.lua @@ -153,6 +153,8 @@ function BoxStorageUI.show(opts) BoxStorageUI._actionSource = nil BoxStorageUI._actionTarget = nil local storage = Storage.ensure(BoxStorageUI._session) + -- Repair a party left with gaps by an older build before any slot is indexed. + if BoxStorageUI._session then Storage.compactParty(BoxStorageUI._session.party) end -- pokefirered/src/pokemon_storage_system_tasks.c:426 BoxStorageUI._lastUsedBox = storage and ((tonumber(storage.currentBox) or 1) - 1) or nil @@ -379,6 +381,8 @@ function BoxStorageUI.handleInput(input) se(se_id("SE_BAG_POCKET")) end end + -- pokefirered/src/pokemon_storage_system_tasks.c SetUpHidePartyMenu -> CompactPartySlots + Storage.compactParty(party) elseif mon then BoxStorageUI._actionSource = "party" BoxStorageUI._actionTarget = { mon = mon, loc = "party", boxId = nil, slot = pIdx } diff --git a/src/ui/game3/party_menu.lua b/src/ui/game3/party_menu.lua index 24dcff19..5061f113 100644 --- a/src/ui/game3/party_menu.lua +++ b/src/ui/game3/party_menu.lua @@ -2490,6 +2490,16 @@ local function slot_description(slot, mon) local item = PartyMenu._item if not item or PartyMenu._battle then return nil end if PartyMenu.mode ~= "use" and PartyMenu.mode ~= "message" then return nil end + -- pokefirered/src/party_menu.c:856 TM/HM -> DisplayPartyPokemonDataToTeachMove + if ItemsData.isTm(item) then + -- pokefirered/src/party_menu.c:4760 CanMonLearnTMTutor + if Pokemon.isEgg(mon) then return desc_text("NOT_ABLE_2") end + local moveId = Pokemon.moveFromTmItem(item) + local species = tonumber(mon.species or mon.speciesId) + if not moveId or not Pokemon.canLearnTmItem(species, item) then return desc_text("NOT_ABLE_2") end + if Pokemon.knowsMove(mon, moveId) then return desc_text("LEARNED") end + return desc_text("ABLE_2") + end if not is_evolution_stone(item) then return nil end local Evolution = require("src.core.game3.evolution") if Evolution.itemCheck(mon, item) then return nil end diff --git a/tests/game3_pc_party_gap_2494_test.lua b/tests/game3_pc_party_gap_2494_test.lua new file mode 100644 index 00000000..0e34b53c --- /dev/null +++ b/tests/game3_pc_party_gap_2494_test.lua @@ -0,0 +1,96 @@ +#!/usr/bin/env luajit +-- #2494: moving a party mon out of the middle of the party in the PC left a +-- hole, so later slots showed as empty in battle and could not be filled. +-- pokefirered/src/pokemon.c CompactPartySlots keeps the party packed. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +require("tests.game3_cache").stubSpeciesNames() +require("tests.fixture_data.game3_items").install() +package.loaded["src.core.game3.rom_text"] = { + plain = function(key) return key end, box = function(key) return key end, + ascii = function(key) return key end, has = function() return true end, + key = function(n, i) return n .. "[" .. i .. "]" end, + at = function(n, i) return n .. "[" .. i .. "]" end, + count = function() return 0 end, list = function() return {} end, + lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, +} + +local Storage = require("src.core.game3.storage") +local BoxStorageUI = require("src.ui.game3.box_storage_ui") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function mon(species) + return { species = species, speciesId = species, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 } } +end + +local function packed(party, n) + for i = 1, n do + if party[i] == nil then return false end + end + for k in pairs(party) do + if type(k) ~= "number" or k > n then return false end + end + return true +end + +print("[test] 1. moving slot 3 of 6 into a box closes the gap") +do + local session = { party = { mon(1), mon(4), mon(7), mon(10), mon(13), mon(16) } } + local party = session.party + Storage.ensure(session) + local ok = Storage.moveMon(session, "party", 3, "box", 1, nil, 1) + check(ok == true, "the move succeeds") + check(session.party == party, "the party table keeps its identity") + check(packed(session.party, 5), "the party is packed into slots 1..5") + check(session.party[3].species == 10 and session.party[5].species == 16, + "later mons shift up in order") +end + +print("[test] 2. placing a box mon into an empty far party slot packs it") +do + local session = { party = { mon(1), mon(4) } } + Storage.ensure(session) + BoxStorageUI.show({ session = session }) + local storage = Storage.ensure(session) + storage.boxes[1].mons[1] = mon(25) + BoxStorageUI.mode = "party_drawer" + BoxStorageUI.drawerOpen = true + BoxStorageUI.holdingMon = storage.boxes[1].mons[1] + BoxStorageUI.holdingSource = { loc = "box", boxId = 1, slot = 1 } + BoxStorageUI.partyCursor = 6 + BoxStorageUI.handleInput({ + wasPressed = function(_, k) return k == "a" end, + isDown = function(_, k) return k == "a" end, + }) + check(packed(session.party, 3), "the party is packed into slots 1..3") + check(session.party[3] and session.party[3].species == 25, "the placed mon lands in slot 3") + check(storage.boxes[1].mons[1] == nil, "the box slot is emptied") + BoxStorageUI.close() +end + +print("[test] 3. a party already saved with a gap is repaired") +do + local party = { mon(1), mon(4), nil, mon(10), mon(13) } + party[3] = nil + Storage.compactParty(party) + check(packed(party, 4), "numeric gaps close") + local stringy = { ["1"] = mon(1), ["2"] = mon(4), ["4"] = mon(10) } + Storage.compactParty(stringy) + check(packed(stringy, 3) and stringy[3].species == 10, "string-keyed slots close too") +end + +if failed > 0 then + print(string.format("[FAIL] %d check(s) failed", failed)) + os.exit(1) +end +print("[PASS] game3_pc_party_gap_2494") diff --git a/tests/game3_tm_able_desc_2520_test.lua b/tests/game3_tm_able_desc_2520_test.lua new file mode 100644 index 00000000..6743dee9 --- /dev/null +++ b/tests/game3_tm_able_desc_2520_test.lua @@ -0,0 +1,80 @@ +#!/usr/bin/env luajit +-- #2520: choosing a TM/HM from the bag opens the party menu without the +-- ABLE! / NOT ABLE! / LEARNED labels on each slot. +-- pokefirered/src/party_menu.c:856 DisplayPartyPokemonDataForMoveTutorOrEvolutionItem +-- -> DisplayPartyPokemonDataToTeachMove. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +require("tests.game3_cache").stubSpeciesNames() +require("tests.fixture_data.game3_items").install() + +-- pokefirered/src/data/party_menu.h:634 sDescriptionStringTable +local DESCRIPTIONS = { [0] = "NO USE", "ABLE", "FIRST", "SECOND", "THIRD", "ABLE", + "NOT ABLE", "ABLE!", "NOT ABLE!", "LEARNED" } +package.loaded["src.core.game3.rom_text"] = { + plain = function(key) return key end, box = function(key) return key end, + ascii = function(key) return key end, has = function() return true end, + key = function(n, i) return n .. "[" .. tostring(i) .. "]" end, + at = function(n, i) + if n == "sDescriptionStringTable" then return DESCRIPTIONS[i] end + return n .. "[" .. tostring(i) .. "]" + end, + count = function() return 0 end, list = function() return {} end, + lazy = function(map) return setmetatable({}, { __index = function(_, k) return map[k] end }) end, +} + +local ItemsData = require("src.core.game3.items_data") +local Pokemon = require("src.core.game3.pokemon") + +local TM06, TOXIC = 294, 92 +local BULBASAUR, CHARMANDER, GEODUDE = 1, 4, 74 + +ItemsData.isTm = function(id) return id == TM06 end +Pokemon.moveFromTmItem = function(id) return id == TM06 and TOXIC or nil end +Pokemon.canLearnTmItem = function(species) return species ~= CHARMANDER end + +local PartyMenu = require("src.ui.game3.party_menu") + +local failed = 0 +local function check(cond, msg) + if cond then + print("[ok] " .. msg) + else + failed = failed + 1 + print("[FAIL] " .. msg) + end +end + +local function mon(species, moves) + return { species = species, speciesId = species, level = 10, hp = 20, maxHp = 20, + moves = moves or { 33 }, pp = { 35 } } +end + +local party = { + mon(BULBASAUR), + mon(CHARMANDER), + mon(GEODUDE, { 33, TOXIC }), + { species = BULBASAUR, speciesId = BULBASAUR, isEgg = true, hp = 0, maxHp = 0, moves = {} }, +} +PartyMenu.show(party, nil, { mode = "use", item = TM06 }) +PartyMenu._item = TM06 +PartyMenu.mode = "use" + +check(PartyMenu.slotDescription(1) == "ABLE!", + "a compatible mon reads ABLE!, got " .. tostring(PartyMenu.slotDescription(1))) +check(PartyMenu.slotDescription(2) == "NOT ABLE!", + "an incompatible mon reads NOT ABLE!, got " .. tostring(PartyMenu.slotDescription(2))) +check(PartyMenu.slotDescription(3) == "LEARNED", + "a mon that knows the move reads LEARNED, got " .. tostring(PartyMenu.slotDescription(3))) +check(PartyMenu.slotDescription(4) == "NOT ABLE!", + "an egg reads NOT ABLE!, got " .. tostring(PartyMenu.slotDescription(4))) + +PartyMenu._item = 13 -- a Potion: no label +check(PartyMenu.slotDescription(1) == nil, "a non-TM item shows no label") + +if failed > 0 then + print(string.format("[FAIL] %d check(s) failed", failed)) + os.exit(1) +end +print("[PASS] game3_tm_able_desc_2520")