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

942 lines
40 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 = "lobby_welcome", session = "abc" })
n.rxBuf = encoded .. "\n"
n:drainLines()
eq(n.inbox[1] and n.inbox[1].session, "abc",
"relay framing: a complete line lands in the inbox")
check(n.rxBuf == "", "relay framing: a complete line is fully consumed")
local n2 = Net.new()
n2.rxBuf = encoded:sub(1, 5)
n2:drainLines()
eq(#n2.inbox, 0, "relay framing: a partial line doesn't parse yet")
n2.rxBuf = n2.rxBuf .. encoded:sub(6) .. "\n"
n2:drainLines()
eq(n2.inbox[1] and n2.inbox[1].session, "abc",
"relay framing: completing the line resolves it")
check(Net.hostOnline == nil and Net.joinOnline == nil,
"relay: the v1 host/join dialers are gone")
for _, v1 in ipairs({ "hosted", "paired", "peer_gone", "join_error" }) do
local nv = Net.new()
nv.rxBuf = Json.encode({ type = v1, code = "ABCDEF", reason = "not_found" }) .. "\n"
nv:drainLines()
check(not nv.closed and not nv.paired and nv.error == nil and #nv.inbox == 1,
"relay framing: a v1 " .. v1 .. " is no transport control any more")
end
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 function waitFor(net, pred, seconds)
local deadline = os.clock() + (seconds or 3)
while os.clock() < deadline do
net:update()
for _, m in ipairs(net:poll()) do
if pred(m) then return m end
end
if net.closed then return nil end
end
return nil
end
local lobby = Net.new()
check(lobby:connectTCP("127.0.0.1:" .. PORT),
"relay: connectTCP dials the real server: " .. tostring(lobby.error))
lobby:send({ type = "lobby_hello", protocol = 3, name = "RED", profiles = {} })
local welcome = waitFor(lobby, function(m) return m.type == "lobby_welcome" end)
check(welcome ~= nil and type(welcome.session) == "string",
"relay: a protocol 3 lobby_hello is welcomed over real TCP")
lobby:close()
local relaySrc = io.open("../pokeserver/relay.js", "r")
local relayText = relaySrc and relaySrc:read("*a") or ""
if relaySrc then relaySrc:close() end
if not relayText:find("upgrade_required", 1, true) then
print("skip real relay upgrade_required (../pokeserver predates protocol 3)")
else
local old = Net.new()
check(old:connectTCP("127.0.0.1:" .. PORT), "relay: a v1 client still dials")
old:send({ type = "host" })
local up = waitFor(old, function(m) return m.type == "upgrade_required" end)
check(up ~= nil, "relay: a v1 host answers upgrade_required")
old:close()
local stale = Net.new()
check(stale:connectTCP("127.0.0.1:" .. PORT), "relay: a v2 client still dials")
stale:send({ type = "lobby_hello", protocol = 2, name = "OLD", profiles = {} })
local up2 = waitFor(stale, function(m)
return m.type == "upgrade_required" or m.type == "lobby_welcome"
end)
eq(up2 and up2.type, "upgrade_required",
"relay: a protocol 2 lobby_hello answers upgrade_required")
stale:close()
end
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))))
local client3Ok, client3Err = pcall(dofile, "tests/online_client_gen3.lua")
check(client3Ok, "online client gen 3 suite"
.. (client3Ok and "" or (": " .. tostring(client3Err))))
-- ---------------------------------------------------------------- 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))))
local LUA = os.getenv("LUA") or "luajit"
local function standalone(path)
local status = os.execute(LUA .. " " .. path)
return status == true or status == 0
end
for _, suite in ipairs({
{ "tests/link3_lockstep_test.lua", "gen 3 lockstep suite" },
{ "tests/link3_arena_state_test.lua", "gen 3 arena state suite" },
{ "tests/link3_desync_fuzz.lua", "gen 3 lockstep desync fuzz" },
{ "tests/link3_multi_test.lua", "gen 3 multi battle lockstep suite" },
}) do
check(standalone(suite[1]), suite[2])
end
print(("\n%s"):format(failures == 0 and "ALL LINK TESTS PASSED" or failures .. " FAILURES"))
os.exit(failures == 0 and 0 or 1)