Files
gen1recomp/tests/online_trade_remote3_test.lua
2026-09-24 05:17:32 -04:00

365 lines
13 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
if not _G.love then _G.love = require("tests.love_stub") end
local Cache = require("tests.game3_cache")
if not Cache.mount("meta.json") then
print("[skip] online_trade_remote3: " .. tostring(Cache.reason))
os.exit(0)
end
local GameVersion = require("src.core.GameVersion")
GameVersion.set("firered")
local SaveData = require("src.core.SaveData")
local Trade = require("src.online.Trade")
local Pokemon = require("src.core.game3.pokemon")
local Party = require("src.core.game3.party")
local Schema = require("src.core.game3.save_schema_firered")
local Relay = require("tests.online_trade_fakerelay")
Pokemon.install(nil)
T.check(Pokemon._names ~= nil, "FireRed species tables load from the cache")
local files = {}
local fs = {
getInfo = function(name) return files[name] and { type = "file" } or nil end,
read = function(name) return files[name] end,
write = function(name, body) files[name] = body return true end,
remove = function(name) files[name] = nil return true end,
createDirectory = function() return true end,
}
SaveData.portableFs = function() return fs end
local DATA = { generation = 3, Pokemon = Pokemon, pokemon = Pokemon }
local function makeSave(version, name, trainerId, mons)
local session = Schema.newGame({ version = version, name = name, rngSeed = trainerId })
session.trainerId = trainerId
session.dex.nationalUnlocked = true
session.party = {}
for _, row in ipairs(mons) do
assert(Party.giveMon(session, row.species, row.level, row.nickname))
local mon = session.party[#session.party]
for k, v in pairs(row.extra or {}) do mon[k] = v end
end
return SaveData.decode(SaveData.encode(Schema.toSaveTable(session)))
end
local function handle(version, slotId, save)
local path = Trade.slotPath(version, slotId)
files[path] = SaveData.encode(save)
return {
version = version, generation = 3, slotId = slotId, save = save,
path = path, party = save.party, data = DATA,
}
end
local function onDisk(h)
return SaveData.decode(files[h.path] or "")
end
local function pair(opts)
opts = opts or {}
local fr = makeSave("firered", "RED", 11111, {
{ species = 64, level = 30, nickname = "ABRA CAD" },
{ species = 25, level = 12 },
})
local lg = makeSave("leafgreen", "LEAF", 22222, {
{ species = 95, level = 25, extra = { item = 199, heldItem = 199 } },
{ species = 1, level = 8 },
})
local relay = Relay.new({ tamper = opts.tamper, rewrite = opts.rewrite })
local rsA, rsB = relay:join(0, "RED"), relay:join(1, "LEAF")
local hA = handle("firered", opts.slot or "slot1", fr)
local hB = handle("leafgreen", opts.slot or "slot1", lg)
local A = assert(Trade.remote(hA, rsA, { transport = relay:transport(rsA), peerName = "LEAF" }))
local B = assert(Trade.remote(hB, rsB, { transport = relay:transport(rsB), peerName = "RED" }))
return { relay = relay, rsA = rsA, rsB = rsB, A = A, B = B, hA = hA, hB = hB,
diskA = files[hA.path], diskB = files[hB.path] }
end
local function pump(p, n)
for _ = 1, n or 1 do
p.A:update()
p.B:update()
end
end
local function toConfirming(p)
p.A:start()
p.B:start()
pump(p, 3)
T.eq(p.A:stage(), "picking", "seat 0 reaches the trade menu")
T.eq(p.B:stage(), "picking", "seat 1 reaches the trade menu")
T.eq(#p.A.session.theirParty, 2, "each side unpacked the other's party")
T.eq(p.A.session.theirParty[1].species, 95, "as real mons")
T.check(p.A:canPick(1), "a legit pick is allowed")
T.check(p.A:pick(1), "seat 0 offers its first mon")
T.check(p.B:pick(1), "seat 1 offers its first mon")
pump(p, 2)
T.eq(p.A:stage(), "confirming", "the leader set the mons to trade")
T.eq(p.B:stage(), "confirming", "the follower heard it")
T.eq(p.A.session.theirPick, 1, "seat 0 knows what it gets")
T.eq(p.B.session.theirPick, 1, "seat 1 knows what it gets")
end
do
local p = pair()
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
T.eq(p.B:stage(), "commit_wait", "seat 1 sent its digest and waits")
T.eq(p.A:stage(), "exchange", "seat 0 is still exchanging")
p.A:update()
T.eq(p.A:stage(), "commit_wait", "seat 0 sent its digest")
T.eq(p.relay.commits, 1, "the relay committed once")
T.eq(files[p.hA.path], p.diskA, "nothing is written before trade_commit reaches seat 0")
T.eq(files[p.hB.path], p.diskB, "nor seat 1")
pump(p, 2)
T.eq(p.A:stage(), "committed", "seat 0 committed")
T.eq(p.B:stage(), "committed", "seat 1 committed")
T.eq(p.A:commit(), true, "commit reports the cached result")
local frNow, lgNow = onDisk(p.hA), onDisk(p.hB)
T.eq(frNow and frNow.party[1].species, 208, "FireRed's file holds the Onix, now Steelix")
T.eq(frNow and frNow.party[1].item, 0, "the Metal Coat was used up")
T.eq(lgNow and lgNow.party[1].species, 65, "LeafGreen's file holds the Kadabra, now Alakazam")
T.eq(lgNow and lgNow.party[1].nickname, "ABRA CAD", "keeping its nickname")
T.eq(lgNow and lgNow.party[1].otName, "RED", "and its OT")
T.eq(frNow and frNow.party[2].species, 25, "untraded mons stay")
T.check(pcall(Schema.fromSaveTable, frNow), "the FireRed file loads back")
T.check(pcall(Schema.fromSaveTable, lgNow), "the LeafGreen file loads back")
pump(p, 1)
T.check(p.A.peerFinished and p.B.peerFinished, "both sent CONFIRM_FINISH_TRADE after committing")
p.A:close()
p.B:close()
T.check(p.rsA.left and p.rsB.left, "closing leaves the room")
end
do
local p = pair({ slot = "slot2" })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
T.eq(p.B:stage(), "commit_wait", "seat 1 confirmed its digest")
p.rsA.close()
pump(p, 2)
T.eq(p.relay.aborts, 1, "the relay aborts when seat 0 leaves before confirming")
T.eq(p.B:stage(), "cancelled", "seat 1 calls the trade off")
T.eq(p.B.session.error, "left", "because the other trainer left")
T.eq(p.A:stage(), "cancelled", "and seat 0 is out too")
T.eq(files[p.hA.path], p.diskA, "a drop before commit writes nothing on seat 0")
T.eq(files[p.hB.path], p.diskB, "or on seat 1")
end
do
local p = pair({ slot = "slot3" })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
p.rsB.online = false
p.A:update()
T.eq(p.relay.commits, 1, "both digests reached the relay")
p.A:update()
T.eq(p.A:stage(), "committed", "seat 0 commits while seat 1 is dropped")
for _ = 1, 3 do p.B:update() end
T.eq(p.B:stage(), "commit_wait", "a reconnecting seat 1 keeps waiting")
T.eq(files[p.hB.path], p.diskB, "and has written nothing yet")
p.rsB.online = true
p.B:update()
T.eq(p.B:stage(), "committed", "the replayed commit lands after the reconnect")
T.eq(onDisk(p.hA).party[1].species, 208, "seat 0's file is written")
T.eq(onDisk(p.hB).party[1].species, 65, "and seat 1's")
end
do
local p = pair({ slot = "slot4" })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
p.A:update()
p.rsA.close()
p.B:update()
T.eq(p.B:stage(), "committed", "a commit already sent survives the other side leaving")
T.eq(onDisk(p.hB).party[1].species, 65, "so seat 1 is written")
p.A:update()
T.eq(p.A:stage(), "committed", "and seat 0, who left after the relay committed, writes too")
T.eq(onDisk(p.hA).party[1].species, 208, "both files are written")
end
do
local p = pair({ slot = "slot5", tamper = function(seat, digest)
if seat == 1 then return ("0"):rep(16) end
return digest
end })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 3)
T.eq(p.relay.aborts, 1, "mismatched digests abort")
T.eq(p.A:stage(), "cancelled", "seat 0 cancels")
T.eq(p.A.session.error, "digest", "naming the digest")
T.eq(p.B:stage(), "cancelled", "seat 1 cancels")
T.eq(files[p.hA.path], p.diskA, "nothing written on seat 0")
T.eq(files[p.hB.path], p.diskB, "nothing written on seat 1")
end
do
local p = pair({ slot = "slot6" })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(false)
pump(p, 2)
T.eq(p.A:stage(), "picking", "a no at the confirm returns both to the menu")
T.eq(p.B:stage(), "picking", "on both sides")
T.eq(p.A.session.myPick, nil, "with the picks cleared")
T.eq(files[p.hA.path], p.diskA, "and nothing written")
p.relay:_relayed({ type = "trade_abort", n = 9, why = "timeout" })
pump(p, 1)
T.eq(p.A:stage(), "picking", "a stale abort from an older barrier is ignored")
end
do
local p = pair({ slot = "slot7" })
p.A:start()
p.B:start()
pump(p, 3)
p.relay:deliver(1, { type = "game3_trade_party", party = { { species = 25, level = 5 } },
name = "EVIL", trainerId = 1, gender = 0, version = 4, progressFlags = 0 })
p.A:update()
T.eq(p.A:stage(), "cancelled", "a hostile party is refused whole")
T.check(p.A.session.error ~= nil, "with a reason: " .. tostring(p.A.session.error))
T.eq(files[p.hA.path], p.diskA, "and nothing written")
p.B:update()
T.eq(p.B:stage(), "cancelled", "the peer is told BOTH_CANCEL_TRADE")
T.eq(p.B.session.error, "both_canceled", "and exits too")
end
local Protocol = require("src.link.Protocol")
local function repick(p, a, b)
T.check(p.A:pick(a), "seat 0 picks again")
T.check(p.B:pick(b), "seat 1 picks again")
pump(p, 2)
T.eq(p.A:stage(), "confirming", "the second pick reaches the confirm")
p.A:confirm(true)
p.B:confirm(true)
pump(p, 6)
end
do
local swap = true
local p
p = pair({ slot = "slot8", rewrite = function(from, msg)
if swap and from == 1 and msg.type == "game3_trade_mon" then
local out = {}
for k, v in pairs(msg) do out[k] = v end
out.mon = Protocol.packMon3(p.hB.party[2])
return out
end
end })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
T.eq(p.B:stage(), "commit_wait", "seat 1 confirmed round 1 with the mon it really sent")
pump(p, 2)
T.eq(p.A.lastRefusal, "not the POKéMON that was shown",
"a block that isn't the previewed mon is refused (trade.c:1951 runs on what was shown)")
T.eq(p.A:stage(), "picking", "seat 0 goes back to the menu")
T.eq(p.B:stage(), "picking", "seat 1 withdraws from commit_wait")
T.eq(p.relay.commits, 0, "the relay never committed")
T.eq(files[p.hA.path], p.diskA, "and nothing is written")
swap = false
repick(p, 2, 1)
T.check(p.relay.dropped >= 1, "the withdrawn side's confirm hit the still-open round")
T.eq(p.relay.aborts, 1, "which settles as one stale abort")
T.eq(p.A:stage(), "committed", "the stale round's abort doesn't cancel the second trade")
T.eq(p.B:stage(), "committed", "on either side")
T.eq(p.relay.commits, 1, "the relay commits the next round")
T.eq(onDisk(p.hB).party[1].species, 25, "seat 1 holds the Pikachu it was shown")
T.eq(onDisk(p.hA).party[2].species, 208, "seat 0 holds the Onix, now Steelix")
end
do
local p = pair({ slot = "slot9" })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
p.B.phase, p.B._staleRound = "commit_wait", 1
p.B.digest = p.B.digest or ("ab"):rep(8)
p.relay:_relayed({ type = "trade_abort", n = 2, why = "digest" })
p.B:update()
T.eq(p.B:stage(), "cancelled", "an abort past the stale round still cancels")
end
do
local p = pair({ slot = "slot10" })
toConfirming(p)
local realPrepare = Trade.prepare
Trade.prepare = function(plan)
if plan.sides[1].handle == p.hB then return false, "that save didn't validate" end
return realPrepare(plan)
end
p.A:confirm(true)
p.B:confirm(true)
pump(p, 4)
Trade.prepare = realPrepare
T.eq(p.B.lastRefusal, "that save didn't validate", "a save that won't build is caught before the confirm")
T.eq(p.B:stage(), "picking", "seat 1 backs out to the menu")
T.eq(p.A:stage(), "picking", "and seat 0 with it")
T.eq(p.relay.commits, 0, "so the relay never commits")
T.eq(files[p.hA.path], p.diskA, "and seat 0 writes nothing")
T.eq(files[p.hB.path], p.diskB, "nor seat 1")
end
do
local LT = require("src.core.game3.link.trade")
local p = pair({ slot = "slot11" })
toConfirming(p)
p.A:confirm(true)
p.B:confirm(true)
pump(p, 1)
T.eq(p.B:stage(), "commit_wait", "seat 1 confirmed")
local jPath = Trade.journalPath(p.hB.path)
local j = files[jPath] and SaveData.decode(files[jPath]) or nil
local e = j and j.entries and j.entries[#j.entries] or {}
T.eq(e.room, p.relay.room, "its journal names the relay room")
T.eq(e.digest, p.B.digest, "and the confirmed digest")
T.eq(e.sent and e.sent.personality, p.hB.party[1].personality,
"it holds the packed mon sent, as the in-game resolver reads it")
T.eq(e.mon and e.mon.species, 64, "and the packed mon received")
T.eq(e.name, "RED", "with the partner's name for the quest log")
p.rsB.online = false
p.A:update()
p.A:update()
T.eq(p.A:stage(), "committed", "seat 0 commits")
p.B:close()
T.eq(onDisk(p.hB).party[1].species, 95, "seat 1 lost the link before trade_commit")
local realWith = Trade.withDataset
Trade.withDataset = function(_, fn)
local ok, result = pcall(fn, DATA)
if not ok then return nil, tostring(result) end
return result
end
LT.outcomeClient = p.relay:outcomeClient()
local done = false
for _ = 1, 60 do
if Trade.pumpPending(1000) == true then done = true break end
end
LT.outcomeClient = nil
Trade.withDataset = realWith
T.check(done, "the next launcher start settles the journal")
local lgNow = onDisk(p.hB)
T.eq(lgNow and lgNow.party[1].species, 65, "the Kadabra arrives, now Alakazam")
T.eq(lgNow and lgNow.party[1].otName, "RED", "keeping its OT")
T.check(pcall(Schema.fromSaveTable, lgNow), "the LeafGreen file loads back")
T.eq(files[jPath], nil, "and the journal is gone")
end
T.finish()