mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 05:32:07 -04:00
587 lines
24 KiB
Lua
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)
|