Files
gen1recomp/tests/game3_trade_rules_test.lua
2026-09-23 06:00:44 -04:00

359 lines
16 KiB
Lua

#!/usr/bin/env luajit
-- pokefirered/src/trade_scene.c:1235 TradeBufferOTnameAndNicknames, link branch
package.path = "./?.lua;./?/init.lua;" .. package.path
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 eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local function finish()
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")
os.exit(0)
end
package.loaded["src.core.game3.audio"] = setmetatable({}, {
__index = function() return function() end end,
})
local store = { flags = {}, vars = {} }
local session = {
store = store, map = "FR_ROUTE2_HOUSE", party = {},
name = "RED", trainerId = 4242, gender = "male", vars = {}, flags = {},
dex = { seen = {}, owned = {} },
}
package.loaded["src.core.game3.runtime"] = {
getSession = function() return session end,
isActive = function() return true end,
}
local Cache = require("tests.game3_cache")
local cacheRoot = Cache.mount("meta.json")
if not cacheRoot then
print("[skip] no current FireRed cache: " .. tostring(Cache.reason))
os.exit(0)
end
local TradeScene = require("src.core.game3.trade_scene")
local Trade = require("src.core.game3.scripting.natives_trade")
local Pokemon = require("src.core.game3.pokemon")
local Party = require("src.core.game3.party")
local Mail = require("src.core.game3.mail")
local ART = { gba = true }
local function run(offer, received, opts, limit)
local order, counts, texts = {}, {}, {}
local frames = 0
TradeScene.play(offer, received, nil, opts)
local seen = TradeScene.phase()
order[#order + 1] = seen
counts[seen] = 0
while frames < (limit or 20000) do
frames = frames + 1
local st = TradeScene.state()
local over = TradeScene.step()
counts[seen] = (counts[seen] or 0) + 1
if st and st.text and st.text ~= "" then texts[seen] = st.text end
if over then break end
local now = TradeScene.phase()
if now ~= seen then
seen = now
order[#order + 1] = seen
counts[seen] = 0
end
end
return order, counts, frames, texts
end
local OFFER = { species = 63, nickname = "ABRA", level = 12, otName = "RED", otId = 4242 }
local RECEIVED = { species = 122, nickname = "MIMIEN", otName = "TRIS", otId = 31337, level = 12 }
local PEER = { name = "TRIS", id = 31337 }
print("[test] 1. the link arm runs pret's link branches")
local swapAt, evolveAt
local order, counts, total, texts = run(OFFER, RECEIVED, {
art = ART, peer = PEER,
onSwap = function() swapAt = TradeScene.phase() end,
onEvolve = function() evolveAt = TradeScene.phase() end,
})
local at = {}
for i, name in ipairs(order) do at[name] = i end
-- pokefirered/src/trade_scene.c:1770 STATE_END_LINK_TRADE returns TRUE for a link trade
local LINK_TAIL = {
"check_ribbons", "end_link_trade", "link_wait_peer", "try_evolution", "wait_evolution",
"link_standby", "link_save", "link_save_delay", "link_close_delay",
"fade_out_end", "wait_fade_out_end",
}
local base = at.check_ribbons
check(base ~= nil, "the link cinema reached check_ribbons")
for i, name in ipairs(LINK_TAIL) do
eq(order[(base or 0) + i - 1], name, "link phase " .. i .. " of the tail is " .. name)
end
eq(order[#order], "wait_fade_out_end", "the link cinema ends on wait_fade_out_end")
-- pokefirered/src/trade_scene.c:2533 CB2_UpdateLinkTrade calls TradeMons at the return
eq(swapAt, "end_link_trade", "a link trade swaps the party at end_link_trade")
-- pokefirered/src/trade_scene.c:2322 CB2_TryLinkTradeEvolution
eq(evolveAt, "try_evolution", "the trade evolution still runs at try_evolution")
-- pokefirered/src/trade_scene.c:2649 case 40 then case 41
eq(counts.link_save_delay, 52, "link_save_delay holds pret's 52 frames")
-- pokefirered/src/trade_scene.c:2698 case 5
eq(counts.link_close_delay, 61, "link_close_delay holds pret's 61 frames")
-- pokefirered/src/trade_scene.c:1238 StringCopy(gStringVar1, gLinkPlayers[mpId ^ 1].name)
check(texts.send_msg and texts.send_msg:find("TRIS", 1, true) ~= nil,
"the send-off names the peer: " .. tostring(texts.send_msg))
check(texts.new_mon_msg and texts.new_mon_msg:find("TRIS", 1, true) ~= nil,
"the reveal names the peer: " .. tostring(texts.new_mon_msg))
-- pokefirered/src/trade_scene.c:2572 gText_CommunicationStandby5
check(texts.link_standby and texts.link_standby:find("standby", 1, true) ~= nil,
"the standby window is drawn: " .. tostring(texts.link_standby))
-- pokefirered/src/trade_scene.c:2594 gText_SavingDontTurnOffThePower2
check(texts.link_save and texts.link_save:find("SAVING", 1, true) ~= nil,
"the saving window is drawn: " .. tostring(texts.link_save))
check(total > 0, "the link cinema ran " .. total .. " frames")
print("[test] 2. the link gates park the scene until the transport answers")
TradeScene.play(OFFER, RECEIVED, nil, { art = ART, peer = PEER, awaitPeer = true, awaitSave = true })
local parked = 0
while parked < 20000 do
parked = parked + 1
if TradeScene.step() then break end
local ph = TradeScene.phase()
if ph == "link_wait_peer" and parked > 1 then break end
end
eq(TradeScene.phase(), "link_wait_peer", "the scene waits for the peer's finish confirmation")
check(TradeScene.isLink(), "the scene reports itself as a link trade")
TradeScene.peerConfirmed()
local guard = 0
while guard < 20000 do
guard = guard + 1
if TradeScene.step() then break end
if TradeScene.phase() == "link_standby" and guard > 1 then break end
end
eq(TradeScene.phase(), "link_standby", "the scene waits on the link task at link_standby")
TradeScene.linkTaskDone()
guard = 0
while guard < 20000 do
guard = guard + 1
if TradeScene.step() then break end
if TradeScene.phase() == "link_save" and guard > 1 then break end
end
eq(TradeScene.phase(), "link_save", "the scene waits on the save at link_save")
TradeScene.saveDone()
guard = 0
while guard < 20000 do
guard = guard + 1
if TradeScene.step() then break end
end
check(not TradeScene.isOpen(), "the scene finished once the transport answered, frames=" .. guard)
print("[test] 3. the in-game arm keeps pret's own ordering")
swapAt, evolveAt = nil, nil
local igOrder = run(OFFER, RECEIVED, {
art = ART,
onSwap = function() swapAt = TradeScene.phase() end,
onEvolve = function() evolveAt = TradeScene.phase() end,
})
local igAt = {}
for i, name in ipairs(igOrder) do igAt[name] = i end
-- pokefirered/src/trade_scene.c:1776 TradeMons runs in STATE_TRY_EVOLUTION for an in-game trade
eq(swapAt, "try_evolution", "an in-game trade swaps the party at try_evolution")
eq(evolveAt, "try_evolution", "and evolves in the same state")
for _, name in ipairs({
"link_wait_peer", "link_standby", "link_save", "link_save_delay", "link_close_delay",
}) do
eq(igAt[name], nil, "the in-game arm has no " .. name)
end
-- pokefirered/src/trade.c:1293 CB_FadeToStartTrade
local fadeOrder, fadeCounts = run(OFFER, RECEIVED, { art = ART, peer = PEER, fadeIn = true })
eq(fadeOrder[1], "fade_to_black", "a link entry can lead in with pret's fade to black")
eq(fadeOrder[2], "wait_fade_to_black", "and waits for it")
eq(fadeCounts.wait_fade_to_black, 16, "the lead-in fade is pret's 16 frames")
eq(fadeOrder[3], "start", "then the cinema starts")
eq(order[1], "start", "a caller that faded for itself gets no lead-in")
print("[test] 4. OT identity, the outsider rule and the met stamps")
-- pokefirered/src/pokemon.c:5974 IsOtherTrainer
check(not Pokemon.isOtherTrainer(4242, "RED", session), "the player's own mon is not an outsider")
check(Pokemon.isOtherTrainer(1985, "RED", session), "a different trainer id is an outsider")
check(Pokemon.isOtherTrainer(4242, "BLUE", session), "the same id with another name is an outsider")
-- pokefirered/src/pokemon.c:5985
check(not Pokemon.isOtherTrainer(4242, "RED", { trainerId = 4242, name = "REDX" }),
"pret stops at the OT name terminator, so a prefix still counts as the owner")
check(Pokemon.isOtherTrainer(4242, "REDX", { trainerId = 4242, name = "RED" }),
"a longer OT name than the player's is an outsider")
check(not Pokemon.isTradedMon({ species = 25 }, session), "a mon with no OT id is not traded")
check(Pokemon.isTradedMon({ species = 122, otId = 1985, otName = "REYLEY" }, session),
"a mon from REYLEY is a traded mon")
session.party = {}
local ok1 = Party.giveMon(session, 25, 9, "PIKA")
check(ok1, "the player's own mon was created")
local mine = session.party[1]
-- pokefirered/src/pokemon.c:1819
eq(mine.metGame, Party.VERSION_FIRE_RED, "a mon created on this cart is stamped FIRE RED")
-- pokefirered/src/pokemon.c:1822
eq(mine.otGender, 0, "a male player's mon is stamped OT gender 0")
eq(mine.metLevel, 9, "the met level is the level it was created at")
check(not Pokemon.isTradedMon(mine, session), "the player's own new mon obeys")
local girl = { party = {}, name = "LEAF", trainerId = 7, gender = "female" }
Party.giveMon(girl, 25, 5)
eq(girl.party[1].otGender, 1, "a female player's mon is stamped OT gender 1")
if not cacheRoot then
print("[skip] the in-game trade mon needs an imported cache")
else
-- pokefirered/src/trade_scene.c:2461 METLOC_IN_GAME_TRADE
session.party = { { species = 63, nickname = "ABRA", level = 17, otName = "RED", otId = 4242 } }
local traded = Trade.createTradeMon(0, Trade.levelOfSlot(0))
check(traded ~= nil, "the in-game trade mon was built")
if traded then
eq(traded.otName, "REYLEY", "the received mon keeps REYLEY as its OT name")
eq(traded.otId, 1985, "the received mon keeps REYLEY's trainer id")
eq(traded.otGender, 0, "the received mon keeps REYLEY's OT gender")
eq(traded.metLocation, 0xFE, "the received mon is stamped METLOC_IN_GAME_TRADE")
eq(traded.metLevel, 17, "the met level is the level the player's mon was at")
check(Pokemon.isTradedMon(traded, session), "the received mon is an outsider that can disobey")
local sent = Trade.tradeMons(session, 0, traded)
check(sent ~= nil, "the swap ran")
eq(session.party[1], traded, "the received mon took the slot")
eq(session.party[1].otName, "REYLEY", "and still carries REYLEY after the swap")
eq(session.party[1].otId, 1985, "and still carries REYLEY's id after the swap")
-- pokefirered/src/trade_scene.c:1075
eq(session.party[1].friendship, 70, "a traded mon starts back at friendship 70")
end
end
print("[test] 5. the traded-mon experience bonus")
if not cacheRoot then
print("[skip] the exp bonus needs an imported cache")
else
local Experience = require("src.core.game3.battle.experience")
local plain = Experience.gainFor(19, 10, { participants = 1 })
local boosted = Experience.gainFor(19, 10, { participants = 1, traded = true })
check(plain > 0, "the plain award is " .. plain)
-- pokefirered/src/battle_script_commands.c:3239
eq(boosted, math.floor(plain * 150 / 100), "a traded mon gets pret's 1.5x once")
local both = Experience.gainFor(19, 10, { participants = 1, traded = true, luckyEgg = true })
eq(both, math.floor(math.floor(plain * 150 / 100) * 150 / 100),
"the Lucky Egg and the trade bonus stack the way pret floors them")
local st = { playerTrainerId = 4242, playerOtName = "RED" }
local opts = Experience.recipientOpts(st, { species = 122, otId = 1985, otName = "REYLEY" })
check(opts.traded == true, "a traded mon is flagged for the bonus by the battle layer")
local ownOpts = Experience.recipientOpts(st, { species = 25, otId = 4242, otName = "RED" })
check(ownOpts.traded ~= true, "the player's own mon is not flagged")
end
print("[test] 6. mail rides across opaquely")
Trade.clearPartnerMail()
local letter = Mail.clear(nil)
letter.playerName = "DONTAE"
letter.trainerId = 36728
letter.species = 124
letter.itemId = 131
letter.words[1] = 3613
check(Trade.setPartnerMail(0, letter), "the transport handed the peer's letter over")
session.party = { { species = 63, nickname = "ABRA", level = 12 } }
session.mail = nil
local arriving = {
species = 124, nickname = "ZYNX", level = 12, otName = "DONTAE", otId = 36728,
item = 131, heldItem = 131, mail = 0,
}
-- pokefirered/src/trade_scene.c:1078
Trade.tradeMons(session, 0, arriving)
local mailId = tonumber(session.party[1].mail)
check(mailId ~= nil and mailId ~= Mail.MAIL_NONE, "the received mon still holds mail, id=" .. tostring(mailId))
local stored = mailId and Mail.slot(session, mailId)
eq(stored and stored.playerName, "DONTAE", "the letter kept its author")
eq(stored and stored.words[1], 3613, "the letter kept its easy-chat words")
print("[test] 7. the refusals pret prints before the scene starts")
-- pokefirered/src/trade.c:2745 CanTradeSelectedMon
local party = {
{ species = 63, nickname = "ABRA", level = 12 },
{ species = 25, nickname = "PIKA", level = 9 },
}
eq(Trade.canTradeSelectedMon(party, 0, { nationalDex = true }), Trade.CAN_TRADE_MON,
"two mons, either may go")
eq(Trade.canTradeSelectedMon({ party[1] }, 0, { nationalDex = true }), Trade.CANT_TRADE_LAST_MON,
"the last mon cannot be traded")
-- pokefirered/src/trade.c:2801 eggs do not count towards the mons left behind
eq(Trade.canTradeSelectedMon({
party[1], { species = 25, isEgg = true, level = 5 },
}, 0, { nationalDex = true }), Trade.CANT_TRADE_LAST_MON,
"an egg does not count as the mon you leave behind")
-- pokefirered/src/trade.c:2769 the retail build answers CANT_TRADE_NATIONAL for an egg
eq(Trade.canTradeSelectedMon({
{ species = 25, isEgg = true, level = 5 }, party[2],
}, 0, { nationalDex = false }), Trade.CANT_TRADE_NATIONAL,
"without the National Dex an egg answers the cart's national refusal")
eq(Trade.canTradeSelectedMon({
{ species = 252, nickname = "TREECKO", level = 5 }, party[2],
}, 0, { nationalDex = false }), Trade.CANT_TRADE_NATIONAL,
"without the National Dex a non-Kanto mon cannot be traded")
-- pokefirered/src/trade.c:2787-2788
eq(Trade.canTradeSelectedMon({
{ species = 25, isEgg = true, level = 5 }, party[2],
}, 0, { nationalDex = true, partner = { version = 4, progressFlags = 0 } }),
Trade.CANT_TRADE_PARTNER_EGG_YET,
"a partner without the National Dex refuses an egg")
eq(Trade.canTradeSelectedMon({
{ species = 252, nickname = "TREECKO", level = 5 }, party[2],
}, 0, { nationalDex = true, partner = { version = 4, progressFlags = 0 } }),
Trade.CANT_TRADE_INVALID_MON,
"a partner without the National Dex refuses a non-Kanto mon")
eq(Trade.canTradeSelectedMon({
{ species = 252, nickname = "TREECKO", level = 5 }, party[2],
}, 0, { nationalDex = true, partner = { version = 2, progressFlags = 0 } }),
Trade.CAN_TRADE_MON,
"a Ruby partner is not asked for its National Dex")
-- pokefirered/src/trade.c:2795
eq(Trade.canTradeSelectedMon({
{ species = 410, nickname = "DEOXYS", level = 30, fatefulEncounter = false }, party[2],
}, 0, { nationalDex = true }), Trade.CANT_TRADE_INVALID_MON,
"an illegal DEOXYS cannot be traded")
eq(Trade.canTradeSelectedMon({
{ species = 151, nickname = "MEW", level = 30, fatefulEncounter = false }, party[2],
}, 0, { nationalDex = true }), Trade.CANT_TRADE_INVALID_MON,
"an illegal MEW cannot be traded")
eq(Trade.canTradeSelectedMon({
{ species = 151, nickname = "MEW", level = 30 }, party[2],
}, 0, { nationalDex = true }), Trade.CAN_TRADE_MON,
"a legitimate MEW may be traded")
-- pokefirered/src/field_specials.c:2458 IsBadEggInParty
check(not Trade.hasBadEgg(party), "a clean party has no bad egg")
check(Trade.hasBadEgg({ party[1], { species = 0, isBadEgg = true } }),
"a bad egg in the party is found")
check(Trade.refusalText(Trade.CANT_TRADE_LAST_MON):find("only", 1, true) ~= nil,
"the last-mon refusal is pret's text: " .. Trade.refusalText(Trade.CANT_TRADE_LAST_MON))
check(Trade.refusalText(Trade.CANT_TRADE_EGG_YET):find("EGG", 1, true) ~= nil,
"the egg refusal is pret's text: " .. Trade.refusalText(Trade.CANT_TRADE_EGG_YET))
check(Trade.refusalText(Trade.CANT_TRADE_NATIONAL):find("can't be traded", 1, true) ~= nil,
"the national refusal is pret's text: " .. Trade.refusalText(Trade.CANT_TRADE_NATIONAL))
eq(Trade.refusalText(Trade.CAN_TRADE_MON), nil, "a tradeable mon has no refusal text")
check(Trade.badEggText():find("can't be taken", 1, true) ~= nil,
"the bad-egg abort is pret's cable club text: " .. Trade.badEggText())
finish()