From a03f69926e0694189ce88d44dd64cc08b81900d5 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Tue, 28 Jul 2026 15:01:59 -0400 Subject: [PATCH] bazinga --- docs/new-features.md | 24 +++ src/battle/BattleState.lua | 63 ++++-- src/core/Game.lua | 9 + src/link/LinkBattle.lua | 67 +++++- src/link/LinkState.lua | 47 ++++- src/link/Tournament.lua | 6 + tests/drivers/online_match_host.lua | 144 +++++++++++++ tests/drivers/online_match_join.lua | 129 ++++++++++++ tests/link_desync_fuzz.lua | 260 ++++++++++++++++++++++++ tests/link_tournament16.lua | 304 ++++++++++++++++++++++++++++ tests/run_link_tests.lua | 45 ++++ 11 files changed, 1081 insertions(+), 17 deletions(-) create mode 100644 tests/drivers/online_match_host.lua create mode 100644 tests/drivers/online_match_join.lua create mode 100644 tests/link_desync_fuzz.lua create mode 100644 tests/link_tournament16.lua diff --git a/docs/new-features.md b/docs/new-features.md index 4ff64e88..abcd5da4 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -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 diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 55910f23..ad0e5e2d 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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 - and math.max(1, opp.trappingTurns) or nil - if battler.boundTurns then + local bound = opp and opp.trappingTurns + and math.max(1, opp.trappingTurns) or nil + 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) diff --git a/src/core/Game.lua b/src/core/Game.lua index 1bad3b9f..5e102d44 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -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) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index 4567a6fb..c51f57c5 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -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,8 +368,11 @@ function LinkBattle.new(game, net, opts) if mine and theirs then for _, component in ipairs(PARTS) do if mine[component] ~= theirs[component] then - reportDesync(s, turn, component, mine[component], theirs[component]) - return + 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 @@ -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) diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index a744dd09..016a4802 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -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 diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua index 47019e4b..233cf4e9 100644 --- a/src/link/Tournament.lua +++ b/src/link/Tournament.lua @@ -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 diff --git a/tests/drivers/online_match_host.lua b/tests/drivers/online_match_host.lua new file mode 100644 index 00000000..8e9be6de --- /dev/null +++ b/tests/drivers/online_match_host.lua @@ -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 diff --git a/tests/drivers/online_match_join.lua b/tests/drivers/online_match_join.lua new file mode 100644 index 00000000..48f8545d --- /dev/null +++ b/tests/drivers/online_match_join.lua @@ -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 diff --git a/tests/link_desync_fuzz.lua b/tests/link_desync_fuzz.lua new file mode 100644 index 00000000..a1bcd603 --- /dev/null +++ b/tests/link_desync_fuzz.lua @@ -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 diff --git a/tests/link_tournament16.lua b/tests/link_tournament16.lua new file mode 100644 index 00000000..7a893cb4 --- /dev/null +++ b/tests/link_tournament16.lua @@ -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 diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index 0c9af60c..2b1921b3 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -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.