mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 13:33:27 -04:00
913 lines
39 KiB
Lua
913 lines
39 KiB
Lua
-- Link play tests: the loopback transport, the trade session state
|
|
-- machine (with a trade evolution), and a lockstep link battle driven
|
|
-- over the loopback. Runs headlessly under plain luajit:
|
|
-- luajit tests/run_link_tests.lua
|
|
-- Real networking uses lua-enet (bundled with LÖVE); when enet is
|
|
-- importable (inside LÖVE) an actual host/join pairing over UDP
|
|
-- localhost is exercised too, otherwise that section is skipped.
|
|
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
love = require("tests.love_stub")
|
|
math.randomseed(4242)
|
|
|
|
local failures = 0
|
|
local function check(cond, msg)
|
|
if cond then
|
|
print("ok " .. msg)
|
|
else
|
|
failures = failures + 1
|
|
print("FAIL " .. msg)
|
|
end
|
|
end
|
|
local function eq(got, want, msg)
|
|
check(got == want, ("%s (got %s, want %s)"):format(msg, tostring(got), tostring(want)))
|
|
end
|
|
|
|
local Data = require("src.core.Data")
|
|
Data:load()
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local Protocol = require("src.link.Protocol")
|
|
|
|
-- ---------------------------------------------------------------- json
|
|
local Json = require("src.link.Json")
|
|
local msg = { type = "party", n = 3, ok = true, list = { 1, 2, 3 },
|
|
name = "RED\"s" }
|
|
local rt = Json.decode(Json.encode(msg))
|
|
eq(rt.type, "party", "json round trip type")
|
|
eq(rt.n, 3, "json round trip number")
|
|
eq(#rt.list, 3, "json round trip array")
|
|
eq(rt.name, 'RED"s', "json round trip escaping")
|
|
|
|
-- ---------------------------------------------------------------- pack/unpack
|
|
local kadabra = Pokemon.new(Data, "KADABRA", 30)
|
|
local packed = Protocol.packMon(kadabra)
|
|
local unpacked = Protocol.unpackMon(Data, packed)
|
|
eq(unpacked.species, "KADABRA", "mon survives the wire")
|
|
eq(unpacked.level, 30, "level survives")
|
|
eq(unpacked.stats.hp, kadabra.stats.hp, "stats recomputed identically")
|
|
-- tampering is clamped
|
|
packed.level = 3000
|
|
packed.dvs.attack = 99
|
|
local clamped = Protocol.unpackMon(Data, packed)
|
|
eq(clamped.level, 100, "tampered level clamped")
|
|
eq(clamped.dvs.attack, 15, "tampered DV clamped")
|
|
|
|
-- OT identity survives the wire (#215): pokered's trade sends each mon's OT
|
|
-- ID (party_struct MON_OTID) and OT name (wPartyMonOT); the receiver keeps
|
|
-- them verbatim so a traded mon shows its original trainer, not the receiver.
|
|
local otMon = Pokemon.new(Data, "KADABRA", 30)
|
|
otMon.ot = "CULLEN"
|
|
otMon.otId = 16012
|
|
local otRt = Protocol.unpackMon(Data, Protocol.packMon(otMon))
|
|
eq(otRt.ot, "CULLEN", "OT name survives the wire")
|
|
eq(otRt.otId, 16012, "OT ID survives the wire")
|
|
-- a tampered OT ID is clamped into the 16-bit range like every other field
|
|
local otPack = Protocol.packMon(otMon)
|
|
otPack.otId = 999999
|
|
eq(Protocol.unpackMon(Data, otPack).otId, 65535, "over-range OT ID clamped")
|
|
otPack.otId = -5
|
|
eq(Protocol.unpackMon(Data, otPack).otId, 0, "under-range OT ID clamped")
|
|
-- an old peer that never sends OT leaves it nil (no worse than legacy; the
|
|
-- load-time stampOT backfill then fills the receiver's own, as before)
|
|
local legacy = Protocol.packMon(Pokemon.new(Data, "PIDGEY", 10))
|
|
check(Protocol.unpackMon(Data, legacy).ot == nil, "missing OT stays nil (legacy peer)")
|
|
|
|
-- ---------------------------------------------------------------- transport
|
|
local Net = require("src.link.Net")
|
|
|
|
-- loopback pair: the offline transport the tests (and headless luajit,
|
|
-- which has no enet) run the protocol over
|
|
local lbA, lbB = Net.loopbackPair()
|
|
check(lbA.paired and lbB.paired, "loopback pair starts paired")
|
|
lbA:send({ type = "hello", name = "RED", mode = "trade" })
|
|
lbB:send({ type = "hello", name = "BLUE", mode = "trade" })
|
|
lbA:update()
|
|
lbB:update()
|
|
local gotA, gotB = lbA:poll()[1], lbB:poll()[1]
|
|
eq(gotA and gotA.name, "BLUE", "loopback A received B's hello")
|
|
eq(gotB and gotB.name, "RED", "loopback B received A's hello")
|
|
check(#lbA:poll() == 0, "poll drains the inbox")
|
|
lbB:close()
|
|
lbB:send({ type = "bye" })
|
|
lbA:update()
|
|
check(#lbA:poll() == 0, "a closed end sends nothing")
|
|
|
|
-- real enet pairing over UDP localhost (only when lua-enet is present,
|
|
-- i.e. inside LÖVE; plain luajit skips this section)
|
|
if Net.available() then
|
|
local host = Net.new()
|
|
check(host:host(7807), "host opens a UDP port")
|
|
check(host.address ~= nil and host.address:match(":7807$") ~= nil,
|
|
"host advertises an address: " .. tostring(host.address))
|
|
local guest = Net.new()
|
|
check(guest:join("127.0.0.1:7807"), "guest dials the address")
|
|
local spins = 0
|
|
while not (host.paired and guest.paired) and spins < 500000 do
|
|
host:update()
|
|
guest:update()
|
|
spins = spins + 1
|
|
end
|
|
check(host.paired and guest.paired, "both sides paired over enet")
|
|
|
|
host:send({ type = "hello", name = "RED", mode = "trade" })
|
|
guest:send({ type = "hello", name = "BLUE", mode = "trade" })
|
|
local got = { host = nil, guest = nil }
|
|
spins = 0
|
|
while (not got.host or not got.guest) and spins < 500000 do
|
|
host:update()
|
|
guest:update()
|
|
for _, m in ipairs(host:poll()) do got.host = m end
|
|
for _, m in ipairs(guest:poll()) do got.guest = m end
|
|
spins = spins + 1
|
|
end
|
|
eq(got.host and got.host.name, "BLUE", "host received guest hello")
|
|
eq(got.guest and got.guest.name, "RED", "guest received host hello")
|
|
|
|
-- disconnect is noticed
|
|
guest:close()
|
|
spins = 0
|
|
while not host.closed and spins < 500000 do
|
|
host:update()
|
|
spins = spins + 1
|
|
end
|
|
check(host.closed, "host notices the guest leaving")
|
|
host:close()
|
|
|
|
-- joining a dead address errors out (short timeout for the test)
|
|
local reject = Net.new()
|
|
reject.joinTimeout = 0.5
|
|
reject:join("127.0.0.1:7809")
|
|
local t0 = os.clock()
|
|
while not reject.error and os.clock() - t0 < 30 do
|
|
reject:update()
|
|
end
|
|
check(reject.error ~= nil, "unanswered join reports an error")
|
|
reject:close()
|
|
else
|
|
print("skip real enet pairing (lua-enet not available under this interpreter)")
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- relay (TCP) transport
|
|
-- Pure framing logic needs no socket at all: Net:drainLines()/handleTCPLine
|
|
-- operate directly on rxBuf, so this much runs even under plain luajit.
|
|
do
|
|
local n = Net.new()
|
|
local encoded = Json.encode({ type = "hosted", code = "ABCDEF" })
|
|
n.rxBuf = encoded .. "\n"
|
|
n:drainLines()
|
|
eq(n.code, "ABCDEF", "relay framing: a complete hosted line sets net.code")
|
|
check(n.rxBuf == "", "relay framing: a complete line is fully consumed")
|
|
|
|
local n2 = Net.new()
|
|
n2.rxBuf = encoded:sub(1, 5) -- the line straddles two reads
|
|
n2:drainLines()
|
|
check(n2.code == nil, "relay framing: a partial line doesn't parse yet")
|
|
n2.rxBuf = n2.rxBuf .. encoded:sub(6) .. "\n"
|
|
n2:drainLines()
|
|
eq(n2.code, "ABCDEF", "relay framing: completing the line resolves it")
|
|
|
|
local n3 = Net.new()
|
|
n3.rxBuf = Json.encode({ type = "join_error", reason = "not_found" }) .. "\n"
|
|
n3:drainLines()
|
|
check(n3.error ~= nil and n3.closed, "relay framing: join_error sets error and closes")
|
|
|
|
local n4 = Net.new()
|
|
n4.rxBuf = Json.encode({ type = "paired" }) .. "\n"
|
|
n4:drainLines()
|
|
check(n4.paired, "relay framing: paired flips net.paired")
|
|
|
|
local n5 = Net.new()
|
|
n5.paired = true
|
|
n5.rxBuf = Json.encode({ type = "peer_gone" }) .. "\n"
|
|
n5:drainLines()
|
|
check(n5.closed, "relay framing: peer_gone closes the connection")
|
|
|
|
local n6 = Net.new()
|
|
n6.rxBuf = Json.encode({ type = "hello", name = "RED" }) .. "\n"
|
|
n6:drainLines()
|
|
eq(#n6.inbox, 1, "relay framing: an unrecognized control type lands in the inbox")
|
|
eq(n6.inbox[1] and n6.inbox[1].name, "RED", "relay framing: ...with its payload intact")
|
|
|
|
-- heartbeat: ping and pong are relay control, never inbox traffic
|
|
local n7 = Net.new()
|
|
n7.tcpSocket = true -- Net:send queues into txBuf on the relay arm
|
|
n7.rxBuf = Json.encode({ type = "ping", t = 1234 }) .. "\n"
|
|
n7:drainLines()
|
|
eq(#n7.inbox, 0, "relay heartbeat: a server ping never reaches the inbox")
|
|
check(n7.txBuf:find('"pong"', 1, true) ~= nil,
|
|
"relay heartbeat: a server ping is answered with a pong")
|
|
check(n7.txBuf:find("1234", 1, true) ~= nil,
|
|
"relay heartbeat: the pong echoes the ping's t")
|
|
|
|
local n8 = Net.new()
|
|
n8.pendingPings = 2
|
|
n8.rxBuf = Json.encode({ type = "pong", t = 5 }) .. "\n"
|
|
n8:drainLines()
|
|
eq(#n8.inbox, 0, "relay heartbeat: a pong never reaches the inbox")
|
|
check(n8.sawPong, "relay heartbeat: a pong marks the relay as answering")
|
|
eq(n8.pendingPings, 0, "relay heartbeat: a pong clears the missed-ping counter")
|
|
|
|
-- a relay that has never ponged (a v1 server) is never timed out
|
|
local n9 = Net.new()
|
|
n9.tcpSocket = true
|
|
n9.lastSendAt = -1000
|
|
for _ = 1, 6 do n9:heartbeatTCP(); n9.lastSendAt = -1000 end
|
|
check(not n9.closed, "relay heartbeat: a relay that never ponged is never timed out")
|
|
check(n9.pendingPings >= 6, "relay heartbeat: ...but pings still go out")
|
|
|
|
-- once a pong has been seen, three missed ones close the connection
|
|
local n10 = Net.new()
|
|
n10.tcpSocket = true
|
|
n10.sawPong = true
|
|
n10.lastSendAt = -1000
|
|
n10:heartbeatTCP()
|
|
check(not n10.closed, "relay heartbeat: one missed pong is not fatal")
|
|
n10.lastSendAt = -1000
|
|
n10:heartbeatTCP()
|
|
check(not n10.closed, "relay heartbeat: two missed pongs are not fatal")
|
|
n10.lastSendAt = -1000
|
|
n10:heartbeatTCP()
|
|
check(n10.closed and n10.error ~= nil,
|
|
"relay heartbeat: three missed pongs close the connection with an error")
|
|
|
|
-- close() drains txBuf through a socket that only accepts partial writes
|
|
local function stubSocket(chunk)
|
|
local sent = {}
|
|
return {
|
|
sent = sent,
|
|
send = function(_, data)
|
|
if chunk <= 0 then return nil, "timeout", 0 end
|
|
local n = math.min(chunk, #data)
|
|
sent[#sent + 1] = data:sub(1, n)
|
|
if n == #data then return n end
|
|
return nil, "timeout", n
|
|
end,
|
|
receive = function() return nil, "timeout", "" end,
|
|
close = function() end,
|
|
setoption = function() end,
|
|
}
|
|
end
|
|
|
|
local n11 = Net.new()
|
|
n11.selectable = false
|
|
local closing = stubSocket(4)
|
|
n11.tcpSocket = closing
|
|
n11:send({ type = "bye" })
|
|
n11:close()
|
|
eq(table.concat(closing.sent), '{"type":"bye"}\n',
|
|
"relay close: close() flushes the queued bye instead of discarding it")
|
|
check(n11.closed and n11.tcpSocket == nil,
|
|
"relay close: close() still closes and releases the connection")
|
|
|
|
local n12 = Net.new()
|
|
n12.selectable = false
|
|
local stub = stubSocket(4)
|
|
n12.tcpSocket = stub
|
|
n12:send({ type = "bye" })
|
|
n12:flushTCP(0.25)
|
|
eq(table.concat(stub.sent), '{"type":"bye"}\n',
|
|
"relay close: a partial-write socket is pumped until txBuf is empty")
|
|
eq(n12.txBuf, "", "relay close: the flush leaves nothing queued")
|
|
|
|
-- a socket that never drains gives up at the deadline instead of hanging
|
|
local n13 = Net.new()
|
|
n13.selectable = false
|
|
local attempts = 0
|
|
n13.tcpSocket = {
|
|
send = function() attempts = attempts + 1 return nil, "timeout", 0 end,
|
|
receive = function() return nil, "timeout", "" end,
|
|
close = function() end,
|
|
}
|
|
n13:send({ type = "bye" })
|
|
n13:flushTCP(0.05)
|
|
check(attempts > 0 and attempts <= Net.CLOSE_FLUSH_ATTEMPTS,
|
|
"relay close: a socket that never drains gives up at the attempt bound")
|
|
check(#n13.txBuf > 0, "relay close: ...and the undelivered bytes stay queued")
|
|
n13:close()
|
|
check(n13.closed, "relay close: an undrainable socket still closes")
|
|
end
|
|
|
|
-- real pokeserver over TCP localhost (only when luasocket is present, i.e.
|
|
-- inside LOVE or a luajit with luasocket installed, AND node is on PATH to
|
|
-- spawn the real relay; otherwise this section is skipped, same spirit as
|
|
-- the enet gate above)
|
|
local hasSocket = pcall(require, "socket")
|
|
local nodeCheck = os.execute("command -v node >/dev/null 2>&1")
|
|
local hasNode = nodeCheck == true or nodeCheck == 0
|
|
if not hasSocket then
|
|
print("skip real relay pairing (luasocket not available under this interpreter)")
|
|
elseif not hasNode then
|
|
print("skip real relay pairing (node not on PATH to spawn pokeserver)")
|
|
else
|
|
local PORT = 17778
|
|
local pidFile = os.tmpname()
|
|
os.execute(("(cd ../pokeserver && PORT=%d HTTP_PORT=%d node server.js >/tmp/pokeserver_test.log 2>&1 & echo $! > %q)")
|
|
:format(PORT, PORT + 1, pidFile))
|
|
|
|
local function busyWait(seconds)
|
|
local t0 = os.clock()
|
|
while os.clock() - t0 < seconds do end
|
|
end
|
|
|
|
local function tcpConnectable(host, port)
|
|
local socket = require("socket")
|
|
local tcp = socket.tcp()
|
|
tcp:settimeout(0.2)
|
|
local ok = tcp:connect(host, port)
|
|
tcp:close()
|
|
return ok ~= nil
|
|
end
|
|
|
|
local ready = false
|
|
for _ = 1, 50 do
|
|
ready = tcpConnectable("127.0.0.1", PORT)
|
|
if ready then break end
|
|
busyWait(0.1)
|
|
end
|
|
|
|
if not ready then
|
|
print("skip real relay pairing (couldn't reach the spawned pokeserver)")
|
|
else
|
|
local host = Net.new()
|
|
check(host:hostOnline("127.0.0.1:" .. PORT), "relay: hostOnline connects: " .. tostring(host.error))
|
|
local deadline = os.clock() + 3
|
|
while not host.code and os.clock() < deadline do host:update() end
|
|
check(host.code ~= nil, "relay: a real server assigns a room code")
|
|
|
|
local guest = Net.new()
|
|
check(guest:joinOnline("127.0.0.1:" .. PORT, host.code or ""),
|
|
"relay: joinOnline connects: " .. tostring(guest.error))
|
|
deadline = os.clock() + 3
|
|
while (not host.paired or not guest.paired) and os.clock() < deadline do
|
|
host:update()
|
|
guest:update()
|
|
end
|
|
check(host.paired and guest.paired, "relay: both sides pair over a real TCP server")
|
|
|
|
host:send({ type = "hello", name = "RED" })
|
|
local relayed = nil
|
|
deadline = os.clock() + 3
|
|
while not relayed and os.clock() < deadline do
|
|
host:update()
|
|
guest:update()
|
|
for _, m in ipairs(guest:poll()) do
|
|
if m.type == "hello" then relayed = m end
|
|
end
|
|
end
|
|
eq(relayed and relayed.name, "RED", "relay: a message round-trips through the real server")
|
|
|
|
host:close()
|
|
guest:close()
|
|
end
|
|
|
|
local pidHandle = io.open(pidFile, "r")
|
|
if pidHandle then
|
|
local pid = pidHandle:read("*l")
|
|
pidHandle:close()
|
|
if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end
|
|
end
|
|
os.remove(pidFile)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- trade session
|
|
local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) }
|
|
local partyB = { Pokemon.new(Data, "MACHOKE", 32) }
|
|
-- distinct original trainers so a preserved OT is unmistakable after the swap
|
|
-- (#215): A's KADABRA belongs to CULLEN/16012, B's MACHOKE to RED/60368
|
|
partyA[1].ot = "CULLEN"; partyA[1].otId = 16012
|
|
partyB[1].ot = "RED"; partyB[1].otId = 60368
|
|
local tA = Protocol.TradeSession.new(Data, partyA)
|
|
local tB = Protocol.TradeSession.new(Data, partyB)
|
|
tA:handle({ type = "party", mons = Protocol.packParty(partyB) })
|
|
tB:handle({ type = "party", mons = Protocol.packParty(partyA) })
|
|
eq(tA.stage, "picking", "trade session enters picking")
|
|
local pickA = tA:pick(1) -- gives KADABRA
|
|
local pickB = tB:pick(1) -- gives MACHOKE
|
|
tA:handle(pickB)
|
|
tB:handle(pickA)
|
|
eq(tA.stage, "confirming", "both picks -> confirming")
|
|
local cA = tA:confirm(true)
|
|
local cB = tB:confirm(true)
|
|
tA:handle(cB)
|
|
tB:handle(cA)
|
|
eq(tA.stage, "done", "trade completes")
|
|
local gotMon, evoTo = tA:apply(nil)
|
|
eq(gotMon.species, "MACHOKE", "A received Machoke")
|
|
eq(evoTo, "MACHAMP", "trade evolution triggers (Machoke -> Machamp)")
|
|
-- the received mon keeps its SENDER's OT, not the receiver's (#215)
|
|
eq(gotMon.ot, "RED", "A's received Machoke keeps sender B's OT name")
|
|
eq(gotMon.otId, 60368, "A's received Machoke keeps sender B's OT ID")
|
|
local gotMon2, evoTo2 = tB:apply(nil)
|
|
eq(gotMon2.species, "KADABRA", "B received Kadabra")
|
|
eq(evoTo2, "ALAKAZAM", "Kadabra -> Alakazam on trade")
|
|
eq(gotMon2.ot, "CULLEN", "B's received Kadabra keeps sender A's OT name")
|
|
eq(gotMon2.otId, 16012, "B's received Kadabra keeps sender A's OT ID")
|
|
|
|
-- declined trades cancel
|
|
local tC = Protocol.TradeSession.new(Data, partyA)
|
|
tC:handle({ type = "party", mons = Protocol.packParty(partyB) })
|
|
tC:pick(1)
|
|
tC:handle({ type = "pick", index = 1 })
|
|
tC:confirm(true)
|
|
tC:handle({ type = "confirm", ok = false })
|
|
eq(tC.stage, "cancelled", "declined trade cancels")
|
|
|
|
-- TradeCenter_PrintPartyListNames (engine/link/cable_club.asm:657) reads
|
|
local LinkStateMod = require("src.link.LinkState")
|
|
local labelState = setmetatable({ game = { data = Data } }, LinkStateMod)
|
|
local nicked = Pokemon.new(Data, "SANDSHREW", 5)
|
|
nicked.nickname = "FLUFFY"
|
|
eq(labelState:listLabel(nicked), "SANDSHREW", "the list shows the species, not the nickname")
|
|
eq(#labelState:listLabel(nicked), 9, "a 9-char species name is not truncated")
|
|
local pickStub = { canPick = function() return false end }
|
|
eq(labelState:listLabel(nicked, pickStub, 1), "SANDSHREWX",
|
|
"an unpickable mon keeps its X mark")
|
|
|
|
-- ---------------------------------------------------------------- link battle (lockstep)
|
|
-- Both sides run the full engine locally on a shared seed; this drives
|
|
-- two simulations over a loopback and checks they agree.
|
|
local Input = require("src.core.Input")
|
|
Input:init()
|
|
require("src.render.Font").load(Data)
|
|
|
|
local function makeFakeGame(leadSpecies)
|
|
local save = require("src.core.SaveData").newGame()
|
|
table.insert(save.party, Pokemon.new(Data, leadSpecies, 50))
|
|
local stack = { list = {} }
|
|
function stack:push(s, ...)
|
|
table.insert(self.list, s)
|
|
if s.enter then s:enter(...) end
|
|
end
|
|
function stack:pop() table.remove(self.list) end
|
|
function stack:top() return self.list[#self.list] end
|
|
function stack:update(dt)
|
|
local t = self:top()
|
|
if t and t.update then t:update(dt) end
|
|
end
|
|
return { data = Data, input = Input, stack = stack, save = save }
|
|
end
|
|
|
|
local LinkBattle = require("src.link.LinkBattle")
|
|
local gameA = makeFakeGame("CHARIZARD")
|
|
local gameB = makeFakeGame("BLASTOISE")
|
|
gameB.save.player.name = "BLUE"
|
|
-- each side's send lands in the other's inbox (json re-encoded like
|
|
-- the real wire) through Net's own loopback transport
|
|
local netA, netB = Net.loopbackPair()
|
|
|
|
local packedA = Protocol.packParty(gameA.save.party)
|
|
local packedB = Protocol.packParty(gameB.save.party)
|
|
local seed = 987654321
|
|
|
|
local battleA = LinkBattle.newHost(gameA, netA, {
|
|
myParty = packedA, theirParty = packedB, theirName = "BLUE", seed = seed,
|
|
})
|
|
local battleB = LinkBattle.newGuest(gameB, netB, {
|
|
myParty = packedB, theirParty = packedA, theirName = "RED", seed = seed,
|
|
})
|
|
local resA, resB = nil, nil
|
|
battleA.onFinish = function(r) resA = r end
|
|
battleB.onFinish = function(r) resB = r end
|
|
gameA.stack:push(battleA)
|
|
gameB.stack:push(battleB)
|
|
eq(battleA.kind, "link", "host battle is a link battle")
|
|
eq(battleA.enemy.mon.species, "BLASTOISE", "guest party became the host's enemy side")
|
|
eq(battleB.enemy.mon.species, "CHARIZARD", "host party became the guest's enemy side")
|
|
|
|
-- drive both sides with mashed A (FIGHT -> first move) until done
|
|
local guard = 0
|
|
while (resA == nil or resB == nil) and guard < 60000 do
|
|
guard = guard + 1
|
|
Input.pressed = { a = true }
|
|
gameA.stack:update(1 / 60)
|
|
gameB.stack:update(1 / 60)
|
|
end
|
|
check(resA ~= nil and resB ~= nil,
|
|
("lockstep battle completes on both sides (%s / %s)"):format(
|
|
tostring(resA), tostring(resB)))
|
|
check((resA == "win" and resB == "lose") or (resA == "lose" and resB == "win")
|
|
or (resA == "draw" and resB == "draw"),
|
|
"the two simulations agree on the outcome")
|
|
-- mirrored final state: my mon's HP on A equals A's mon HP as seen by B
|
|
eq(battleA.player.mon.hp, battleB.enemy.mon.hp, "host mon HP identical on both sides")
|
|
eq(battleA.enemy.mon.hp, battleB.player.mon.hp, "guest mon HP identical on both sides")
|
|
local leftoverMismatch = false
|
|
for turn, h in pairs(battleA.localHashes) do
|
|
if battleB.localHashes[turn] and battleB.localHashes[turn] ~= h then
|
|
leftoverMismatch = true
|
|
end
|
|
end
|
|
check(not leftoverMismatch, "no desync detected across the whole battle")
|
|
eq(gameA.save.money, 3000, "no prize money in link battles")
|
|
eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp,
|
|
"the real party is untouched (battle used clamped copies)")
|
|
|
|
-- ---------------------------------------------------------------- forced-level ("auto 50") matches
|
|
-- online matches/tournaments may force every participant's real level to a
|
|
-- fixed value for the match (opts.forceLevel), regardless of their save's
|
|
-- actual level -- this checks both sides normalize identically and stats
|
|
-- recompute for the forced level rather than clamping the original level's
|
|
local gameG = makeFakeGame("PIKACHU")
|
|
gameG.save.party[1] = Pokemon.new(Data, "PIKACHU", 12)
|
|
local gameH = makeFakeGame("GEODUDE")
|
|
gameH.save.party[1] = Pokemon.new(Data, "GEODUDE", 100)
|
|
gameH.save.player.name = "BLUE"
|
|
local netG, netH = Net.loopbackPair()
|
|
local packedG = Protocol.packParty(gameG.save.party)
|
|
local packedH = Protocol.packParty(gameH.save.party)
|
|
local seedGH = 13579
|
|
|
|
local battleG = LinkBattle.newHost(gameG, netG, {
|
|
myParty = packedG, theirParty = packedH, theirName = "BLUE", seed = seedGH,
|
|
forceLevel = 50,
|
|
})
|
|
local battleH = LinkBattle.newGuest(gameH, netH, {
|
|
myParty = packedH, theirParty = packedG, theirName = "RED", seed = seedGH,
|
|
forceLevel = 50,
|
|
})
|
|
eq(battleG.player.mon.level, 50, "forced level overrides a low real level (12 -> 50)")
|
|
eq(battleG.enemy.mon.level, 50, "...and a high real level (100 -> 50) the same way")
|
|
eq(battleH.player.mon.level, 50, "the guest's own mon is forced too")
|
|
eq(battleH.enemy.mon.level, 50, "the guest sees the host's mon forced too")
|
|
local expectedStats = require("src.pokemon.Stats").calc(
|
|
Data.pokemon["PIKACHU"], 50, battleG.player.mon.dvs, battleG.player.mon.statExp)
|
|
eq(battleG.player.mon.stats.hp, expectedStats.hp,
|
|
"forced-level stats are recomputed for level 50, not clamped from level 12")
|
|
eq(gameG.save.party[1].level, 12, "the real save data keeps its actual level")
|
|
eq(gameH.save.party[1].level, 100, "...on both sides")
|
|
|
|
-- ---------------------------------------------------------------- "ANY" level ruling (#204)
|
|
-- The link "level ruling" picker cycles a string sentinel "ANY" meaning
|
|
-- "use each mon's real level" (Gen1 link cable always used the real level).
|
|
-- LinkState.levelForWire turns that sentinel into nil on the wire;
|
|
-- a broken `x and nil or y` idiom used to let the literal "ANY" through into
|
|
-- opts.forceLevel, and Protocol.unpackMon then called math.floor("ANY") ->
|
|
-- "bad argument #1 to 'floor' (number expected, got string)", crashing the
|
|
-- host the instant parties were unpacked in newHost (see the #204 report's
|
|
-- traceback: unpackMon <- unpackParty <- newHost <- LinkState.update).
|
|
-- unpackMon now coerces forceLevel with tonumber, so any non-numeric level
|
|
-- string means "no forced level" and the mon keeps its packed real level.
|
|
local anyPk = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
|
|
local okAny, monAny = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "ANY" })
|
|
check(okAny, "unpackMon does not crash on the ANY sentinel string")
|
|
eq(okAny and monAny and monAny.level, 12, "ANY forceLevel keeps the mon's real level (12)")
|
|
local okStr, monStr = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "50" })
|
|
eq(okStr and monStr and monStr.level, 50, "a numeric-string forceLevel is still honored (50)")
|
|
local okNil, monNil = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = nil })
|
|
eq(okNil and monNil and monNil.level, 12, "no forceLevel keeps the real level (12)")
|
|
|
|
-- end-to-end host path from the crash report: newHost -> unpackParty ->
|
|
-- unpackMon, with the exact bad value the bug emitted (forceLevel = "ANY").
|
|
local gameI = makeFakeGame("PIKACHU")
|
|
gameI.save.party[1] = Pokemon.new(Data, "PIKACHU", 12)
|
|
local gameJ = makeFakeGame("GEODUDE")
|
|
gameJ.save.party[1] = Pokemon.new(Data, "GEODUDE", 100)
|
|
gameJ.save.player.name = "BLUE"
|
|
local netI, netJ = Net.loopbackPair()
|
|
local packedI = Protocol.packParty(gameI.save.party)
|
|
local packedJ = Protocol.packParty(gameJ.save.party)
|
|
local seedIJ = 24680
|
|
local okHost, battleI = pcall(LinkBattle.newHost, gameI, netI, {
|
|
myParty = packedI, theirParty = packedJ, theirName = "BLUE", seed = seedIJ,
|
|
forceLevel = "ANY",
|
|
})
|
|
local okGuest, battleJ = pcall(LinkBattle.newGuest, gameJ, netJ, {
|
|
myParty = packedJ, theirParty = packedI, theirName = "RED", seed = seedIJ,
|
|
forceLevel = "ANY",
|
|
})
|
|
check(okHost and battleI ~= nil, "newHost does not crash with the ANY ruling")
|
|
check(okGuest and battleJ ~= nil, "newGuest does not crash with the ANY ruling")
|
|
eq(okHost and battleI and battleI.player.mon.level, 12,
|
|
"ANY ruling: the host keeps its mon's real level (12, not forced)")
|
|
eq(okHost and battleI and battleI.enemy.mon.level, 100,
|
|
"ANY ruling: the enemy mon keeps its real level (100, not forced)")
|
|
|
|
-- ---------------------------------------------------------------- tournament shot clock
|
|
-- opts.turnLimit only applies to tournament matches; the guest mashes
|
|
-- through its own menu every frame while the host never presses anything,
|
|
-- so the host's clock is the only one that can expire.
|
|
local gameC = makeFakeGame("PIKACHU")
|
|
local gameD = makeFakeGame("SNORLAX")
|
|
gameD.save.player.name = "YELLOW"
|
|
local netC, netD = Net.loopbackPair()
|
|
local packedC = Protocol.packParty(gameC.save.party)
|
|
local packedD = Protocol.packParty(gameD.save.party)
|
|
local battleC = LinkBattle.newHost(gameC, netC, {
|
|
myParty = packedC, theirParty = packedD, theirName = "YELLOW", seed = 42,
|
|
turnLimit = 0.05,
|
|
})
|
|
local battleD = LinkBattle.newGuest(gameD, netD, {
|
|
myParty = packedD, theirParty = packedC, theirName = "RED", seed = 42,
|
|
turnLimit = 0.05,
|
|
})
|
|
local resC, resD = nil, nil
|
|
battleC.onFinish = function(r) resC = r end
|
|
battleD.onFinish = function(r) resD = r end
|
|
gameC.stack:push(battleC)
|
|
gameD.stack:push(battleD)
|
|
|
|
-- both sides need "a" to get through the intro's messages/animations
|
|
-- (send-out poofs, cries...) before the host's own menu even shows; only
|
|
-- once the host's decision point is actually up does withholding input
|
|
-- from it mean anything
|
|
local guardIntro = 0
|
|
while battleC.phase ~= "menu" and guardIntro < 6000 do
|
|
guardIntro = guardIntro + 1
|
|
Input.pressed = { a = true }
|
|
gameC.stack:update(1 / 60)
|
|
gameD.stack:update(1 / 60)
|
|
end
|
|
check(battleC.phase == "menu", "shot clock: host reaches its own decision point")
|
|
|
|
local guard2 = 0
|
|
while resD == nil and guard2 < 6000 do
|
|
guard2 = guard2 + 1
|
|
Input.pressed = { a = true }
|
|
gameD.stack:update(1 / 60) -- guest mashes through its menu
|
|
Input.pressed = {}
|
|
gameC.stack:update(1 / 60) -- host presses nothing; its clock ticks down
|
|
end
|
|
check(resD == "win", "shot clock: the timed-out player's opponent wins immediately")
|
|
|
|
-- the timed-out host still has to dismiss its own "time's up" message to
|
|
-- reach finish() -- exactly like a slow-but-present player would; this
|
|
-- isn't the clock's business, just the ordinary message queue
|
|
local guard3 = 0
|
|
while resC == nil and guard3 < 6000 do
|
|
guard3 = guard3 + 1
|
|
Input.pressed = { a = true }
|
|
gameC.stack:update(1 / 60)
|
|
end
|
|
check(resC == "lose", "shot clock: the timed-out host is recorded as the loser")
|
|
|
|
-- ---------------------------------------------------------------- tournament spectator replay
|
|
-- A spectator (LinkBattle.newSpectator) reconstructs the same lockstep
|
|
-- battle from a copy of the wire traffic tagged by side -- exactly what
|
|
-- pokeserver's tournament fan-out gives it. Feed it directly here rather
|
|
-- than standing up a real relay: wrap the host/guest loopback sends so
|
|
-- every message they exchange also lands, tagged, in a fake spectator net.
|
|
local gameE = makeFakeGame("CHARIZARD")
|
|
local gameF = makeFakeGame("BLASTOISE")
|
|
gameF.save.player.name = "BLUE"
|
|
local gameSpec = makeFakeGame("RATTATA")
|
|
local netE, netF = Net.loopbackPair()
|
|
local packedE = Protocol.packParty(gameE.save.party)
|
|
local packedF = Protocol.packParty(gameF.save.party)
|
|
local specSeed = 13579
|
|
|
|
local specInbox = {}
|
|
local origSendE, origSendF = netE.send, netF.send
|
|
netE.send = function(self, msg)
|
|
origSendE(self, msg)
|
|
table.insert(specInbox, { type = "spectate", side = "host", msg = msg })
|
|
end
|
|
netF.send = function(self, msg)
|
|
origSendF(self, msg)
|
|
table.insert(specInbox, { type = "spectate", side = "guest", msg = msg })
|
|
end
|
|
local specNet = {
|
|
closed = false,
|
|
update = function() end,
|
|
poll = function()
|
|
local msgs = specInbox
|
|
specInbox = {}
|
|
return msgs
|
|
end,
|
|
}
|
|
|
|
local battleE = LinkBattle.newHost(gameE, netE, {
|
|
myParty = packedE, theirParty = packedF, theirName = "BLUE", seed = specSeed,
|
|
})
|
|
local battleF = LinkBattle.newGuest(gameF, netF, {
|
|
myParty = packedF, theirParty = packedE, theirName = "RED", seed = specSeed,
|
|
})
|
|
local battleSpec = LinkBattle.newSpectator(gameSpec, specNet, {
|
|
hostParty = packedE, guestParty = packedF, hostName = "RED", guestName = "BLUE",
|
|
seed = specSeed,
|
|
})
|
|
check(battleSpec ~= nil, "spectator battle constructs")
|
|
eq(battleSpec.spectating, true, "spectator battle is marked as such (not a reportable match)")
|
|
|
|
local resE, resF = nil, nil
|
|
battleE.onFinish = function(r) resE = r end
|
|
battleF.onFinish = function(r) resF = r end
|
|
gameE.stack:push(battleE)
|
|
gameF.stack:push(battleF)
|
|
gameSpec.stack:push(battleSpec)
|
|
|
|
local guard3 = 0
|
|
while (resE == nil or resF == nil) and guard3 < 60000 do
|
|
guard3 = guard3 + 1
|
|
Input.pressed = { a = true }
|
|
gameE.stack:update(1 / 60)
|
|
gameF.stack:update(1 / 60)
|
|
gameSpec.stack:update(1 / 60)
|
|
end
|
|
check(resE ~= nil and resF ~= nil, "spectator test: the underlying match completes")
|
|
eq(battleSpec.player.mon.hp, battleE.player.mon.hp,
|
|
"spectator's host-side HP matches the host's own view")
|
|
eq(battleSpec.enemy.mon.hp, battleF.player.mon.hp,
|
|
"spectator's guest-side HP matches the guest's own view")
|
|
|
|
-- ---------------------------------------------------------------- fx clock
|
|
-- A link battle skips BattleState:update on two hot paths -- waiting for the
|
|
-- peer's action, and draining a resolved turn -- and the presentational
|
|
-- clock (BGP flash sequences, pic slide/hide programs, the send-out grow-in)
|
|
-- only advances inside it. Frozen, a flash stopped on its inverted BGP step
|
|
-- and repainted the UI in inverted shades, and a pic caught mid-slide stayed
|
|
-- off screen, both for as long as the opponent took to choose. These assert
|
|
-- the clock keeps running on every path a link battle can sit in.
|
|
local fxGame = makeFakeGame("CHARIZARD")
|
|
local fxNetA = select(1, Net.loopbackPair())
|
|
local fxBattle = LinkBattle.newHost(fxGame, fxNetA, {
|
|
myParty = Protocol.packParty(fxGame.save.party),
|
|
theirParty = Protocol.packParty(makeFakeGame("BLASTOISE").save.party),
|
|
theirName = "BLUE", seed = 99 })
|
|
fxGame.stack:push(fxBattle)
|
|
|
|
local function fxAdvances(phase, afterQueue)
|
|
fxBattle.phase = phase
|
|
fxBattle.afterQueue = afterQueue
|
|
-- a three-step flash sequence, exactly as an SE_* flash row starts one
|
|
fxBattle.fx = fxBattle.fx or {}
|
|
fxBattle.fx.bgpSeq = { steps = { { map = {}, frames = 2 },
|
|
{ map = {}, frames = 2 },
|
|
{ map = {}, frames = 2 } },
|
|
idx = 1, left = 2 }
|
|
local startFrame = fxBattle.frame
|
|
for _ = 1, 4 do fxBattle:update(1 / 60) end
|
|
local seq = fxBattle.fx.bgpSeq
|
|
return fxBattle.frame > startFrame and (seq == nil or seq.idx > 1)
|
|
end
|
|
|
|
check(fxAdvances("waitRemote", nil),
|
|
"the fx clock keeps running while waiting for the peer's action")
|
|
check(fxAdvances("messages", "linkNext"),
|
|
"the fx clock keeps running while a resolved turn drains")
|
|
|
|
-- ---------------------------------------------------------------- ruleset draw count
|
|
local Damage = require("src.battle.Damage")
|
|
local faithful = require("src.battle.rulesets.gen1_faithful")
|
|
local modern = require("src.battle.rulesets.modern_clean")
|
|
local function countingRng()
|
|
local n = 0
|
|
return function(a, b)
|
|
n = n + 1
|
|
if a == nil then return 0 end
|
|
if b == nil then a, b = 1, a end
|
|
return a
|
|
end, function() return n end
|
|
end
|
|
local accMove = { accuracy = 100 }
|
|
local accAttacker = { stages = { accuracy = 0 } }
|
|
local accDefender = { stages = { evasion = 0 } }
|
|
local rngF, drawsF = countingRng()
|
|
Damage.accuracyRoll(faithful, accMove, accAttacker, accDefender, rngF)
|
|
eq(drawsF(), 1, "gen1_faithful spends one RNG draw on a 100-accuracy roll")
|
|
local rngM, drawsM = countingRng()
|
|
Damage.accuracyRoll(modern, accMove, accAttacker, accDefender, rngM)
|
|
eq(drawsM(), 0, "modern_clean spends none (the 1/256 miss is gone)")
|
|
|
|
-- ---------------------------------------------------------------- host-dealt ruleset
|
|
local Handshake = require("src.link.Handshake")
|
|
local Wire = require("src.link.Wire")
|
|
local gameK = makeFakeGame("CHARIZARD")
|
|
local gameL = makeFakeGame("BLASTOISE")
|
|
gameL.save.player.name = "BLUE"
|
|
gameK.save.options.ruleset = "gen1_faithful"
|
|
gameL.save.options.ruleset = "modern_clean"
|
|
eq(Handshake.ruleset(gameK), "gen1_faithful", "the hello reports the local ruleset")
|
|
eq(Handshake.ruleset(gameL), "modern_clean", "...on each side independently")
|
|
|
|
local netK, netL = Net.loopbackPair()
|
|
local packedK = Protocol.packParty(gameK.save.party)
|
|
local packedL = Protocol.packParty(gameL.save.party)
|
|
local rsSeed = 424242
|
|
local dealt = Wire.sanitize({ type = "party", mons = packedK, seed = rsSeed,
|
|
ruleset = Handshake.ruleset(gameK) })
|
|
eq(dealt.ruleset, "gen1_faithful", "the ruleset survives the party schema")
|
|
|
|
local battleK = LinkBattle.newHost(gameK, netK, {
|
|
myParty = packedK, theirParty = packedL, theirName = "BLUE", seed = rsSeed,
|
|
ruleset = Handshake.ruleset(gameK),
|
|
})
|
|
local battleL = LinkBattle.newGuest(gameL, netL, {
|
|
myParty = packedL, theirParty = packedK, theirName = "RED", seed = dealt.seed,
|
|
ruleset = dealt.ruleset,
|
|
})
|
|
eq(battleK.rulesetId, battleL.rulesetId, "both machines run the host's ruleset id")
|
|
eq(battleL.ruleset.name, "gen1_faithful",
|
|
"the guest's own modern_clean OPTIONS row is overridden by the host's")
|
|
check(battleL.ruleset ~= modern,
|
|
"...and the local selection is not the record the battle holds")
|
|
|
|
local resK, resL = nil, nil
|
|
battleK.onFinish = function(r) resK = r end
|
|
battleL.onFinish = function(r) resL = r end
|
|
gameK.stack:push(battleK)
|
|
gameL.stack:push(battleL)
|
|
local guardRs = 0
|
|
while (resK == nil or resL == nil) and guardRs < 60000 do
|
|
guardRs = guardRs + 1
|
|
Input.pressed = { a = true }
|
|
gameK.stack:update(1 / 60)
|
|
gameL.stack:update(1 / 60)
|
|
end
|
|
check(resK ~= nil and resL ~= nil,
|
|
"mismatched-OPTIONS battle completes on both sides")
|
|
check((resK == "win" and resL == "lose") or (resK == "lose" and resL == "win")
|
|
or (resK == "draw" and resL == "draw"),
|
|
"...and the two simulations agree on the outcome")
|
|
eq(battleK.player.mon.hp, battleL.enemy.mon.hp,
|
|
"mismatched-OPTIONS battle: host mon HP identical on both sides")
|
|
eq(battleK.enemy.mon.hp, battleL.player.mon.hp,
|
|
"mismatched-OPTIONS battle: guest mon HP identical on both sides")
|
|
eq(battleK.rngDraws, battleL.rngDraws,
|
|
"the two sides took the same number of RNG draws")
|
|
local rsSplit = false
|
|
for turn, h in pairs(battleK.localHashes) do
|
|
if battleL.localHashes[turn] and battleL.localHashes[turn] ~= h then rsSplit = true end
|
|
end
|
|
check(not rsSplit, "no desync across a battle between differently-configured players")
|
|
|
|
local netM, netN = Net.loopbackPair()
|
|
local gameM = makeFakeGame("PIKACHU")
|
|
local gameN = makeFakeGame("GEODUDE")
|
|
gameN.save.player.name = "BLUE"
|
|
gameM.save.options.ruleset = "modern_clean"
|
|
gameN.save.options.ruleset = "gen1_faithful"
|
|
local battleM = LinkBattle.newHost(gameM, netM, {
|
|
myParty = Protocol.packParty(gameM.save.party),
|
|
theirParty = Protocol.packParty(gameN.save.party),
|
|
theirName = "BLUE", seed = 777 })
|
|
local battleN = LinkBattle.newGuest(gameN, netN, {
|
|
myParty = Protocol.packParty(gameN.save.party),
|
|
theirParty = Protocol.packParty(gameM.save.party),
|
|
theirName = "RED", seed = 777 })
|
|
eq(battleM.ruleset.name, "gen1_faithful", "no dealt ruleset falls back to the default")
|
|
eq(battleN.ruleset.name, "gen1_faithful", "...identically on the other machine")
|
|
|
|
-- ---------------------------------------------------------------- ruleset in the handshake
|
|
local rsHelloA = Handshake.hello(gameK, "battle")
|
|
local rsHelloB = Handshake.hello(gameL, nil)
|
|
eq(rsHelloA.ruleset, "gen1_faithful", "the hello carries the local ruleset")
|
|
local rsVerdict, rsReason = Handshake.checkCompat(rsHelloA, rsHelloB)
|
|
eq(rsVerdict, "ruleset_skew", "two peers on different rulesets do not pair as full")
|
|
eq(rsReason, "ruleset_mismatch", "...with the ruleset named as the reason")
|
|
eq(Handshake.battleAllowed(rsVerdict), false, "a ruleset mismatch refuses battle")
|
|
eq(Handshake.tradeAllowed(rsVerdict), true, "...and leaves trading alone")
|
|
eq(Handshake.strict(rsVerdict), true, "both peers are v2, so the trade stays strict")
|
|
local rsLines = Handshake.describe(rsHelloA, rsHelloB, rsVerdict, "battle")
|
|
check(#rsLines > 0, "the incompatibility screen has something to say")
|
|
local rsText = table.concat(rsLines, " ")
|
|
check(rsText:find("RULESET") ~= nil, "...and it names the OPTIONS row to change")
|
|
gameL.save.options.ruleset = "gen1_faithful"
|
|
eq(Handshake.checkCompat(rsHelloA, Handshake.hello(gameL, nil)), "full",
|
|
"matching rulesets still pair as full")
|
|
local rsOld = Handshake.hello(gameL, nil)
|
|
rsOld.ruleset = nil
|
|
eq(Handshake.checkCompat(rsHelloA, rsOld), "full",
|
|
"a peer without the field is treated as the default ruleset")
|
|
|
|
-- ---------------------------------------------------------------- desync fuzz
|
|
-- The lockstep battle above holds A through one Charizard/Blastoise duel,
|
|
-- which is one path. This walks the rest -- random parties, switches,
|
|
-- faints, locked actions -- with the two sides deliberately configured as
|
|
-- DIFFERENT clients (options, game speed, relay lag), which is the shape
|
|
-- every desync players reported actually had.
|
|
local fuzzOk, fuzzErr = pcall(dofile, "tests/link_desync_fuzz.lua")
|
|
check(fuzzOk, "lockstep desync fuzz" .. (fuzzOk and "" or (": " .. tostring(fuzzErr))))
|
|
|
|
-- ---------------------------------------------------------------- hostile wire
|
|
-- Every message type crossed with every wrong Lua type, through the real
|
|
-- Session choke point and into the real consumers. The regression net for
|
|
-- the remote-crash payloads; self-contained like the fuzz above.
|
|
local hostileOk, hostileErr = pcall(dofile, "tests/link_hostile.lua")
|
|
check(hostileOk, "hostile wire suite"
|
|
.. (hostileOk and "" or (": " .. tostring(hostileErr))))
|
|
|
|
-- ---------------------------------------------------------------- mod link compat
|
|
-- Self-contained like the tests/mod_*.lua suites: own bootstrap and
|
|
-- assert-based checks, so it lands here as a single pass/fail line.
|
|
local modOk, modErr = pcall(dofile, "tests/mod_link_tests.lua")
|
|
check(modOk, "mod link compat suite" .. (modOk and "" or (": " .. tostring(modErr))))
|
|
|
|
local clientOk, clientErr = pcall(dofile, "tests/online_client.lua")
|
|
check(clientOk, "online client suite" .. (clientOk and "" or (": " .. tostring(clientErr))))
|
|
|
|
-- ---------------------------------------------------------------- gen 2 lockstep
|
|
-- The Gold peer of the lockstep section above: two src/link/LinkBattle2.lua
|
|
-- simulations over a loopback, driven through the real Gen 2 battle screen.
|
|
-- ROM-free (its own Gen 2 shaped fixture), so it runs here whatever the cache
|
|
-- holds.
|
|
local link2Ok, link2Err = pcall(dofile, "tests/link2_lockstep.lua")
|
|
check(link2Ok, "gen 2 lockstep suite"
|
|
.. (link2Ok and "" or (": " .. tostring(link2Err))))
|
|
|
|
local fuzz2Ok, fuzz2Err = pcall(dofile, "tests/link2_desync_fuzz.lua")
|
|
check(fuzz2Ok, "gen 2 lockstep desync fuzz"
|
|
.. (fuzz2Ok and "" or (": " .. tostring(fuzz2Err))))
|
|
|
|
print(("\n%s"):format(failures == 0 and "ALL LINK TESTS PASSED" or failures .. " FAILURES"))
|
|
os.exit(failures == 0 and 0 or 1)
|