#!/usr/bin/env luajit -- Game3: in-battle item use must read the LIVE battle party, not the stale -- session.party snapshot. -- -- session.party is only written back once the battle ends, so reading it -- mid-battle shows pre-battle HP: a Potion was refused with "It won't have any -- effect." on a Pokémon the battle had already hurt (issue #2316). The battle -- fights a deep copy held in st.playerParty; that copy is the source of truth -- while the battle is running. package.path = "./?.lua;./?/init.lua;" .. package.path require("tests.game3_cache").requireData("game3_battle_item_party_test") local failed = 0 local function check(cond, msg) if cond then print("[ok] " .. msg) else failed = failed + 1 print("[FAIL] " .. msg) end end local Bag = require("src.core.game3.bag") local ItemsData = require("src.core.game3.items_data") local Pokemon = require("src.core.game3.pokemon") local Battle = require("src.core.game3.battle") local State = require("src.core.game3.battle.state") local PartyView = require("src.core.game3.battle.party_view") ItemsData.install(nil) Pokemon.install(nil) local POTION = 13 local REVIVE = 24 local ORAN_BERRY = 139 local BERRY_POUCH_ITEM = 365 local fakeInputA = { wasPressed = function(self, k) local key = (k ~= nil) and k or self return key == "a" end, } local fakeInputB = { wasPressed = function(self, k) local key = (k ~= nil) and k or self return key == "b" end, } local function mon(species, name, hp, maxHp) return { species = species, name = name, level = 10, hp = hp, maxHp = maxHp, moves = { 33 }, pp = { 35 }, } end -- session.party is the frozen pre-battle snapshot; liveParty is the working -- copy the battle fights with. They deliberately disagree -- reading the wrong -- one is the bug under test. local function make_session() local s = { name = "RED", bag = Bag.new(), dex = { seen = {}, owned = {} }, party = { mon(1, "BULBASAUR", 20, 20), -- frozen: full mon(4, "CHARMANDER", 20, 20), -- frozen: alive mon(7, "SQUIRTLE", 20, 20), -- frozen: full }, } s.liveParty = { mon(1, "BULBASAUR", 5, 20), -- live: hurt mon(4, "CHARMANDER", 0, 20), -- live: fainted mon(7, "SQUIRTLE", 20, 20), -- live: full } return s end local Runtime = { _session = nil } function Runtime.getSession() return Runtime._session end package.loaded["src.core.game3.runtime"] = Runtime local Ui = require("src.core.game3.battle.ui") local BagMenu = require("src.ui.game3.bag_menu") local PartyMenu = require("src.ui.game3.party_menu") local BerryPouch = require("src.ui.game3.berry_pouch") local function start_battle(s, activeSlot, extra) activeSlot = activeSlot or 1 Runtime._session = s Battle._active = true Battle._phase = "command" local st = { player = State.makeBattler(s.liveParty[activeSlot], "player", { partyIndex = activeSlot }), playerParty = s.liveParty, } for k, v in pairs(extra or {}) do st[k] = v end Battle._st = st Ui._session = s Ui._pendingCommand = nil Ui._mode = "menu" Ui._queue = {} Ui._showing = false Ui._linger = false Ui.bindState(st) return st end local function open_bag_from_battle() Ui._menuIndex = 2 -- BAG Ui.handleInput(fakeInputA) BagMenu.settle() end local function find_row(itemId) local rows = BagMenu.list(BagMenu.currentPocket()) for i, r in ipairs(rows) do if ItemsData.toNumericId(r.id) == itemId then return i end end return nil end -- Walk the bag's own list -> action menu -> USE, leaving the party menu open. local function choose_use(itemId) local i = find_row(itemId) if not i then return false, "item " .. tostring(itemId) .. " not in " .. tostring(BagMenu.currentPocket()) end BagMenu.cursor = i BagMenu.mode = "list" BagMenu.handleInput(fakeInputA) -- open the action menu BagMenu.actionCursor = 1 -- in battle USE is the first action BagMenu.handleInput(fakeInputA) -- USE BagMenu.settle() -- field USE opens the submenu via begin_exit return true end local function select_party_row(row) PartyMenu.cursor = row PartyMenu.handleInput(fakeInputA) -- pokefirered/src/party_menu.c:4538 Task_ClosePartyMenuAfterText for _ = 1, 8 do if not (PartyMenu.open and (PartyMenu._hpAnim or PartyMenu.mode == "message")) then break end PartyMenu.handleInput(fakeInputA) end -- BagMenu.battleUse reports back to the battle only after its exit -- transition finishes. BagMenu.settle() end ------------------------------------------------------------------------ print("[test] 1. PartyView.live falls back to session.party outside battle") local s = make_session() check(PartyView.live(s) == s.party, "no battle -> returns session.party itself") check(type(PartyView.live(nil)) == "table" and next(PartyView.live(nil)) == nil, "nil session -> empty table") ------------------------------------------------------------------------ print("[test] 2. Heal accepted when the snapshot is full but the battle party is hurt") s = make_session() Bag.add(s.bag, POTION, 2) local st = start_battle(s) check(st.playerParty == s.liveParty, "battle fights the live copy, not session.party") check(st.playerParty ~= s.party, "live copy is a different table from session.party") open_bag_from_battle() local ok, err = choose_use(POTION) check(ok, "Potion row reachable in battle bag (" .. tostring(err) .. ")") check(PartyMenu.open == true, "party menu opened for the Potion") select_party_row(1) -- live[1] is hurt (5/20) even though session.party[1] is full check(Ui._pendingCommand ~= nil, "Potion handed to the battle system") check(Ui._pendingCommand and Ui._pendingCommand.itemId == POTION, "pending command carries the Potion") check(Ui._pendingCommand and Ui._pendingCommand.partySlot == 1, "pending command targets party slot 1 (got " .. tostring(Ui._pendingCommand and Ui._pendingCommand.partySlot) .. ")") check(Bag.get(s.bag, POTION) == 1, "Potion spent in the party menu (party_menu.c:4502)") check(s.liveParty[1].hp > 5, "the live battle copy was healed in the party menu") check(Ui._pendingCommand and Ui._pendingCommand.usedInMenu == true, "pending command says the item was already used") ------------------------------------------------------------------------ print("[test] 3. Refusal still works when the battle party really is full") s = make_session() Bag.add(s.bag, POTION, 2) start_battle(s) open_bag_from_battle() choose_use(POTION) check(PartyMenu.open == true, "party menu opened") select_party_row(3) -- live[3] is 20/20 check(Ui._pendingCommand == nil, "no pending command for a full-HP target") check(PartyMenu.open == true, "party menu stays open after the refusal") check(Bag.get(s.bag, POTION) == 2, "Potion not consumed on a refused heal") ------------------------------------------------------------------------ print("[test] 4. battleOrder maps the tapped row to the right party slot") -- Active mon is party slot 2, so battleOrder returns { 2, 1, 3 }: row 1 is -- slot 2 and row 2 is slot 1. Mapping the slot twice (row 1 -> order[1] = 2, -- then order[2] = 1) would heal slot 1 instead of slot 2. s = make_session() s.liveParty = { mon(1, "BULBASAUR", 20, 20), -- slot 1: full -> a Potion must be refused mon(4, "CHARMANDER", 5, 20), -- slot 2: hurt -> a Potion must work mon(7, "SQUIRTLE", 20, 20), -- slot 3: full } Bag.add(s.bag, POTION, 2) start_battle(s, 2, { _partyOrder = { 2, 1, 3 } }) check(table.concat(PartyMenu.battleOrder(Battle._st), ",") == "2,1,3", "battleOrder is non-identity (2,1,3)") open_bag_from_battle() choose_use(POTION) select_party_row(1) -- row 1 == party slot 2, the hurt mon check(Ui._pendingCommand ~= nil, "row 1 accepted the Potion") check(Ui._pendingCommand and Ui._pendingCommand.partySlot == 2, "row 1 resolved to party slot 2, not slot 1 (got " .. tostring(Ui._pendingCommand and Ui._pendingCommand.partySlot) .. ")") ------------------------------------------------------------------------ print("[test] 5. Revive targets a mon fainted in battle but alive in the snapshot") s = make_session() Bag.add(s.bag, REVIVE, 2) start_battle(s) open_bag_from_battle() choose_use(REVIVE) check(PartyMenu.open == true, "party menu opened for the Revive") select_party_row(2) -- live[2] is fainted (0/20), session.party[2] is 20/20 check(Ui._pendingCommand ~= nil, "Revive accepted on the fainted battler") check(Ui._pendingCommand and Ui._pendingCommand.partySlot == 2, "Revive targets party slot 2") ------------------------------------------------------------------------ print("[test] 6. Berry Pouch hands a berry to the battle system too") s = make_session() Bag.add(s.bag, ORAN_BERRY, 2) Bag.add(s.bag, BERRY_POUCH_ITEM, 1) start_battle(s) open_bag_from_battle() BagMenu.pocketIdx = 2 -- KEY_ITEMS local pouchRow = find_row(BERRY_POUCH_ITEM) check(pouchRow ~= nil, "BERRY POUCH key item present") BagMenu.cursor = pouchRow BagMenu.mode = "list" BagMenu.handleInput(fakeInputA) -- action menu -> { OPEN, CANCEL } BagMenu.actionCursor = 1 BagMenu.handleInput(fakeInputA) -- OPEN BagMenu.settle() check(BerryPouch.isOpen() == true, "Berry Pouch opened from the battle bag") BerryPouch.cursor = 1 -- first berry row BerryPouch.handleInput(fakeInputA) BerryPouch.handleInput(fakeInputA) -- USE check(PartyMenu.open == true, "party menu opened from the Berry Pouch") select_party_row(1) -- live[1] is hurt (5/20), session.party[1] is full BagMenu.settle() check(Ui._pendingCommand ~= nil, "berry handed to the battle system") check(Ui._pendingCommand and Ui._pendingCommand.itemId == ORAN_BERRY, "pending command carries the Oran Berry (got " .. tostring(Ui._pendingCommand and Ui._pendingCommand.itemId) .. ")") check(Bag.get(s.bag, ORAN_BERRY) == 1, "berry spent in the party menu (party_menu.c:4502)") check(BerryPouch.isOpen() == false, "Berry Pouch closed after the hand-off") ------------------------------------------------------------------------ print("[test] 7. Field (non-battle) party item use still heals session.party") s = make_session() s.party[1].hp = 5 -- hurt in the real save, since there is no battle copy Runtime._session = s Battle._active = false Battle._st = nil Ui._session = s Ui._pendingCommand = nil Bag.add(s.bag, POTION, 2) BagMenu.show(s.bag, { session = s }) BagMenu.settle() check(BagMenu._battle == false, "bag is not in battle mode") local ok7, err7 = choose_use(POTION) check(ok7, "Potion row reachable in the field bag (" .. tostring(err7) .. ")") check(PartyMenu.open == true, "field party menu opened") select_party_row(1) check(s.party[1].hp > 5, "field Potion healed session.party (hp=" .. tostring(s.party[1].hp) .. ")") check(Ui._pendingCommand == nil, "field use does not queue a battle command") Battle._active = false Battle._st = nil ------------------------------------------------------------------------ if failed > 0 then print(string.format("\n%d FAILED", failed)) os.exit(1) end print("\nAll battle item party tests passed.") os.exit(0)