local Battle = require("src.battle.gen2.Battle") local BattleState = require("src.ui.gen2.BattleState") local Fingerprint = require("src.link.Fingerprint") local Handshake = require("src.link.Handshake") local Logger = require("src.core.Logger") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Strings = require("src.core.Strings") local LinkBattle2 = {} local function makeRandom(seed, owner) local s = tonumber(seed) or 1 if s ~= s or s == math.huge or s == -math.huge then s = 1 end s = math.floor(s) % 2147483647 if s <= 0 then s = s + 2147483646 end owner.rngDraws = 0 return function(n) owner.rngDraws = owner.rngDraws + 1 s = (s * 16807) % 2147483647 n = math.floor(tonumber(n) or 1) if n < 1 then n = 1 end return s % n end end local PARTS = { "actives", "volatile", "bench" } local FATAL_PART = { actives = true, bench = true } local IDLE_PHASE = { menu = true, moves = true, ["locked-in"] = true, ["forced-switch"] = true, ["link-wait"] = true, ["link-hold"] = true, ["refuse-menu"] = true, ["refuse-move"] = true, ["refuse-switch"] = true, } local function unpackParty(game, packed, unpackOpts, errFmt) local out = {} for _, p in ipairs(packed or {}) do local mon, why = Protocol.unpackMon2(game.data, p, unpackOpts) if mon then table.insert(out, mon) elseif unpackOpts.strict then return nil, errFmt(p, why) end end return out end local function linkSave(game, party) local player = (game and game.save and game.save.player) or {} return { party = party, player = { name = player.name or "PLAYER", gender = player.gender }, inventory = {}, pokedex = { seen = {}, caught = {} }, options = {}, modData = {}, } end local function digestParts(parts) return { actives = Fingerprint.digest(parts.actives), volatile = Fingerprint.digest(parts.volatile), bench = Fingerprint.digest(parts.bench), } end local function moveSlot(mon, moveId) for i, mv in ipairs((mon and mon.moves) or {}) do if mv.id == moveId then return i end end return nil end local function encodeAction(battle, action) if action.kind == "switch" then return { type = "action", kind = "switch", index = action.index } end if action.kind == "run" then return { type = "action", kind = "run" } end if action.move == Battle.STRUGGLE then return { type = "action", kind = "struggle" } end local slot = moveSlot(battle.player, action.move) if not slot then return { type = "action", kind = "locked" } end return { type = "action", kind = "move", slot = slot } end local function decodeAction(battle, msg, mon) local kind = msg.kind if kind == "switch" then local index = math.floor(tonumber(msg.index) or 1) return { kind = "switch", index = index } end if kind == "run" then return { kind = "run" } end if kind == "struggle" then return { kind = "move", move = Battle.STRUGGLE } end if kind == "locked" then local locked = battle:lockedInMove(mon) or battle:volatile(mon).chargeMove or battle:forcedMove(mon) return { kind = "move", move = locked } end local moves = (mon and mon.moves) or {} local slot = math.max(1, math.min(math.max(1, #moves), math.floor(tonumber(msg.slot) or 1))) local mv = moves[slot] return { kind = "move", move = mv and mv.id or Battle.STRUGGLE } end local function outcomeResult(outcome, battle) if outcome == "win" then if battle and not Battle.firstHealthy(battle.party) then return "draw" end return "win" end if outcome == "lose" then return "lose" end return "draw" end function LinkBattle2.new(game, net, opts) local role = opts.role local theirName = opts.theirName or "FOE" if not Handshake.battleAllowed(opts.verdict) then return nil, Strings("Link battle needs\nthe same version\nand mods.") end local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) return Strings("Your %s can't\nbattle on the\nother game.", tostring(p.species)) end) if not myParty then return nil, myErr end local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) return Strings("Their %s isn't\nin this game.\n(%s)", tostring(p.species), tostring(why)) end) if not theirParty then return nil, theirErr end if #myParty == 0 or #theirParty == 0 then Logger.warn("link2: empty party on one side") end local function announceReceived(party) for _, mon in ipairs(party) do Runtime.emit("pokemon.received", { mon = mon, from = "link", peerName = theirName }) end end announceReceived(role == "host" and myParty or theirParty) announceReceived(role == "host" and theirParty or myParty) local owner = {} local random = makeRandom(opts.seed or 1, owner) local battle = Battle.new({ data = game.data, random = random, party = myParty, trainer = { name = theirName, party = theirParty }, save = nil, }) battle.linkBattle = true battle.mirrored = role == "guest" theirParty = battle.enemyParty local hooks = {} local screen local ended, byeSent = false, false local pendingMine, pendingTheirs = nil, nil local remoteReplace = {} local turnCount = 0 local localHashes, remoteHashes = {}, {} local localParts, remoteParts = {}, {} local checkedTurns = {} local clock = nil local function send(msg) net:send(msg) end local function kick(s) if IDLE_PHASE[s.phase] then s.phase = "resolving" s:advanceQueue() end end local function endWithResult(s, result, text) if ended then return end ended = true s.result = result if text then s:pushAll({ { kind = "message", text = text } }) end if not battle.over then battle:endBattle(result == "win" and "win" or (result == "lose" and "lose" or "draw")) end kick(s) end local function endAsDraw(s, text) endWithResult(s, "draw", text) end local function reportDesync(s, turn, component, localH, remoteH) Logger.warn("link2: 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, fatal = true }) endAsDraw(s, Strings( "Link desync! %s differs. Are both games the same version and mods?", component)) end local function noteDrift(turn, component, localH, remoteH) Logger.warn("link2: %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 local function checkHashes(s) for turn, localH in pairs(localHashes) do local remoteH = remoteHashes[turn] if remoteH and not checkedTurns[turn] then checkedTurns[turn] = true local mine, theirs = localParts[turn], remoteParts[turn] 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(turn, component, mine[component], theirs[component]) end end end if remoteH ~= localH then reportDesync(s, turn, "state", localH, remoteH) return end end end end local function signTurn(s) battle.rngDraws = owner.rngDraws turnCount = turnCount + 1 local raw = battle:linkSignature(role) local parts = digestParts(raw) local value = parts.actives .. "|" .. parts.bench localHashes[turnCount] = value localParts[turnCount] = parts if LinkBattle2.keepSignatures then s.linkSignatures[turnCount] = raw end send({ type = "hash", turn = turnCount, value = value, parts = parts }) checkHashes(s) end local function resolveLockstep(s, myMsg, theirMsg) if myMsg.kind == "run" or theirMsg.kind == "run" then local who = myMsg.kind == "run" and ((game.save and game.save.player and game.save.player.name) or "PLAYER") or theirName endAsDraw(s, Strings("%s ran from the battle!", who)) return end local myAction = decodeAction(battle, myMsg, battle.player) local theirAction = decodeAction(battle, theirMsg, battle.enemy) local events = battle:takeLinkTurn(myAction, theirAction) signTurn(s) if ended then return end s.phase = "resolving" s:pushAll(events) s.message = nil s.messageTimer = 0 s:advanceQueue() end local function tryResolve(s) if not pendingMine or not pendingTheirs then return end local mine, theirs = pendingMine, pendingTheirs pendingMine, pendingTheirs = nil, nil resolveLockstep(s, mine, theirs) end local function applyRemoteReplace(s) if not battle.pendingEnemySwitch or #remoteReplace == 0 then return end local index = table.remove(remoteReplace, 1) battle:forcedReplacement("enemy", index) s:pushAll(battle:takeEvents()) if s.phase == "link-hold" then kick(s) end end hooks.submit = function(s, action) if ended then return end if action.kind == "item" then return s:refuseMenu(Strings("Items can't be used in a link battle!")) end if action.kind == "run" then return s:refuseMenu(Strings("No running from a link battle!")) end local msg = encodeAction(battle, action) send(msg) pendingMine = msg s.phase = "link-wait" tryResolve(s) end hooks.menuChoice = function(s, choice) if choice == "item" then s:refuseMenu(Strings("Items can't be used in a link battle!")) return true end if choice == "run" then s:refuseMenu(Strings("No running from a link battle!")) return true end return false end -- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after a hooks.forcedSwitch = function(s, index) local mon = myParty[index] if not mon or (mon.hp or 0) <= 0 or mon.isEgg then return s:refuseSwitch(true) end if not battle:forcedReplacement("player", index) then return s:refuseSwitch(true) end send({ type = "replace", index = index }) s.phase = "resolving" s:pushAll(battle:takeEvents()) s:advanceQueue() return true end screen = BattleState.new(game, { battle = battle, save = linkSave(game, myParty), link = hooks, onDone = function(outcome) local result = screen.result or outcomeResult(outcome, battle) screen.result = result if not byeSent then byeSent = true send({ type = "bye" }) end if not opts.keepNetOpen then net:close() if game.linkNet == net then game.linkNet = nil end end if game.stack and game.stack:top() == screen then game.stack:pop() end if screen.onFinish then screen.onFinish(result) end end, }) screen.kind = "link" screen.linkRole = role screen.result = nil screen.playerParty = myParty screen.enemyParty = theirParty screen.localHashes = localHashes screen.remoteHashes = remoteHashes screen.localParts = localParts screen.remoteParts = remoteParts screen.linkSignatures = {} screen.rngOwner = owner game.linkNet = net local baseUpdate = screen.update screen.update = function(s, dt) net:update() for _, msg in ipairs(net:poll()) do if msg.type == "action" then pendingTheirs = msg tryResolve(s) elseif msg.type == "hash" then remoteHashes[msg.turn or 0] = msg.value remoteParts[msg.turn or 0] = msg.parts checkHashes(s) elseif msg.type == "replace" then table.insert(remoteReplace, math.max(1, math.min(#theirParty, math.floor(tonumber(msg.index) or 1)))) elseif msg.type == "bye" then if not ended and not battle.over then endAsDraw(s, Strings("%s left the battle.", theirName)) end elseif msg.type == "forfeit" then if not ended and not battle.over then endWithResult(s, "win", Strings("%s ran out of time!", theirName)) end else s.pendingLinkMessages = s.pendingLinkMessages or {} table.insert(s.pendingLinkMessages, msg) end end if net.closed and not ended and not battle.over then endAsDraw(s) end applyRemoteReplace(s) if battle.pendingEnemySwitch and (s.phase == "menu" or s.phase == "moves" or s.phase == "locked-in") then s.phase = "link-hold" end if opts.turnLimit and (s.phase == "menu" or s.phase == "moves") then clock = (clock or opts.turnLimit) - (dt or 0) if clock <= 0 then clock = nil send({ type = "forfeit" }) endWithResult(s, "lose", Strings("Time's up! You forfeit the match.")) end elseif opts.turnLimit then clock = nil end return baseUpdate(s, dt) end return screen end function LinkBattle2.newSpectator(game, net, opts) local hostName = opts.hostName or "HOST" local guestName = opts.guestName or "GUEST" if not Handshake.battleAllowed(opts.verdict) then return nil, Strings("Link battle needs\nthe same version\nand mods.") end local unpackOpts = { strict = opts.strict or false, forceLevel = opts.forceLevel } local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) return Strings("%s's %s can't\nbattle on this\ngame.", hostName, tostring(p.species)) end) if not hostParty then return nil, hostErr end local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts, function(p, why) return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName, tostring(p.species), tostring(why)) end) if not guestParty then return nil, guestErr end local owner = {} local random = makeRandom(opts.seed or 1, owner) local battle = Battle.new({ data = game.data, random = random, party = hostParty, trainer = { name = guestName, party = guestParty }, save = nil, }) battle.linkBattle = true guestParty = battle.enemyParty local hooks = {} local screen local hostMsg, guestMsg = nil, nil local hostReplace, guestReplace = {}, {} local ended = false local function endSpectate(s, text) if ended then return end ended = true s.result = s.result or "ended" if text then s:pushAll({ { kind = "message", text = text } }) end if not battle.over then battle:endBattle("draw") end if IDLE_PHASE[s.phase] then s.phase = "resolving" s:advanceQueue() end end local function resolveSpecTurn(s, hMsg, gMsg) if hMsg.kind == "run" or gMsg.kind == "run" then endSpectate(s, Strings("The match ended.")) return end local hostAction = decodeAction(battle, hMsg, battle.player) local guestAction = decodeAction(battle, gMsg, battle.enemy) local events = battle:takeLinkTurn(hostAction, guestAction) if ended then return end s.phase = "resolving" s:pushAll(events) s.message = nil s.messageTimer = 0 s:advanceQueue() end hooks.forcedPrompt = function(s) local index = table.remove(hostReplace, 1) if not index then return true end battle:forcedReplacement("player", index) s.phase = "resolving" s:pushAll(battle:takeEvents()) s:advanceQueue() return true end hooks.submit = function() end hooks.menuChoice = function() return true end hooks.forcedSwitch = function(s) return s:refuseSwitch(true) end screen = BattleState.new(game, { battle = battle, save = linkSave(game, hostParty), link = hooks, onDone = function(outcome) local result = screen.result or outcomeResult(outcome, battle) screen.result = result if game.stack and game.stack:top() == screen then game.stack:pop() end if screen.onFinish then screen.onFinish(result) end end, }) screen.kind = "link" screen.spectating = true screen.result = nil screen.playerParty = hostParty screen.enemyParty = guestParty game.linkNet = net local baseUpdate = screen.update screen.update = function(s, dt) net:update() for _, msg in ipairs(net:poll()) do if msg.type == "spectate" and type(msg.msg) == "table" then local inner = msg.msg if inner.type == "action" then if msg.side == "host" then hostMsg = inner else guestMsg = inner end if hostMsg and guestMsg then local h, g = hostMsg, guestMsg hostMsg, guestMsg = nil, nil resolveSpecTurn(s, h, g) end elseif inner.type == "replace" then local index = math.floor(tonumber(inner.index) or 1) if msg.side == "host" then table.insert(hostReplace, math.max(1, math.min(#hostParty, index))) else table.insert(guestReplace, math.max(1, math.min(#guestParty, index))) end elseif inner.type == "bye" or inner.type == "forfeit" then endSpectate(s, Strings("The match ended.")) end else s.pendingLinkMessages = s.pendingLinkMessages or {} table.insert(s.pendingLinkMessages, msg) end end if net.closed and not ended and not battle.over then endSpectate(s) end if battle.pendingEnemySwitch then local index = table.remove(guestReplace, 1) if index then battle:forcedReplacement("enemy", index) s:pushAll(battle:takeEvents()) if s.phase == "link-hold" then s.phase = "resolving" s:advanceQueue() end elseif s.phase == "menu" or s.phase == "moves" or s.phase == "locked-in" then s.phase = "link-hold" end end if s.phase == "menu" or s.phase == "moves" or s.phase == "locked-in" then s.phase = "link-wait" end return baseUpdate(s, dt) end return screen end function LinkBattle2.newHost(game, net, opts) opts.role = "host" return LinkBattle2.new(game, net, opts) end function LinkBattle2.newGuest(game, net, opts) opts.role = "guest" return LinkBattle2.new(game, net, opts) end return LinkBattle2