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

587 lines
24 KiB
Lua

package.path = "./?.lua;./?/init.lua;" .. package.path
local H = require("tests.link3_harness")
local Cache = require("tests.game3_cache")
if not Cache.root("meta.json") then
print("[skip] Gen 3 link desync fuzz runs the real battle engine on ROM data: " .. tostring(Cache.reason))
os.exit(0)
end
H.mountCache()
require("src.core.Logger").warn = function() end
local quietPrint = print
local function silent(fn)
print = function() end
local ok, a, b, c = pcall(fn)
print = quietPrint
if not ok then error(a, 0) end
return a, b, c
end
local function makeRandom(seed)
local s = seed % 2147483647
if s <= 0 then s = s + 2147483646 end
return function(a, b)
s = (s * 16807) % 2147483647
if b == nil then a, b = 1, a end
return a + (s % (b - a + 1))
end
end
local ITEM_NAMES = {
"LEFTOVERS", "KINGS_ROCK", "QUICK_CLAW", "FOCUS_BAND", "BRIGHT_POWDER", "SCOPE_LENS",
"SHELL_BELL", "LUM_BERRY", "SITRUS_BERRY", "CHESTO_BERRY", "WHITE_HERB", "MENTAL_HERB",
"CHOICE_BAND", "ORAN_BERRY", "PERSIM_BERRY", "CHARCOAL", "MYSTIC_WATER", "SHARP_BEAK",
}
local ITEMS = {}
do
local ItemsData = require("src.core.game3.items_data")
pcall(ItemsData.ensureLoaded)
for _, name in ipairs(ITEM_NAMES) do
local ok, id = pcall(ItemsData.toNumericId, name)
if ok and tonumber(id) and tonumber(id) > 0 then ITEMS[#ITEMS + 1] = tonumber(id) end
end
end
local Pokemon = require("src.core.game3.pokemon")
local function randomMon(rnd)
for _ = 1, 50 do
local species = rnd(1, 411)
if (species < 252 or species > 276) and Pokemon.isInternalSpecies(species) then
local level = rnd(25, 60)
local pool = Pokemon.movesAtLevel(species, level) or {}
local learn = {}
for _, row in ipairs(Pokemon.learnset(species) or {}) do
local lv, mv = tonumber(row[1] or row.level), tonumber(row[2] or row.move)
if lv and mv and lv <= level then learn[#learn + 1] = mv end
end
local moves, seen = {}, {}
for _ = 1, 4 do
local src = (#learn > 0 and rnd(1, 3) == 1) and learn or pool
local mv = src[rnd(1, math.max(1, #src))]
if mv and not seen[mv] then
seen[mv] = true
moves[#moves + 1] = mv
end
end
if #moves > 0 then
local evs, left = {}, 510
for _, key in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do
local v = math.min(left, rnd(0, 255))
evs[key] = v
left = left - v
end
local mon = {
species = species, level = level, moves = moves,
personality = rnd(0, 65535) * 65536 + rnd(0, 65535),
ivs = { hp = rnd(0, 31), atk = rnd(0, 31), def = rnd(0, 31), spe = rnd(0, 31), spa = rnd(0, 31), spd = rnd(0, 31) },
evs = evs,
item = (#ITEMS > 0 and rnd(1, 100) <= 60) and ITEMS[rnd(1, #ITEMS)] or 0,
friendship = rnd(0, 255),
}
return H.legal(mon)
end
end
end
return H.legal({ species = 6, level = 50, moves = { 53 } })
end
local function randomParty(rnd, n)
local out = {}
for i = 1, n do out[i] = randomMon(rnd) end
return out
end
local function usableSlots(st, b)
local out = {}
local mon = b and b.mon or {}
for i = 1, 4 do
if mon.moves and mon.moves[i] and mon.moves[i] ~= 0 and (tonumber(mon.pp and mon.pp[i]) or 0) > 0 then
out[#out + 1] = i
end
end
return out
end
local function makePolicy(rnd, runChance)
return function(w, st)
if st.double then
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)
if rnd(1, 1000) <= runChance then
w.Ui._pendingCommand = { kind = "run", user = "player", battler = id }
return
end
if rnd(1, 100) <= 8 then
for _ = 1, 4 do
local slot = rnd(1, #st.playerParty)
if not w.Commands.switchError(st, slot, false, id) then
w.Ui._pendingCommand = { kind = "switch", user = "player", battler = id, slot = slot }
return
end
end
end
local slots = usableSlots(st, b)
local slot = slots[rnd(1, math.max(1, #slots))] or 1
local targets = {}
for _, t in ipairs({ 1, 3, (id + 2) % 4 }) do
if w.State.isPresent(st, t) then targets[#targets + 1] = t end
end
w.Ui._pendingCommand = w.Commands.playerAction(st, 1, slot, id, targets[rnd(1, math.max(1, #targets))] or 1)
return
end
if rnd(1, 1000) <= runChance then
w.Ui._pendingCommand = { kind = "run", user = "player" }
return
end
if rnd(1, 100) <= 8 then
for _ = 1, 4 do
local slot = rnd(1, #st.playerParty)
if slot ~= st.player.partyIndex and not w.Commands.switchError(st, slot) then
w.Ui._pendingCommand = { kind = "switch", user = "player", slot = slot }
return
end
end
end
local slots = usableSlots(st, st.player)
w.Ui._pendingCommand = w.Commands.playerAction(st, 1, slots[rnd(1, math.max(1, #slots))] or 1)
end
end
local MODES = { "clean", "clean", "clean", "seed", "rng", "hp", "party" }
local MIRROR = { win = "lose", lose = "win", draw = "draw" }
local function hashes(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 firstSplit(a, b)
local found
for turn, value in pairs(a) do
if b[turn] and b[turn] ~= value and (not found or turn < found) then found = turn end
end
return found
end
local function runOne(seed)
local rnd = makeRandom((seed * 69621 + 7) % 2147483647)
for _ = 1, 5 do rnd(1, 2) end
local mode = MODES[rnd(1, #MODES)]
local battleMode = rnd(1, 3) == 1 and "double" or "single"
local lagA, lagB = rnd(0, 3), rnd(0, 3)
local stepsA, stepsB = rnd(1, 3), rnd(1, 3)
local auto = rnd(1, 5) == 1
local watch = rnd(1, 2) == 1
local injectTurn = rnd(2, 4)
local sizeA = battleMode == "double" and rnd(2, 4) or rnd(1, 4)
local sizeB = battleMode == "double" and rnd(2, 4) or rnd(1, 4)
local partyA = randomParty(rnd, sizeA)
local partyB = randomParty(rnd, sizeB)
local tamper
if mode == "party" then
tamper = function(fromSeat, toSeat, msg)
if fromSeat == 0 and toSeat == 1 and msg.type == "game3_battle_setup" and type(msg.party) == "table" then
local m = msg.party[1]
for _, mv in ipairs(m and m.moves or {}) do
if (tonumber(mv.pp) or 0) > 0 then
mv.pp = mv.pp - 1
break
end
end
end
return msg
end
end
local relay = H.relay({ seed = rnd(1, 2147483000), seats = 2, roomSeed = seed,
lag = { [0] = lagA, [1] = lagB, spectator = rnd(0, 3) }, tamper = tamper })
local w0 = H.newWorld("seat0", { name = "GOLD", trainerId = rnd(0, 65535), gender = 0, party = {}, bag = {} })
local w1 = H.newWorld("seat1", { name = "SILVER", trainerId = rnd(0, 65535), gender = 1, party = {}, bag = {} })
local ws = watch and H.newWorld("spectator", { name = "WATCH", trainerId = 1, gender = 0, party = {}, bag = {} }) or nil
local policyA = makePolicy(makeRandom(seed * 7 + 1), mode == "clean" and 4 or 0)
local policyB = makePolicy(makeRandom(seed * 7 + 2), mode == "clean" and 4 or 0)
silent(function()
H.attachSeat(w0, relay, 0, { mode = battleMode, myParty = H.pack(partyA), profile = { rule = {} }, autoFight = auto })
H.attachSeat(w1, relay, 1, { mode = battleMode, myParty = H.pack(partyB), profile = { rule = {} }, autoFight = auto,
seedOverride = mode == "seed" and (relay.seed + 1) or nil })
if ws then H.attachSpectator(ws, relay, { mode = battleMode, profile = { rule = {} } }) end
end)
local trace = os.getenv("LINK3_FUZZ_TRACE") == "1"
for _, w in ipairs({ w0, w1 }) do
H.run(w, function()
w.LB.keepRaw = true
if trace then
w.acts = {}
local send = w.LB.sendAction
w.LB.sendAction = function(turn, act)
w.acts[#w.acts + 1] = ("send t%s %s slot=%s move=%s"):format(tostring(turn), tostring(act and act.kind), tostring(act and act.slot), tostring(act and act.move))
return send(turn, act)
end
local Engine = require("src.core.game3.battle.engine")
local resolve = Engine.resolveMove
Engine.resolveMove = function(user, target, moveId, slot, ...)
local st = w.Battle.getState()
w.acts[#w.acts + 1] = ("resolve t%s user=%s move=%s slot=%s"):format(tostring(st and st.turn), tostring(type(user) == "table" and user.id or user), tostring(moveId), tostring(slot))
return resolve(user, target, moveId, slot, ...)
end
local plan = Engine.planTurnFromActions
Engine.planTurnFromActions = function(st, ad, p, e)
w.acts[#w.acts + 1] = ("plan t%s p=%s/%s/%s e=%s/%s/%s"):format(tostring(st.turn), tostring(p and p.kind), tostring(p and p.slot), tostring(p and p.move), tostring(e and e.kind), tostring(e and e.slot), tostring(e and e.move))
return plan(st, ad, p, e)
end
w.draws = {}
local make = w.LB.makeRng
w.LB.makeRng = function(s, counter)
local fn = make(s, counter)
return function(lo, hi)
local v = fn(lo, hi)
local tb = debug.traceback("", 2):gsub("\n%s*", " < "):sub(1, 400)
w.draws[#w.draws + 1] = tostring(lo) .. "," .. tostring(hi) .. "=" .. tostring(v) .. tb
return v
end
end
end
end)
end
local injected, injectedAt = false, nil
local guard = 0
local label = ("seed %d [%s %s lag %d/%d steps %d/%d%s%s]"):format(seed, mode, battleMode, lagA, lagB,
stepsA, stepsB, auto and " auto" or "", ws and " watched" or "")
silent(function()
while guard < 30000 do
guard = guard + 1
for _ = 1, stepsA do H.step(w0, policyA) end
for _ = 1, stepsB do H.step(w1, policyB) end
if ws then H.step(ws, nil) end
relay:tick()
if (mode == "rng" or mode == "hp") and not injected then
H.run(w1, function()
local st = w1.Battle.getState()
if st and w1.Battle.isActive() and st.turn >= injectTurn and w1.Battle._phase == "command" then
injected = true
injectedAt = st.turn
if mode == "rng" then
st.rng(0, 1)
else
local foe = st.enemy and st.enemy.mon
if foe and (tonumber(foe.hp) or 0) > 1 then foe.hp = foe.hp - 1 else st.player.mon.hp = math.max(1, st.player.mon.hp - 1) end
end
end
end)
end
if w0.result and w1.result and (not ws or ws.result) then break end
end
end)
local turns = H.run(w0, function() local st = w0.Battle.getState() return st and st.turn or 0 end)
if not (w0.result and w1.result) then
local detail = ""
if os.getenv("LINK3_FUZZ_LOG") == "1" then
for _, w in ipairs({ w0, w1 }) do
H.run(w, function()
local st = w.Battle.getState()
detail = detail .. ("\n %s phase=%s lb=%s turn=%s sw=%s ui=%s"):format(w.name, tostring(w.Battle._phase),
tostring(w.LB.state), tostring(st and st.turn), tostring(w.Battle._linkSwitch and w.Battle._linkSwitch.side),
tostring(w.Ui._mode))
local log = w.Ui.log() or {}
for i = math.max(1, #log - 12), #log do
detail = detail .. "\n " .. w.name .. " | " .. tostring(log[i]):gsub("\n", " ")
end
end)
end
end
return label .. ": did not finish (" .. tostring(w0.result) .. "/" .. tostring(w1.result) .. ") at turn " .. tostring(turns) .. detail, turns
end
if ws and not ws.result then return label .. ": the spectator never finished", turns end
local r0 = H.run(w0, function() return w0.LB.endReason end)
local r1 = H.run(w1, function() return w1.LB.endReason end)
local h0, h1 = hashes(w0), hashes(w1)
if mode == "clean" then
local split = firstSplit(h0, h1)
if split then
local a = H.run(w0, function() return w0.LB._raw and w0.LB._raw[split] end) or {}
local b = H.run(w1, function() return w1.LB._raw and w1.LB._raw[split] end) or {}
local detail = ""
for _, part in ipairs({ "actives", "volatile", "bench", "field" }) do
if a[part] ~= b[part] then
detail = ("\n %s:\n seat0 %s\n seat1 %s"):format(part, tostring(a[part]), tostring(b[part]))
break
end
end
if trace then
for i = 1, math.max(#(w0.draws or {}), #(w1.draws or {})) do
local da, db = w0.draws[i], w1.draws[i]
local ka = da and da:match("^[^<]*")
local kb = db and db:match("^[^<]*")
if ka ~= kb or (da and db and da:match("< ([^<]*) <") ~= db:match("< ([^<]*) <")) then
detail = detail .. ("\n draw %d\n seat0 %s\n seat1 %s"):format(i, tostring(da), tostring(db))
break
end
end
end
if trace then
for _, w in ipairs({ w0, w1 }) do
for i = math.max(1, #w.acts - 14), #w.acts do detail = detail .. "\n " .. w.name .. " # " .. w.acts[i] end
end
end
if os.getenv("LINK3_FUZZ_LOG") == "1" then
for _, w in ipairs({ w0, w1 }) do
H.run(w, function()
local log = w.Ui.log() or {}
for i = math.max(1, #log - 40), #log do
detail = detail .. "\n " .. w.name .. " | " .. tostring(log[i]):gsub("\n", " ")
end
end)
end
end
return label .. ": turn " .. split .. " hash split" .. detail, turns
end
if r0 == "desync" or r1 == "desync" then return label .. ": a clean run desynced (" .. tostring(r0) .. "/" .. tostring(r1) .. ")", turns end
if MIRROR[w0.result] ~= w1.result then
local detail = ""
if os.getenv("LINK3_FUZZ_LOG") == "1" then
for _, w in ipairs({ w0, w1 }) do
H.run(w, function()
local log = w.Ui.log() or {}
for i = math.max(1, #log - 30), #log do
detail = detail .. "\n " .. w.name .. " | " .. tostring(log[i]):gsub("\n", " ")
end
end)
end
end
return label .. ": results disagree (" .. tostring(w0.result) .. " vs " .. tostring(w1.result) .. ")" .. detail, turns
end
if MIRROR[w0.reports[1]] ~= w1.reports[1] or #w0.reports ~= 1 or #w1.reports ~= 1 then
return label .. ": reports disagree", turns
end
if ws then
if ws.result ~= "ended" then return label .. ": the spectator ended " .. tostring(ws.result), turns end
local split2 = firstSplit(h0, hashes(ws))
if split2 then return label .. ": the spectator split from seat 0 at turn " .. split2, turns end
end
return nil, turns
end
if (mode == "seed" or mode == "party") and turns <= 1 then return nil, turns end
if (mode == "seed" or mode == "party") and not firstSplit(h0, h1) and MIRROR[w0.result] == w1.result then return nil, turns end
if w0.result ~= "draw" or w1.result ~= "draw" then
if (mode == "rng" or mode == "hp") and not injected then return nil, turns end
return label .. ": a " .. mode .. " mismatch was not caught (" .. tostring(w0.result) .. "/" .. tostring(w1.result) .. ")", turns
end
if r0 ~= "desync" and r1 ~= "desync" then
return label .. ": the draw was not a desync (" .. tostring(r0) .. "/" .. tostring(r1) .. ")", turns
end
if (mode == "rng" or mode == "hp") and turns > (injectedAt or injectTurn) + 2 then
return label .. ": caught late at turn " .. turns .. " (injected at " .. tostring(injectedAt) .. ")", turns
end
if ws and ws.result ~= "ended" and ws.result ~= "error" then
return label .. ": the spectator ended " .. tostring(ws.result), turns
end
return nil, turns
end
local function runMulti(seed)
local rnd = makeRandom((seed * 48271 + 12345) % 2147483647)
for _ = 1, 5 do rnd(1, 2) end
local mode = MODES[rnd(1, #MODES)]
local lag, steps = { spectator = rnd(0, 3) }, {}
for seat = 0, 3 do
lag[seat] = rnd(0, 3)
steps[seat] = rnd(1, 3)
end
local auto = rnd(1, 5) == 1
local watch = rnd(1, 2) == 1
local injectTurn = rnd(2, 4)
local odd = rnd(0, 3)
local parties = {}
for seat = 0, 3 do parties[seat] = randomParty(rnd, rnd(1, 3)) end
local tamper
if mode == "party" then
tamper = function(fromSeat, toSeat, msg)
if fromSeat == odd and toSeat == (odd + 1) % 4 and msg.type == "game3_battle_setup" and type(msg.party) == "table" then
local m = msg.party[1]
for _, mv in ipairs(m and m.moves or {}) do
if (tonumber(mv.pp) or 0) > 0 then
mv.pp = mv.pp - 1
break
end
end
end
return msg
end
end
local relay = H.relay({ seed = rnd(1, 2147483000), seats = 4, roomSeed = seed + 100000, lag = lag, tamper = tamper })
local names = { [0] = "GOLD", [1] = "SILVER", [2] = "CRYS", [3] = "EMER" }
local ws = {}
for seat = 0, 3 do
ws[seat] = H.newWorld("multi" .. seat, { name = names[seat], trainerId = rnd(0, 65535), gender = seat % 2,
party = {}, bag = {} })
end
local wsp = watch and H.newWorld("multiwatch", { name = "WATCH", trainerId = 1, gender = 0, party = {}, bag = {} }) or nil
local policies = {}
for seat = 0, 3 do policies[seat] = makePolicy(makeRandom(seed * 11 + seat + 1), mode == "clean" and 3 or 0) end
silent(function()
for seat = 0, 3 do
H.attachSeat(ws[seat], relay, seat, { mode = "multi", myParty = H.pack(parties[seat]), profile = { rule = {} },
autoFight = auto, seedOverride = (mode == "seed" and seat == odd) and (relay.seed + 1) or nil })
H.run(ws[seat], function() ws[seat].LB.keepRaw = true end)
end
if wsp then H.attachSpectator(wsp, relay, { mode = "multi", profile = { rule = {} } }) end
end)
local label = ("multi seed %d [%s lag %d/%d/%d/%d%s%s odd %d]"):format(seed, mode, lag[0], lag[1], lag[2], lag[3],
auto and " auto" or "", wsp and " watched" or "", odd)
local injected, guard, injectedAt = false, 0, nil
local function allDone()
for seat = 0, 3 do if not ws[seat].result then return false end end
return not wsp or wsp.result ~= nil
end
silent(function()
while guard < 40000 do
guard = guard + 1
for seat = 0, 3 do
for _ = 1, steps[seat] do H.step(ws[seat], policies[seat]) end
end
if wsp then H.step(wsp, nil) end
relay:tick()
if (mode == "rng" or mode == "hp") and not injected then
local w = ws[odd]
H.run(w, function()
local st = w.Battle.getState()
if st and w.Battle.isActive() and st.turn >= injectTurn and w.Battle._phase == "command" then
injected = true
injectedAt = st.turn
if mode == "rng" then
st.rng(0, 1)
else
local foe = w.State.battler(st, 1)
local mine = w.State.battler(st, st.linkOwn or 0)
if foe and (tonumber(foe.mon.hp) or 0) > 1 then foe.mon.hp = foe.mon.hp - 1
elseif mine then mine.mon.hp = math.max(1, mine.mon.hp - 1) end
end
end
end)
end
if allDone() then break end
end
end)
local turns = H.run(ws[0], function() local st = ws[0].Battle.getState() return st and st.turn or 0 end)
for seat = 0, 3 do
if not ws[seat].result then
local detail = ""
if os.getenv("LINK3_FUZZ_LOG") == "1" then
for s = 0, 3 do
H.run(ws[s], function()
local st = ws[s].Battle.getState()
detail = detail .. ("\n seat%d phase=%s lb=%s turn=%s sw=%s result=%s"):format(s, tostring(ws[s].Battle._phase),
tostring(ws[s].LB.state), tostring(st and st.turn), tostring(ws[s].Battle._linkSwitch and ws[s].Battle._linkSwitch.battler),
tostring(ws[s].result))
local log = ws[s].Ui.log() or {}
for i = math.max(1, #log - 6), #log do detail = detail .. "\n seat" .. s .. " | " .. tostring(log[i]):gsub("\n", " ") end
end)
end
end
return label .. ": seat " .. seat .. " did not finish at turn " .. tostring(turns) .. detail, turns
end
end
if wsp and not wsp.result then return label .. ": the spectator never finished", turns end
local reasons, h = {}, {}
for seat = 0, 3 do
reasons[seat] = H.run(ws[seat], function() return ws[seat].LB.endReason end)
h[seat] = hashes(ws[seat])
end
if mode == "clean" then
for seat = 1, 3 do
local split = firstSplit(h[0], h[seat])
if split then
local a = H.run(ws[0], function() return ws[0].LB._raw and ws[0].LB._raw[split] end) or {}
local b = H.run(ws[seat], function() return ws[seat].LB._raw and ws[seat].LB._raw[split] end) or {}
local detail = ""
for _, part in ipairs({ "actives", "volatile", "bench", "field" }) do
if a[part] ~= b[part] then
detail = ("\n %s:\n seat0 %s\n seat%d %s"):format(part, tostring(a[part]), seat, tostring(b[part]))
break
end
end
return label .. ": seat " .. seat .. " split from seat 0 at turn " .. split .. detail, turns
end
end
for seat = 0, 3 do
if reasons[seat] == "desync" then return label .. ": a clean run desynced at seat " .. seat, turns end
end
if ws[2].result ~= ws[0].result or ws[3].result ~= ws[1].result or MIRROR[ws[0].result] ~= ws[1].result then
return label .. (": results disagree (%s/%s/%s/%s)"):format(tostring(ws[0].result), tostring(ws[1].result),
tostring(ws[2].result), tostring(ws[3].result)), turns
end
for seat = 0, 3 do
if #ws[seat].reports ~= 1 then return label .. ": seat " .. seat .. " reported " .. #ws[seat].reports .. " times", turns end
end
if wsp then
if wsp.result ~= "ended" then return label .. ": the spectator ended " .. tostring(wsp.result), turns end
local split = firstSplit(h[0], hashes(wsp))
if split then return label .. ": the spectator split from seat 0 at turn " .. split, turns end
if #wsp.reports ~= 0 then return label .. ": the spectator reported", turns end
end
return nil, turns
end
if (mode == "rng" or mode == "hp") and not injected then return nil, turns end
if (mode == "seed" or mode == "party") and turns <= 1 then return nil, turns end
if (mode == "seed" or mode == "party") and not (firstSplit(h[0], h[1]) or firstSplit(h[0], h[2]) or firstSplit(h[0], h[3]))
and ws[2].result == ws[0].result and MIRROR[ws[0].result] == ws[1].result then
return nil, turns
end
for seat = 0, 3 do
if ws[seat].result ~= "draw" then
return label .. (": a %s mismatch was not caught at seat %d (%s)"):format(mode, seat, tostring(ws[seat].result)), turns
end
if reasons[seat] ~= "desync" then
return label .. (": seat %d drew without a desync (%s)"):format(seat, tostring(reasons[seat])), turns
end
end
if (mode == "rng" or mode == "hp") and turns > (injectedAt or injectTurn) + 2 then
return label .. ": caught late at turn " .. turns .. " (injected at " .. tostring(injectedAt) .. ")", turns
end
if wsp and wsp.result ~= "ended" and wsp.result ~= "error" then
return label .. ": the spectator ended " .. tostring(wsp.result), turns
end
return nil, turns
end
local RUNS = tonumber(arg and arg[1]) or 40
local FIRST = tonumber(arg and arg[2]) or 1
local WHICH = (arg and arg[3]) or os.getenv("LINK3_FUZZ_MODE") or "all"
local failures, turns, multiRuns, multiTurns = 0, 0, 0, 0
for seed = FIRST, FIRST + RUNS - 1 do
if WHICH ~= "multi" then
local ok, why, t = pcall(runOne, seed)
turns = turns + (tonumber(t) or 0)
if not ok then
failures = failures + 1
print("FAIL gen3 desync fuzz seed " .. seed .. ": " .. tostring(why))
elseif why then
failures = failures + 1
print("FAIL gen3 desync fuzz " .. why)
end
end
if WHICH == "multi" or (WHICH == "all" and seed % 2 == 0) then
multiRuns = multiRuns + 1
local ok, why, t = pcall(runMulti, seed)
multiTurns = multiTurns + (tonumber(t) or 0)
if not ok then
failures = failures + 1
print("FAIL gen3 desync fuzz multi seed " .. seed .. ": " .. tostring(why))
elseif why then
failures = failures + 1
print("FAIL gen3 desync fuzz " .. why)
end
end
end
print(("gen3 link desync fuzz: %d runs, %d turns, %d multi runs, %d multi turns, %d failures"):format(
WHICH == "multi" and 0 or RUNS, turns, multiRuns, multiTurns, failures))
os.exit(failures == 0 and 0 or 1)