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

201 lines
7.5 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
local H = require("tests.link3_harness")
local Cache = require("tests.game3_cache")
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
if not Cache.root("meta.json") then
print("[skip] Gen 3 link lockstep runs the real battle engine on ROM data: " .. tostring(Cache.reason))
os.exit(0)
end
H.mountCache()
local function mon(species, level, extra)
local Pokemon = require("src.core.game3.pokemon")
local moves = Pokemon.movesAtLevel(species, level)
local m = {
species = species, level = level, moves = moves, personality = extra and extra.personality or 0,
ivs = { hp = 20, atk = 20, def = 20, spe = 20, spa = 20, spd = 20 },
evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 },
item = extra and extra.item or 0,
}
return H.legal(m)
end
local function session(name, trainerId, gender)
return { name = name, trainerId = trainerId, gender = gender, party = {}, bag = {} }
end
local function singlesPolicy(w, st)
local b = st.player
local mon0 = b and b.mon or {}
local usable = {}
for i = 1, 4 do
if mon0.moves and mon0.moves[i] and (tonumber(mon0.pp and mon0.pp[i]) or 0) > 0 then usable[#usable + 1] = i end
end
local slot = usable[((st.turn or 0) % math.max(1, #usable)) + 1] or 1
w.Ui._pendingCommand = w.Commands.playerAction(st, 1, slot)
end
local function doublesPolicy(w, st)
local sel = w.Battle._dblSel
if not (sel and sel.active) then return end
local id = sel.active
local b = w.State.battler(st, id)
local mon0 = b and b.mon or {}
local slot = 1
for i = 1, 4 do
if mon0.moves and mon0.moves[i] and (tonumber(mon0.pp and mon0.pp[i]) or 0) > 0 then
slot = i
if (st.turn + id) % 2 == 0 then break end
end
end
local target = w.State.isPresent(st, 1) and 1 or 3
w.Ui._pendingCommand = w.Commands.playerAction(st, 1, slot, id, target)
end
local function runMatch(opts)
local relay = H.relay({ seed = opts.seed, seats = 2, lag = opts.lag or {}, roomSeed = opts.seed })
local partyA = opts.partyA
local partyB = opts.partyB
local w0 = H.newWorld("seat0", session("RED", 0x1234, 0))
local w1 = H.newWorld("seat1", session("LEAF", 0x5678, 1))
local ws = H.newWorld("spectator", session("WATCHER", 0x9999, 0))
H.attachSeat(w0, relay, 0, { mode = opts.mode, myParty = H.pack(partyA),
profile = { rule = {} } })
H.attachSeat(w1, relay, 1, { mode = opts.mode, myParty = H.pack(partyB),
profile = { rule = {} } })
if not opts.lateSpectator then H.attachSpectator(ws, relay, { mode = opts.mode, profile = { rule = {} } }) end
local policy = opts.mode == "double" and doublesPolicy or singlesPolicy
local frames, spectating = 0, not opts.lateSpectator
while frames < 20000 do
frames = frames + 1
for _ = 1, opts.stepsA or 1 do H.step(w0, policy) end
for _ = 1, opts.stepsB or 1 do H.step(w1, policy) end
if not spectating then
local st = H.run(w0, function() return w0.Battle.getState() end)
if st and (st.turn or 0) >= 3 then
H.attachSpectator(ws, relay, { mode = opts.mode, profile = { rule = {} } })
spectating = true
end
end
if spectating then H.step(ws, nil) end
relay:tick()
if w0.result and w1.result and (ws.result or not spectating) then break end
end
return w0, w1, ws, relay, frames
end
local function hashesOf(w)
return H.run(w, function()
local out = {}
for turn, value in pairs(w.LB._myHashes or {}) do out[turn] = value end
return out
end)
end
local function sameHashes(a, b)
local n = 0
for turn, value in pairs(a) do
if b[turn] ~= nil then
n = n + 1
if b[turn] ~= value then return false, n, turn end
end
end
return true, n
end
local MIRROR = { win = "lose", lose = "win", draw = "draw" }
local function verify(label, w0, w1, ws, relay)
eq(w0.result ~= nil and w1.result ~= nil, true, label .. ": both seats finished")
eq(MIRROR[w0.result], w1.result, label .. ": the results mirror (" .. tostring(w0.result) .. "/" .. tostring(w1.result) .. ")")
local r0 = H.run(w0, function() return w0.LB.endReason end)
local r1 = H.run(w1, function() return w1.LB.endReason end)
eq(r0, nil, label .. ": seat 0 saw no desync")
eq(r1, nil, label .. ": seat 1 saw no desync")
local h0, h1, hs = hashesOf(w0), hashesOf(w1), hashesOf(ws)
local ok, n, turn = sameHashes(h0, h1)
check(ok and n >= 2, label .. ": every turn's digest matched across seats (" .. tostring(n) .. " turns" .. (turn and (", split at " .. turn) or "") .. ")")
local okS, nS = sameHashes(h0, hs)
check(okS and nS >= 1, label .. ": the spectator's own simulation digested the same (" .. tostring(nS) .. " turns)")
eq(ws.result, "ended", label .. ": the spectator watched to the end")
eq(#w0.reports, 1, label .. ": seat 0 reported once")
eq(#w1.reports, 1, label .. ": seat 1 reported once")
eq(MIRROR[w0.reports[1]], w1.reports[1], label .. ": and the reports mirror")
eq(#ws.reports, 0, label .. ": the spectator never reports")
local spectatorSent = false
for _, row in ipairs(relay.log) do if row.seat == nil then spectatorSent = true end end
check(not spectatorSent, label .. ": the spectator never sent a message")
local kinds = {}
for _, row in ipairs(relay.log) do kinds[row.msg.type] = (kinds[row.msg.type] or 0) + 1 end
check((kinds.game3_battle_hash or 0) >= 4, label .. ": hashes crossed the relay")
check((kinds.game3_battle_outcome or 0) == 2, label .. ": each seat sent one game3_battle_outcome")
local st0 = H.run(w0, function() return w0.Battle.getState() end)
local stS = H.run(ws, function() return ws.Battle.getState() end)
local hp0, hpS = {}, {}
for i, m in ipairs(st0.playerParty) do hp0[i] = tonumber(m.hp) end
for i, m in ipairs(stS.playerParty) do hpS[i] = tonumber(m.hp) end
eq(table.concat(hp0, ","), table.concat(hpS, ","), label .. ": the spectator's view of seat 0's party ends the same")
end
print("[test] 1. a singles arena match over the relay, both seats and a spectator")
do
local w0, w1, ws, relay = runMatch({
seed = 0x13579, mode = "single",
partyA = { mon(6, 50), mon(25, 50), mon(131, 50) },
partyB = { mon(9, 50), mon(3, 50), mon(143, 50) },
})
verify("singles", w0, w1, ws, relay)
end
print("[test] 2. the same with lag and uneven frame rates on each side")
do
local w0, w1, ws, relay = runMatch({
seed = 0x2468A, mode = "single", lag = { [0] = 2, [1] = 0, spectator = 3 }, stepsA = 3, stepsB = 1,
partyA = { mon(94, 50), mon(65, 50) },
partyB = { mon(68, 50), mon(59, 50) },
})
verify("singles lag", w0, w1, ws, relay)
end
print("[test] 3. a doubles arena match with a spectator")
do
local w0, w1, ws, relay = runMatch({
seed = 0x5A5A5, mode = "double",
partyA = { mon(6, 50), mon(9, 50), mon(3, 50) },
partyB = { mon(130, 50), mon(143, 50), mon(65, 50) },
})
verify("doubles", w0, w1, ws, relay)
end
print("[test] 4. a spectator who joins at turn 3 replays the log and catches up")
do
local w0, w1, ws, relay = runMatch({
seed = 0x777, mode = "single", lateSpectator = true,
partyA = { mon(149, 50), mon(26, 50) },
partyB = { mon(248, 50), mon(59, 50) },
})
verify("late spectator", w0, w1, ws, relay)
end
if failed == 0 then
print("[pass] link3 lockstep")
os.exit(0)
end
print("[fail] link3 lockstep: " .. failed)
os.exit(1)