This commit is contained in:
bryanthaboi
2026-07-28 15:01:59 -04:00
parent 04ece522d7
commit a03f69926e
11 changed files with 1081 additions and 17 deletions
+24
View File
@@ -151,6 +151,30 @@ tradeoff vs. the relay). Headless tests drive the protocol over an
in-memory loopback (`Net.loopbackPair`); under LÖVE the same test file
also exercises real UDP pairing.
## Fair play in link and online matches
A link session is decided by the battle and nothing else, so for its
duration:
- **Game speed is pinned to normal.** The GAME SPEED option and
`POKEPORT_SPEED` are ignored from the moment LINK PLAY opens until it
closes, and apply again after. Fast-forward otherwise runs one peer's
queue faster than the peer it is locked to and drains a tournament shot
clock faster than the opponent racing it.
- **Online play runs vanilla.** Picking ONLINE MATCH or TOURNAMENT with
mods enabled offers to switch them all off and relaunch (mods merge at
boot, so a restart is the only way). The restart is confirmed, not
silent. They stay listed as disabled, ready to switch back on.
- **Only a meaningful split ends a match.** The per-turn state signature
both peers exchange is split three ways: `actives` and `bench` carry
species, HP, status, stat stages, PP and the rest of the party, and a
divergence there ends the match as a draw. `volatile` carries per-turn
flags both sides recompute anyway - a divergence there is logged and
reported to mods, and play continues.
The relay logs which component diverged on which turn, so a desync report
names something specific.
## Custom boot text
The boot sequence replaces the Nintendo / GAME FREAK identifiers with
+50 -11
View File
@@ -1240,6 +1240,31 @@ local function clearTrapping(battler)
battler.trapDamage = nil
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
-- selection opens, but the clear is skipped for a mon that must recharge or
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
-- glitch).
--
-- A method rather than three lines inside the menu branch, because two
-- other places need the identical rule at the identical point in the turn
-- and both got it wrong by not having it:
--
-- * guarded PER BATTLER, not off self.player alone. This runs on
-- whichever machine is looking at its own menu, and in a lockstep link
-- battle "self.player" is the host's mon on one peer and the guest's on
-- the other, so one shared guard let one peer clear both flags while
-- the other cleared neither -- a bogus desync draw in a winnable match.
-- * a tournament spectator never enters the menu phase at all (it has no
-- decision to make), so nothing cleared a flinch in its replay: the
-- flag survived into the next turn, ate a move the real players saw
-- land, and from there the replay was watching a different battle.
-- LinkBattle.newSpectator calls this at the head of every turn.
function BattleState:clearTurnFlinches()
for _, b in ipairs({ self.player, self.enemy }) do
if b and not (b.mustRecharge or b.rageMove) then b.flinched = false end
end
end
-- Actions that skip DisplayBattleMenu entirely (core.asm:300-310):
-- recharge, Rage, thrash, charge. Bide / trapping / being held do NOT
-- skip the menu -- the player can still item/switch (and must press
@@ -1260,11 +1285,17 @@ function BattleState:fightLockedAction(battler)
end
if battler.bideTurns then return { special = "bide" } end
-- held while the OPPONENT's trapping bit is set (live mirror so a
-- trap ended early by paralysis/faint frees the victim immediately)
-- trap ended early by paralysis/faint frees the victim immediately).
-- Read, not written: executeAction refreshes battler.boundTurns from the
-- same expression when the action actually runs, and that site runs on
-- both peers of a link battle. Storing it here instead wrote a hashed
-- field on whichever machine happened to open its own FIGHT menu, which
-- left the two peers holding boundTurns=0 against nil for the same
-- battler and ended the match as a desync over a mirror of a mirror.
local opp = battler.isPlayer and self.enemy or self.player
battler.boundTurns = opp and opp.trappingTurns
local bound = opp and opp.trappingTurns
and math.max(1, opp.trappingTurns) or nil
if battler.boundTurns then
if bound then
return { special = "bound" }
end
return nil
@@ -1301,9 +1332,23 @@ function BattleState:swapMoves(i, j)
require("src.core.Sound").play(self.data, "Swap")
end
function BattleState:update(dt)
-- One frame of the presentational clock: the BGP flash sequences, the
-- per-battler pic slide/hide programs, the send-out grow-in, the intro
-- slide and the screen-shake programs all advance in updateFx and nowhere
-- else. It lives behind its own entry point because a caller that has to
-- skip the rest of update() for a frame must still tick this, and a link
-- battle does exactly that on two hot paths -- waiting on the peer's action,
-- and draining a resolved lockstep turn. Skipping it there froze whatever
-- was mid-flight: a flash stuck on its inverted BGP step repainted the whole
-- UI in inverted shades, and a pic part-way through a slide-off or a grow-in
-- simply stayed gone -- for as long as the opponent took to choose.
function BattleState:tickFx()
self.frame = self.frame + 1
self:updateFx()
end
function BattleState:update(dt)
self:tickFx()
local input = self.game.input
-- safety net: HP/status changed outside a queued drain (level-up heals,
@@ -1386,13 +1431,7 @@ function BattleState:update(dt)
end
return
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared during
-- move selection, but the clear is skipped while the player must
-- recharge or is locked into Rage (core.asm:293-295 -- the Hyper
-- Beam flinch-recharge glitch)
if not (self.player.mustRecharge or self.player.rageMove) then
self.player.flinched, self.enemy.flinched = false, false
end
self:clearTurnFlinches()
-- only recharge/Rage/thrash/charge skip DisplayBattleMenu; trapping
-- victims (and wrappers) still get FIGHT/PKMN/ITEM/RUN (core.asm:312)
local locked = self:menuLockedAction(self.player)
+9
View File
@@ -191,6 +191,15 @@ end
-- or screenshot run does not depend on whatever the player last chose.
function Game:logicSpeed()
local GameSpeed = require("src.core.GameSpeed")
-- Link play is always 1X on both machines, and this wins over every other
-- source including POKEPORT_SPEED. Fast-forward multiplies the logic
-- clock, so a peer at 10X burned a tournament shot clock ten times faster
-- than the opponent it is racing, and drove its own animation/message
-- queue at a different rate than the peer it is locked to. Nothing about
-- a match should depend on what either player set this to.
if self.linkSession or (self.linkNet and not self.linkNet.closed) then
return 1
end
if self.speedOverride then return GameSpeed.clamp(self.speedOverride) end
local opts = self.save and self.save.options
return GameSpeed.clamp(opts and opts.speed or GameSpeed.DEFAULT)
+61 -2
View File
@@ -112,6 +112,19 @@ local function scalar(v)
return tostring(v)
end
-- A volatile slot is "off" three interchangeable ways -- absent, false, or
-- a counter sitting at 0 -- because every reader tests `if b.key then` or
-- `key > 0`. They have to hash the same, or a match ends over a difference
-- that does not exist. They routinely disagree: the menu-phase flinch
-- clear (BattleState:update) and the FIGHT-branch boundTurns mirror
-- (fightLockedAction) are written by whichever machine is sitting at ITS
-- OWN menu, and in a link battle that is a different battler on each side,
-- so one peer holds flinched=false / boundTurns=0 where the other still
-- holds nil with two identical simulations underneath.
local function off(v)
return v == nil or v == false or v == 0
end
local function stageStr(b)
local out = {}
for i, stat in ipairs(STAGES) do
@@ -131,7 +144,7 @@ end
local function volStr(b)
local out = {}
for _, key in ipairs(VOLATILE) do
if b[key] ~= nil then
if not off(b[key]) then
out[#out + 1] = key .. "=" .. scalar(b[key])
end
end
@@ -168,6 +181,17 @@ end
local PARTS = { "actives", "volatile", "bench" }
-- Which components are allowed to end a match. `actives` and `bench` carry
-- what decides one -- species, HP, status, stat stages, PP, the rest of the
-- party -- so a divergence there is a real split between the two
-- simulations and stays a draw. `volatile` is per-turn bookkeeping that
-- both sides recompute from the authoritative state every turn (see `off`
-- above): it can disagree for a turn without either side being wrong, and
-- when it does mean something real it lands in `actives` as damage or
-- status within a turn or two, where it is caught. Ending a match on it
-- alone cost players games they were winning, over nothing.
local FATAL_PART = { actives = true, bench = true }
-- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep
@@ -315,12 +339,23 @@ function LinkBattle.new(game, net, opts)
Logger.warn("link: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH })
localHash = localH, remoteHash = remoteH,
fatal = true })
endAsDraw(s, Strings(
"Link desync!\n%s differs.\fAre both games\nrunning the same\nmods?",
component))
end
-- a non-fatal component split: both sides log it and carry on, so the
-- match is decided by the battle rather than by bookkeeping
local function noteDrift(s, turn, component, localH, remoteH)
Logger.warn("link: %s drift on turn %s (%s vs %s) -- match continues",
component, tostring(turn), tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = false })
end
-- a verified turn stays recorded: consuming it here left a finished
-- battle holding 0-1 entries, so the whole-battle sweep the link suite
-- runs over localHashes had nothing left to compare
@@ -333,9 +368,12 @@ function LinkBattle.new(game, net, opts)
if mine and theirs then
for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then
if FATAL_PART[component] then
reportDesync(s, turn, component, mine[component], theirs[component])
return
end
noteDrift(s, turn, component, mine[component], theirs[component])
end
end
end
-- a v1 peer sends the combined value only
@@ -565,10 +603,19 @@ function LinkBattle.new(game, net, opts)
if net.closed and not s.linkEnded and not s.result then
endAsDraw(s)
end
-- Both early returns below skip baseUpdate, which is where the
-- presentational clock normally advances -- so tick it here, in the same
-- order baseUpdate would (fx, then the queue). Without this an
-- animation caught mid-flight froze for the whole wait: a flash stopped
-- on its inverted BGP step and repainted the UI in inverted shades, and
-- a pic part-way through a slide or grow-in stayed off screen, which is
-- the "the screen went inverted" / "a Pokemon just vanished" pair.
if s.phase == "waitRemote" then
s:tickFx()
return -- the other side is still choosing
end
if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then
s.afterQueue = "menu"
s.phase = "menu"
@@ -742,6 +789,13 @@ function LinkBattle.newSpectator(game, net, opts)
end
s:act(function()
-- the two real players cleared their flinch flags when their move
-- menu opened; a spectator has no menu, so it does it here instead,
-- at the same point in the turn (see BattleState:clearTurnFlinches).
-- Without this a flinch survived into the next turn and ate a move
-- that landed in the real match, and the replay -- sharing the RNG
-- stream -- was watching a different battle from that point on.
s:clearTurnFlinches()
local hostAction = hostMsg.kind ~= "switch" and hostMsg.kind ~= "run"
and decodeWireAction(s, hostMsg, s.player) or nil
local guestAction = guestMsg.kind ~= "switch" and guestMsg.kind ~= "run"
@@ -837,7 +891,11 @@ function LinkBattle.newSpectator(game, net, opts)
if net.closed and not s.linkEnded and not s.result then
endSpectate(s)
end
-- same as the real-participant loop: every path that skips baseUpdate
-- still has to advance the presentational clock, and a spectator sits in
-- waitBoth between every single turn
if s.phase == "messages" and s.afterQueue == "linkNext" then
s:tickFx()
if not s:updateQueue() then
s.afterQueue = "waitBoth"
s.phase = "waitBoth"
@@ -845,6 +903,7 @@ function LinkBattle.newSpectator(game, net, opts)
return
end
if s.phase == "menu" or s.phase == "waitBoth" then
s:tickFx()
return -- frozen between resolved turns; never a real decision here
end
baseUpdate(s, dt)
+46 -1
View File
@@ -73,6 +73,9 @@ end
function LinkState.new(game)
local self = setmetatable({}, LinkState)
self.game = game
-- a link session runs at 1X on both machines whatever either player set
-- GAME SPEED to (see Game:logicSpeed); cleared in exitWith
game.linkSession = true
self.stage = "menu"
self.index = 1
self.addr = ipDigits(Net.lanIP())
@@ -98,6 +101,7 @@ end
function LinkState:exitWith(message, reason)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Runtime.emit("link.ended", { reason = reason or (message and "error" or "bye") })
if self.net then self.net:close() end
self.game.stack:pop()
@@ -106,6 +110,46 @@ function LinkState:exitWith(message, reason)
end
end
-- Online play meets strangers, so it requires vanilla on both ends
-- (Handshake.onlineAllowed). Mods merge into the shared Data registries at
-- boot and there is no unmerge, so switching them off has to go through a
-- relaunch -- but the player should not have to go find the mod manager and
-- work out which mods count. This turns every enabled mod off, records
-- them so the mod manager can put them back, and relaunches. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
local mods = Handshake.mods(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
end
local list = table.concat(names, ", ")
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
local text = Strings(
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
self.game.linkSession = nil
Runtime.emit("link.ended", { reason = "error" })
if self.net then self.net:close() end
game.stack:pop()
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
if not yes then return end
-- setEnabled persists the toggle itself (Loader:_saveState), so the
-- relaunch comes up vanilla and the mod manager lists them as disabled
-- for the player to switch back on afterwards
for _, mod in ipairs(mods) do
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
end
if game.restartWithMods then
game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
end
end }))
end
-- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two
@@ -202,7 +246,7 @@ function LinkState:update(dt)
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:exitWith(Strings("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS."))
self:offerVanillaRestart()
return
end
if self.index == 2 then
@@ -509,6 +553,7 @@ function LinkState:updateTrade(input)
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.game.linkSession = nil -- this path pops without exitWith
self.net:close()
self.game.stack:pop()
local game = self.game
+6
View File
@@ -91,6 +91,11 @@ function Tournament.new(game)
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
-- everything from here to exitWith runs at 1X regardless of the GAME
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
-- down on the logic step, so fast-forward would hand one player less
-- real time to choose than the opponent they are racing
game.linkSession = true
Sound.startLoop(game.data, MUSIC)
return self
end
@@ -113,6 +118,7 @@ end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
+144
View File
@@ -0,0 +1,144 @@
-- Driver: play a real online match, hosting side.
--
-- Pair with tests/drivers/online_match_join.lua in a second instance:
--
-- POKEPORT_IDENTITY=pokehost ONLINE_CODE_FILE=/tmp/poke_code.txt \
-- POKEPORT_DRIVER=tests/drivers/online_match_host.lua love .
-- POKEPORT_IDENTITY=pokeguest ONLINE_CODE_FILE=/tmp/poke_code.txt \
-- POKEPORT_DRIVER=tests/drivers/online_match_join.lua love .
--
-- Two real windows, the real relay (POKEPORT_RELAY_ADDR to point elsewhere),
-- the real LinkState menus and the real lockstep battle -- the loopback
-- suites can't see anything the transport, the two separate processes or
-- the two save identities contribute. The host writes its room code to
-- ONLINE_CODE_FILE for the joiner to pick up, since on a real screen that
-- code is read aloud to a friend.
--
-- Prints ONLINE_MATCH_HOST: lines; the wrapper script greps them.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local LinkState = require("src.link.LinkState")
local Runtime = require("src.mods.Runtime")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt"
local TAG = "ONLINE_MATCH_HOST:"
local function log(...) U.log(TAG, ...) end
-- a desync is the whole point of the exercise, so make it loud
local desyncs = {}
-- wrap emit rather than subscribing: with no mods loaded Runtime.events is
-- the null sink, and a vanilla online run is exactly the case under test
local realEmit = Runtime.emit
Runtime.emit = function(name, p)
if name == "link.desync" and p then
desyncs[#desyncs + 1] = p
log(("DESYNC turn=%s component=%s fatal=%s"):format(
tostring(p.turn), tostring(p.component), tostring(p.fatal)))
end
return realEmit(name, p)
end
game.save.player.name = "HOST"
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "SNORLAX", 50),
Pokemon.new(game.data, "ALAKAZAM", 50),
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(30)
local link = LinkState.new(game)
game.stack:push(link)
U.wait(10)
-- the GAME SPEED forcing under test: a link session pins the logic clock
-- to 1X no matter what the option or POKEPORT_SPEED says
game.save.options.speed = 10
U.wait(2)
log("logicSpeed with GAME SPEED=10 during link:", game:logicSpeed())
-- top menu: LAN / ONLINE MATCH / TOURNAMENT
U.tap(game, "down"); U.wait(3)
U.tap(game, "a"); U.wait(5) -- ONLINE MATCH
if link.stage ~= "onlineMenu" then
log("FAIL expected onlineMenu, at", tostring(link.stage))
-- a mods-enabled build lands on the vanilla-restart prompt instead
U.wait(60)
U.shot(game, DIR .. "/online_host_blocked.png")
return
end
U.tap(game, "a"); U.wait(10) -- HOST ONLINE
-- wait for the relay to hand back a room code
local code
for _ = 1, 900 do
if link.net and link.net.code then code = link.net.code break end
U.wait(1)
end
if not code then
log("FAIL no room code from the relay:", tostring(link.net and link.net.error))
U.shot(game, DIR .. "/online_host_nocode.png")
return
end
log("hosting code", code)
U.shot(game, DIR .. "/online_host_1_code.png")
local f = io.open(CODE_FILE, "w")
if f then f:write(code) f:close() end
-- wait for the joiner
for _ = 1, 1800 do
if link.stage == "modeSelect" then break end
U.wait(1)
end
if link.stage ~= "modeSelect" then
log("FAIL nobody joined (stage " .. tostring(link.stage) .. ")")
return
end
log("paired with", tostring(link.peerName))
U.shot(game, DIR .. "/online_host_2_paired.png")
U.tap(game, "down"); U.wait(3) -- TRADE / BATTLE -> BATTLE
U.tap(game, "a"); U.wait(10)
-- host owns the level rule; ANY is the default row
for _ = 1, 600 do
if link.stage == "battleOptions" then break end
U.wait(1)
end
if link.stage == "battleOptions" then
U.shot(game, DIR .. "/online_host_3_options.png")
U.tap(game, "a"); U.wait(5)
end
-- the battle: mash A, exactly like a player who just wants it over with
local battle
for _ = 1, 1800 do
local top = game.stack:top()
if top and top.kind == "link" then battle = top break end
U.wait(1)
end
if not battle then
log("FAIL battle never started (stage " .. tostring(link.stage) .. ")")
U.shot(game, DIR .. "/online_host_nobattle.png")
return
end
log("battle started vs", tostring(battle.opponentName))
U.shot(game, DIR .. "/online_host_4_battle.png")
local shots = 0
for i = 1, 200000 do
if battle.result then break end
U.tap(game, "a")
if i % 900 == 0 and shots < 3 then
shots = shots + 1
U.shot(game, DIR .. ("/online_host_5_turn%d.png"):format(shots))
end
end
U.wait(60)
U.shot(game, DIR .. "/online_host_6_result.png")
log("result", tostring(battle.result), "turns", tostring(battle.turnCount))
log("desyncs", #desyncs)
log("DONE")
end
+129
View File
@@ -0,0 +1,129 @@
-- Driver: play a real online match, joining side.
-- See tests/drivers/online_match_host.lua for how to run the pair.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local LinkState = require("src.link.LinkState")
local CodeEntry = require("src.link.CodeEntry")
local Runtime = require("src.mods.Runtime")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local CODE_FILE = os.getenv("ONLINE_CODE_FILE") or "/tmp/poke_code.txt"
local TAG = "ONLINE_MATCH_JOIN:"
local function log(...) U.log(TAG, ...) end
local desyncs = {}
-- wrap emit rather than subscribing: with no mods loaded Runtime.events is
-- the null sink, and a vanilla online run is exactly the case under test
local realEmit = Runtime.emit
Runtime.emit = function(name, p)
if name == "link.desync" and p then
desyncs[#desyncs + 1] = p
log(("DESYNC turn=%s component=%s fatal=%s"):format(
tostring(p.turn), tostring(p.component), tostring(p.fatal)))
end
return realEmit(name, p)
end
game.save.player.name = "GUEST"
game.save.party = {
Pokemon.new(game.data, "BLASTOISE", 50),
Pokemon.new(game.data, "GENGAR", 50),
Pokemon.new(game.data, "DRAGONITE", 50),
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(30)
-- the host writes its room code out once the relay assigns one
local code
for _ = 1, 3600 do
local f = io.open(CODE_FILE, "r")
if f then
local text = (f:read("*a") or ""):gsub("%s+", "")
f:close()
if #text == CodeEntry.LENGTH then code = text break end
end
U.wait(1)
end
if not code then
log("FAIL host never published a code")
return
end
log("joining code", code)
local link = LinkState.new(game)
game.stack:push(link)
U.wait(10)
game.save.options.speed = 20
U.wait(2)
log("logicSpeed with GAME SPEED=20 during link:", game:logicSpeed())
log("before nav: stage", tostring(link.stage), "index", tostring(link.index),
"top", tostring(game.stack:top() == link), "mods", #require("src.link.Handshake").mods(game))
U.tap(game, "down"); U.wait(3)
log("after down: stage", tostring(link.stage), "index", tostring(link.index))
U.tap(game, "a"); U.wait(5) -- ONLINE MATCH
log("after a: stage", tostring(link.stage), "index", tostring(link.index))
if link.stage ~= "onlineMenu" then
log("FAIL expected onlineMenu, at", tostring(link.stage))
U.wait(60)
U.shot(game, DIR .. "/online_join_blocked.png")
return
end
U.tap(game, "down"); U.wait(3)
U.tap(game, "a"); U.wait(5) -- JOIN ONLINE -> code entry
if link.stage ~= "codeEntry" then
log("FAIL expected codeEntry, at", tostring(link.stage))
return
end
-- set the six slots straight rather than scrubbing each one with UP
-- presses; the scrub interaction has its own coverage (online_play_test)
for i = 1, CodeEntry.LENGTH do
local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true)
link.codeEntry.chars[i] = idx or 1
end
U.wait(2)
U.shot(game, DIR .. "/online_join_1_code.png")
U.tap(game, "a"); U.wait(10)
for _ = 1, 1800 do
if link.stage == "waitMode" or link.stage == "battleWait" then break end
if link.net and link.net.error then
log("FAIL join error:", tostring(link.net.error))
return
end
U.wait(1)
end
log("connected, stage", tostring(link.stage))
U.shot(game, DIR .. "/online_join_2_connected.png")
local battle
for _ = 1, 1800 do
local top = game.stack:top()
if top and top.kind == "link" then battle = top break end
U.wait(1)
end
if not battle then
log("FAIL battle never started (stage " .. tostring(link.stage) .. ")")
U.shot(game, DIR .. "/online_join_nobattle.png")
return
end
log("battle started vs", tostring(battle.opponentName))
U.shot(game, DIR .. "/online_join_3_battle.png")
local shots = 0
for i = 1, 200000 do
if battle.result then break end
U.tap(game, "a")
if i % 900 == 0 and shots < 3 then
shots = shots + 1
U.shot(game, DIR .. ("/online_join_4_turn%d.png"):format(shots))
end
end
U.wait(60)
U.shot(game, DIR .. "/online_join_5_result.png")
log("result", tostring(battle.result), "turns", tostring(battle.turnCount))
log("desyncs", #desyncs)
log("DONE")
end
+260
View File
@@ -0,0 +1,260 @@
-- Lockstep desync fuzz: two full LinkBattle simulations over a loopback,
-- driven the way a player drives them, checked turn by turn.
--
-- The suite in run_link_tests.lua battles one Charizard against one
-- Blastoise with the A button held, which reaches exactly one code path:
-- move slot 1, no switch, no faint, no status, no locked action. Every
-- desync players actually hit lived outside it. This walks the rest:
-- random multi-mon parties with random movesets, random move choices,
-- switches, faints and replacements, and -- the part that matters -- two
-- sides that are NOT identical clients. Three things differ between two
-- real players and never differ inside one test process:
--
-- options animations on/off, text speed, battle style change how long a
-- side spends in its message queue
-- speed the GAME SPEED option multiplies the logic clock, so one side
-- takes several fixed steps per frame while the other takes one
-- lag the relay is not instantaneous, so a peer's action lands while
-- this side is somewhere in the middle of its own turn
--
-- None of those may change the outcome: a lockstep battle is decided by the
-- two actions and the shared RNG stream, nothing else. What they DID change
-- was when each machine wrote per-turn bookkeeping flags, and the state hash
-- read `false`/`0` against `nil` as a divergence and ended winnable matches
-- as draws (LinkBattle's `off`, BattleState's per-battler flinch clear, and
-- fightLockedAction no longer storing its boundTurns mirror).
--
-- Self-contained; run directly or via run_link_tests.lua:
-- luajit tests/link_desync_fuzz.lua [runs] [firstSeed]
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Data = require("src.core.Data")
if not Data.pokemon then Data:load() end
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Net = require("src.link.Net")
local Json = require("src.link.Json")
local Input = require("src.core.Input")
local LinkBattle = require("src.link.LinkBattle")
Input:init()
require("src.render.Font").load(Data)
-- one PRNG per run so a failure replays from its seed alone
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 a == nil then return s / 2147483647 end
if b == nil then a, b = 1, a end
return a + (s % (b - a + 1))
end
end
local SPECIES = {}
for id in pairs(Data.pokemon) do SPECIES[#SPECIES + 1] = id end
table.sort(SPECIES) -- pairs order is not stable; the seed has to be the only input
local MOVES = {}
for id, m in pairs(Data.moves) do
if id ~= "STRUGGLE" and m.pp and m.pp > 0 then MOVES[#MOVES + 1] = id end
end
table.sort(MOVES)
-- a loopback pair that holds each message for `delay` pumps before it lands
-- in the peer's inbox, so one side is mid-queue when the other's action
-- arrives (Net.loopbackPair on its own delivers instantly, which is the one
-- thing the real relay never does)
local function laggyPair(delayA, delayB)
local a, b = Net.loopbackPair()
a.wire, b.wire = {}, {}
a.delay, b.delay = delayA or 0, delayB or 0
local function send(self, msg)
if self.closed then return end
local decoded = Json.decode(Json.encode(msg)) -- same round trip as the wire
if decoded then table.insert(self.wire, { msg = decoded, at = self.delay }) end
end
local function update(self)
for i = #self.wire, 1, -1 do
local row = self.wire[i]
row.at = row.at - 1
if row.at <= 0 then
table.remove(self.wire, i)
if not self.peerEnd.closed then table.insert(self.peerEnd.inbox, row.msg) end
end
end
end
a.send, b.send = send, send
a.update, b.update = update, update
return a, b
end
local function makeFakeGame(name, options)
local save = require("src.core.SaveData").newGame()
save.player.name = name
for k, v in pairs(options or {}) do save.options[k] = v end
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
-- random movesets, not level-up ones: status, trapping, thrash, bide,
-- Hyper Beam and Rage are where the locked-action paths live
local function randomParty(rnd, size)
local party = {}
for _ = 1, size do
local mon = Pokemon.new(Data, SPECIES[rnd(1, #SPECIES)], rnd(5, 60))
mon.moves = {}
for _ = 1, rnd(1, 4) do
local id = MOVES[rnd(1, #MOVES)]
table.insert(mon.moves, { id = id, pp = Data.moves[id].pp })
end
party[#party + 1] = mon
end
return party
end
local PARTS = { "actives", "volatile", "bench" }
local function firstMismatch(a, b)
local turns = {}
for t in pairs(a.localParts) do turns[t] = true end
for t in pairs(b.localParts) do turns[t] = true end
local ordered = {}
for t in pairs(turns) do ordered[#ordered + 1] = t end
table.sort(ordered)
for _, t in ipairs(ordered) do
local mine, theirs = a.localParts[t], b.localParts[t]
if mine and theirs then
for _, part in ipairs(PARTS) do
if mine[part] ~= theirs[part] then return t, part end
end
end
end
end
-- Returns nil when the run agreed, or a description of how it split.
local function runOne(seed)
local rnd = makeRandom(seed)
-- the two sides are deliberately different clients
local optsA = { animations = false, textSpeed = 1, battleStyle = "SET" }
local optsB = { animations = true, textSpeed = 3, battleStyle = "SHIFT" }
local stepsA, stepsB = rnd(1, 4), 1 -- A fast-forwards, B does not
local lagA, lagB = rnd(0, 8), rnd(0, 8)
local gameA, gameB = makeFakeGame("RED", optsA), makeFakeGame("BLUE", optsB)
gameA.save.party = randomParty(rnd, rnd(1, 4))
gameB.save.party = randomParty(rnd, rnd(1, 4))
local netA, netB = laggyPair(lagA, lagB)
local battleSeed = rnd(1, 2 ^ 30)
local battleA = LinkBattle.newHost(gameA, netA, {
myParty = Protocol.packParty(gameA.save.party),
theirParty = Protocol.packParty(gameB.save.party),
theirName = "BLUE", seed = battleSeed })
local battleB = LinkBattle.newGuest(gameB, netB, {
myParty = Protocol.packParty(gameB.save.party),
theirParty = Protocol.packParty(gameA.save.party),
theirName = "RED", seed = battleSeed })
if not battleA or not battleB then return nil, 0 end
local resA, resB
battleA.onFinish = function(r) resA = r end
battleB.onFinish = function(r) resB = r end
gameA.stack:push(battleA)
gameB.stack:push(battleB)
local sides = {
{ bt = battleA, game = gameA, party = battleA.playerParty, steps = stepsA },
{ bt = battleB, game = gameB, party = battleB.playerParty, steps = stepsB },
}
-- Only the cursor is steered; A (held below) does the committing, so the
-- battle is reached through DisplayBattleMenu exactly as a player reaches
-- it and every locked action (recharge / thrash / rage / bide / trapping /
-- bound / Struggle) fires from its own code path rather than being
-- injected. A switch waits for a second menu frame for the same reason:
-- the real PKMN branch sits behind the menu-entry bookkeeping.
local function drive(side)
local bt = side.bt
if bt.result then return end
if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then
local usable = {}
for i, mv in ipairs(bt.player.curMoves) do
if (mv.pp or 0) > 0 and bt.player.disabledSlot ~= i then usable[#usable + 1] = i end
end
if #usable > 0 then bt.moveIndex = usable[rnd(1, #usable)] end
bt.menuIndex = 1
elseif bt.phase == "menu" then
bt.menuIndex = 1 -- FIGHT
side.menuFrames = (side.menuFrames or 0) + 1
if side.menuFrames >= 2 and not bt:menuLockedAction(bt.player)
and rnd(1, 100) <= 10 then
for _ = 1, 4 do
local mon = side.party[rnd(1, #side.party)]
if mon.hp > 0 and mon ~= bt.player.mon then
bt:resolveSwitch(mon)
side.menuFrames = 0
return
end
end
end
end
end
local guard = 0
while (resA == nil or resB == nil) and guard < 60000 do
guard = guard + 1
Input.pressed = { a = true }
for _, side in ipairs(sides) do
for _ = 1, side.steps do
drive(side)
side.game.stack:update(1 / 60)
end
end
local turn, part = firstMismatch(battleA, battleB)
if turn then
return ("seed %d: turn %d %s split (lag %d/%d, steps %d/%d)"):format(
seed, turn, part, lagA, lagB, stepsA, stepsB), battleA.turnCount or 0
end
end
-- a battle still running at the guard is a stalemate (two mons that cannot
-- KO each other), not a split; only a finished one can be checked mirrored
if guard < 60000
and (battleA.player.mon.hp ~= battleB.enemy.mon.hp
or battleA.enemy.mon.hp ~= battleB.player.mon.hp) then
return ("seed %d: final HP not mirrored (%d/%d vs %d/%d)"):format(
seed, battleA.player.mon.hp, battleA.enemy.mon.hp,
battleB.enemy.mon.hp, battleB.player.mon.hp), battleA.turnCount or 0
end
return nil, battleA.turnCount or 0
end
local RUNS = tonumber(arg and arg[1]) or 40
local FIRST = tonumber(arg and arg[2]) or 1
local failures, turns = 0, 0
for seed = FIRST, FIRST + RUNS - 1 do
local ok, why, t = pcall(runOne, seed)
turns = turns + (t or 0)
if not ok then
failures = failures + 1
print("FAIL link desync fuzz seed " .. seed .. ": " .. tostring(why))
elseif why then
failures = failures + 1
print("FAIL link desync fuzz " .. why)
end
end
print(("link desync fuzz: %d runs, %d turns, %d failures"):format(RUNS, turns, failures))
assert(failures == 0, failures .. " lockstep run(s) diverged")
return true
+304
View File
@@ -0,0 +1,304 @@
-- A full 16-player tournament, played out.
--
-- Sixteen distinct identities, six random Pokemon each, everyone mashing A,
-- through every match of every round until one champion is left: 8 + 4 + 2 +
-- 1 = 15 real lockstep battles, each with the tournament match shape
-- (turnLimit shot clock, keepNetOpen, forceLevel) plus a live spectator
-- rebuilding the same match from the relay's fan-out.
--
-- What this is actually checking, beyond "it finishes":
-- * 15 consecutive lockstep matches agree turn by turn, so a desync is
-- not something that only shows up deep into a bracket
-- * every match produces one winner and one loser -- never a double-win,
-- a double-loss, or a draw the server has to coin-flip (relay.js
-- resolves conflicting results with crypto.randomInt, so a draw here is
-- a real player's tournament decided by a coin toss)
-- * the spectator's reconstruction matches the two players' own views,
-- which is the tournament-only client path
-- * the shot clock does not fire on its own during ordinary play
--
-- Bracket pairing/advancement itself is the relay's job and is covered
-- server-side (pokeserver test/tournament16.js); this owns the client half.
--
-- luajit tests/link_tournament16.lua [seed]
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Data = require("src.core.Data")
if not Data.pokemon then Data:load() end
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Net = require("src.link.Net")
local Input = require("src.core.Input")
local LinkBattle = require("src.link.LinkBattle")
Input:init()
require("src.render.Font").load(Data)
local PLAYERS = 16
local PARTY_SIZE = 6
local TURN_LIMIT = 6 -- one of relay.js's VALID_TURN_LIMITS
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 makeRandom(seed)
local s = seed % 2147483647
if s <= 0 then s = s + 2147483646 end
return function(a, b)
s = (s * 16807) % 2147483647
if a == nil then return s / 2147483647 end
if b == nil then a, b = 1, a end
return a + (s % (b - a + 1))
end
end
local SPECIES = {}
for id in pairs(Data.pokemon) do SPECIES[#SPECIES + 1] = id end
table.sort(SPECIES)
local MOVES = {}
for id, m in pairs(Data.moves) do
if id ~= "STRUGGLE" and m.pp and m.pp > 0 then MOVES[#MOVES + 1] = id end
end
table.sort(MOVES)
local function makeFakeGame(name)
local save = require("src.core.SaveData").newGame()
save.player.name = name
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
-- six random species with random movesets, the tournament's required size
local function randomParty(rnd)
local party = {}
for _ = 1, PARTY_SIZE do
local mon = Pokemon.new(Data, SPECIES[rnd(1, #SPECIES)], rnd(20, 60))
mon.moves = {}
for _ = 1, rnd(1, 4) do
local id = MOVES[rnd(1, #MOVES)]
table.insert(mon.moves, { id = id, pp = Data.moves[id].pp })
end
party[#party + 1] = mon
end
return party
end
-- a three-way loopback: the two players relay to each other, and every line
-- is also fanned out to the spectator tagged with the side that sent it,
-- which is the `spectate` envelope relay.js wraps tournament traffic in
local function matchNets()
local a, b = Net.loopbackPair()
local spec = Net.new()
spec.paired, spec.mode = true, "loopback"
local function send(self, msg)
if self.closed then return end
local Json = require("src.link.Json")
local decoded = Json.decode(Json.encode(msg))
if not decoded then return end
if not self.peerEnd.closed then table.insert(self.peerEnd.inbox, decoded) end
if not spec.closed then
table.insert(spec.inbox, { type = "spectate", side = self.matchSide,
msg = Json.decode(Json.encode(msg)) })
end
end
a.matchSide, b.matchSide = "host", "guest"
a.send, b.send = send, send
return a, b, spec
end
local PARTS = { "actives", "volatile", "bench" }
local function splitTurn(a, b)
for turn, mine in pairs(a.localParts) do
local theirs = b.localParts[turn]
if theirs then
for _, part in ipairs(PARTS) do
if mine[part] ~= theirs[part] then return turn, part end
end
end
end
end
-- Plays one bracket match to a finish. Returns the winner/loser entries, or
-- nil plus why it could not be decided.
local function playMatch(hostEntry, guestEntry, rnd, label)
local netH, netG, netS = matchNets()
local packedH = Protocol.packParty(hostEntry.game.save.party)
local packedG = Protocol.packParty(guestEntry.game.save.party)
local seed = rnd(1, 2 ^ 30)
local opts = { turnLimit = TURN_LIMIT, keepNetOpen = true,
verdict = "full", strict = true }
local bH = LinkBattle.newHost(hostEntry.game, netH, {
myParty = packedH, theirParty = packedG, theirName = guestEntry.name,
seed = seed, turnLimit = opts.turnLimit, keepNetOpen = true,
verdict = opts.verdict, strict = opts.strict })
local bG = LinkBattle.newGuest(guestEntry.game, netG, {
myParty = packedG, theirParty = packedH, theirName = hostEntry.name,
seed = seed, turnLimit = opts.turnLimit, keepNetOpen = true,
verdict = opts.verdict, strict = opts.strict })
-- one of the fourteen players not in this match, watching it live (it has
-- a party of its own like any eliminated entrant would -- the spectator
-- never battles with it, but newWild builds its scaffold from one)
local specEntry = makeFakeGame("WATCHER")
specEntry.save.party = randomParty(rnd)
local bS = LinkBattle.newSpectator(specEntry, netS, {
hostParty = packedH, guestParty = packedG,
hostName = hostEntry.name, guestName = guestEntry.name,
seed = seed, verdict = opts.verdict, strict = opts.strict })
if not (bH and bG and bS) then return nil, label .. ": a side refused to build" end
local resH, resG
bH.onFinish = function(r) resH = r end
bG.onFinish = function(r) resG = r end
hostEntry.game.stack:push(bH)
guestEntry.game.stack:push(bG)
specEntry.stack:push(bS)
local sides = {
{ bt = bH, game = hostEntry.game, party = bH.playerParty },
{ bt = bG, game = guestEntry.game, party = bG.playerParty },
}
-- everyone mashes A: the cursor is only steered onto a legal move row,
-- and occasionally onto a switch, so all six mons get used
local function drive(side)
local bt = side.bt
if bt.result then return end
if bt.phase ~= "menu" then side.menuFrames = 0 end
if bt.phase == "moveSelect" then
local usable = {}
for i, mv in ipairs(bt.player.curMoves) do
if (mv.pp or 0) > 0 and bt.player.disabledSlot ~= i then usable[#usable + 1] = i end
end
if #usable > 0 then bt.moveIndex = usable[rnd(1, #usable)] end
bt.menuIndex = 1
elseif bt.phase == "menu" then
bt.menuIndex = 1
side.menuFrames = (side.menuFrames or 0) + 1
if side.menuFrames >= 2 and not bt:menuLockedAction(bt.player)
and rnd(1, 100) <= 8 then
for _ = 1, 4 do
local mon = side.party[rnd(1, #side.party)]
if mon.hp > 0 and mon ~= bt.player.mon then
bt:resolveSwitch(mon)
side.menuFrames = 0
return
end
end
end
end
end
local guard = 0
while (resH == nil or resG == nil) and guard < 200000 do
guard = guard + 1
Input.pressed = { a = true }
for _, side in ipairs(sides) do
drive(side)
side.game.stack:update(1 / 60)
end
specEntry.stack:update(1 / 60)
local turn, part = splitTurn(bH, bG)
if turn then
return nil, ("%s: turn %d %s split"):format(label, turn, part)
end
end
if resH == nil or resG == nil then
return nil, ("%s: unfinished after %d frames (turn %s)"):format(
label, guard, tostring(bH.turnCount))
end
-- the two views of the same match have to be opposite verdicts; a draw
-- means the server picks the winner with a coin flip
if not ((resH == "win" and resG == "lose") or (resH == "lose" and resG == "win")) then
return nil, ("%s: not a decisive result (%s / %s)"):format(label, resH, resG)
end
-- The spectator is a replay, so it is legitimately behind the two players
-- when they finish -- it still has their last turns queued. Let it drain
-- before comparing, or the check reads a mid-match frame.
local settle = 0
while settle < 20000 and not bS.finished do
settle = settle + 1
local before = bS.turnCount
specEntry.stack:update(1 / 60)
if bS.result and bS.turnCount == before and #bS.queue == 0 then break end
end
-- the spectator rebuilt the same battle from the relay copy
local specOk = bS.player.mon.hp == bH.player.mon.hp
and bS.enemy.mon.hp == bG.player.mon.hp
if resH == "win" then
return hostEntry, guestEntry, specOk
end
return guestEntry, hostEntry, specOk
end
local seed = tonumber(arg and arg[1]) or 20260728
local rnd = makeRandom(seed)
local entrants = {}
for i = 1, PLAYERS do
local name = ("PLAYER%02d"):format(i)
local game = makeFakeGame(name)
game.save.party = randomParty(rnd)
game.save.player.id = rnd(0, 65535) -- distinct trainer identities
entrants[i] = { name = name, game = game }
end
check(#entrants == PLAYERS, "16 distinct entrants registered")
local sizesOk = true
for _, e in ipairs(entrants) do
if #e.game.save.party ~= PARTY_SIZE then sizesOk = false end
end
check(sizesOk, "every entrant brings a full party of " .. PARTY_SIZE)
-- 16 is a power of two, so no byes: 8 + 4 + 2 + 1 real matches
local alive = entrants
local round, matchesPlayed, specOkCount = 0, 0, 0
while #alive > 1 and failures == 0 do
round = round + 1
local survivors = {}
for i = 1, #alive, 2 do
local label = ("round %d match %d"):format(round, (i + 1) / 2)
local winner, loser, specOk = playMatch(alive[i], alive[i + 1], rnd, label)
if not winner then
check(false, tostring(loser))
break
end
matchesPlayed = matchesPlayed + 1
if specOk then specOkCount = specOkCount + 1 end
survivors[#survivors + 1] = winner
end
if failures > 0 then break end
check(#survivors == #alive / 2,
("round %d halved the field (%d -> %d)"):format(round, #alive, #survivors))
alive = survivors
end
check(matchesPlayed == PLAYERS - 1,
("every bracket match was played (%d of %d)"):format(matchesPlayed, PLAYERS - 1))
check(round == 4, ("the bracket ran 4 rounds (ran %d)"):format(round))
check(#alive == 1, "exactly one champion is left")
if alive[1] then print(" champion: " .. alive[1].name) end
check(specOkCount == matchesPlayed,
("every match's spectator view matched the players' (%d of %d)"):format(
specOkCount, matchesPlayed))
print(("\ntournament16: %s"):format(
failures == 0 and "PASSED" or (failures .. " FAILURES")))
assert(failures == 0, failures .. " tournament failure(s)")
return true
+45
View File
@@ -600,6 +600,51 @@ eq(battleSpec.player.mon.hp, battleE.player.mon.hp,
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")
-- ---------------------------------------------------------------- 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))))
-- ---------------------------------------------------------------- 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.