diff --git a/src/link/CodeEntry.lua b/src/link/CodeEntry.lua new file mode 100644 index 00000000..f0335662 --- /dev/null +++ b/src/link/CodeEntry.lua @@ -0,0 +1,44 @@ +-- Shared 6-slot room-code entry widget: the digit-scrub interaction +-- LinkState's own `ipDigits`/`addrPos` already uses for IP entry, over the +-- Crockford-32-style alphabet pokeserver room/tournament codes are drawn +-- from (23456789ABCDEFGHJKMNPQRSTUVWXYZ -- no 0/O/1/I/L, so a code read +-- aloud or handwritten never has to be checked twice). + +local CodeEntry = {} + +CodeEntry.CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" +CodeEntry.LENGTH = 6 + +function CodeEntry.new() + local chars = {} + for i = 1, CodeEntry.LENGTH do chars[i] = 1 end -- index into CHARSET, 1-based + return { chars = chars, pos = 1 } +end + +local N = #CodeEntry.CHARSET + +function CodeEntry.up(state) + state.chars[state.pos] = state.chars[state.pos] % N + 1 +end + +function CodeEntry.down(state) + state.chars[state.pos] = (state.chars[state.pos] - 2) % N + 1 +end + +function CodeEntry.left(state) + state.pos = math.max(1, state.pos - 1) +end + +function CodeEntry.right(state) + state.pos = math.min(CodeEntry.LENGTH, state.pos + 1) +end + +function CodeEntry.text(state) + local out = {} + for i = 1, CodeEntry.LENGTH do + out[i] = CodeEntry.CHARSET:sub(state.chars[i], state.chars[i]) + end + return table.concat(out) +end + +return CodeEntry diff --git a/src/link/Handshake.lua b/src/link/Handshake.lua index 2cdad249..6872a5cc 100644 --- a/src/link/Handshake.lua +++ b/src/link/Handshake.lua @@ -67,6 +67,17 @@ function Handshake.linkModified(game) return false end +-- online play (the relay-based online match / tournament flows in +-- LinkState/Tournament) meets strangers, not a coordinating friend, so it +-- skips the LAN path's per-peer compatibility negotiation entirely and +-- just requires vanilla on both ends: no mod-added Pokemon, no surprises. +-- Mods only ever get baked in at boot (Loader:load), so this is a gate on +-- attempting to go online, not a live mod toggle -- the player disables +-- mods via the mod manager and relaunches. +function Handshake.onlineAllowed(game) + return #Handshake.mods(game) == 0 +end + -- mode is nil on the guest: it pairs and announces itself before the host -- has picked, and compatibility is decided from the two hellos, not the mode function Handshake.hello(game, mode) diff --git a/src/link/LinkBattle.lua b/src/link/LinkBattle.lua index dce7f012..2c4c7a53 100644 --- a/src/link/LinkBattle.lua +++ b/src/link/LinkBattle.lua @@ -15,6 +15,7 @@ -- them in link battles). local Fingerprint = require("src.link.Fingerprint") +local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Logger = require("src.core.Logger") local Protocol = require("src.link.Protocol") @@ -45,6 +46,38 @@ local function mkBattler(data, mon, isPlayer) return BattleState.makeBattler(data, mon, isPlayer, nil) end +-- shared by LinkBattle.new (myParty/theirParty) and LinkBattle.newSpectator +-- (hostParty/guestParty): pack->unpack clamp so every perspective watching +-- a given match holds identical mon copies. errFmt(packedMon, why) builds +-- the message shown when strict mode refuses an unrebuildable mon. +local function unpackParty(game, packed, unpackOpts, errFmt) + local out = {} + for _, p in ipairs(packed or {}) do + local mon, why = Protocol.unpackMon(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 + +-- decode a wire action message against whichever battler it belongs to +-- (the opponent's, from a real participant's perspective; either side's, +-- from a spectator's) +local function decodeWireAction(s, msg, battler) + if msg.kind == "move" then + local slot = math.max(1, math.min(#battler.curMoves, math.floor(msg.slot or 1))) + return battler.curMoves[slot] + elseif msg.kind == "struggle" then + return { id = "STRUGGLE", pp = 1, struggle = true } + elseif msg.kind == "locked" then + return s:lockedAction(battler) + end + return nil +end + -- canonical (host-side-first) state hash, unchanged since v1: it stays on -- the wire as `value` so a pre-mod peer still compares something it agrees -- with, while the components below carry the real coverage @@ -150,25 +183,14 @@ function LinkBattle.new(game, net, opts) -- both parties pass through the same pack->unpack clamp on both -- machines, so the copies are identical everywhere local unpackOpts = { strict = opts.strict or false } - local myParty, theirParty = {}, {} - for _, p in ipairs(opts.myParty or {}) do - local mon = Protocol.unpackMon(game.data, p, unpackOpts) - if mon then - table.insert(myParty, mon) - elseif unpackOpts.strict then - return nil, ("Your %s can't\nbattle on the\nother game."):format( - tostring(p.species)) - end - end - for _, p in ipairs(opts.theirParty or {}) do - local mon, why = Protocol.unpackMon(game.data, p, unpackOpts) - if mon then - table.insert(theirParty, mon) - elseif unpackOpts.strict then - return nil, ("Their %s isn't\nin this game.\n(%s)"):format( - tostring(p.species), tostring(why)) - end - end + local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p) + return ("Your %s can't\nbattle on the\nother game."):format(tostring(p.species)) + end) + if not myParty then return nil, myErr end + local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts, function(p, why) + return ("Their %s isn't\nin this game.\n(%s)"):format(tostring(p.species), tostring(why)) + end) + if not theirParty then return nil, theirErr end if #myParty == 0 or #theirParty == 0 then Logger.warn("link: empty party on one side") end @@ -227,6 +249,17 @@ function LinkBattle.new(game, net, opts) if text then s:say(text) end end + -- a tournament shot clock (opts.turnLimit) costs the slow player + -- specifically, unlike RUN or a desync -- both of which stay a draw -- + -- so it needs its own result rather than reusing endAsDraw + local function endWithResult(s, result, text) + if s.linkEnded then return end + s.result = result + s.afterQueue = "finish" + s.phase = "messages" + if text then s:say(text) end + end + local function orderMove(action) if action and action.id then return game.data.moves[action.id] end return nil @@ -271,15 +304,7 @@ function LinkBattle.new(game, net, opts) -- decode a remote action message against the enemy battler local function decodeTheirAction(s, msg) - if msg.kind == "move" then - local slot = math.max(1, math.min(#s.enemy.curMoves, math.floor(msg.slot or 1))) - return s.enemy.curMoves[slot] - elseif msg.kind == "struggle" then - return { id = "STRUGGLE", pp = 1, struggle = true } - elseif msg.kind == "locked" then - return s:lockedAction(s.enemy) - end - return nil + return decodeWireAction(s, msg, s.enemy) end -- with the handshake guaranteeing both games share a link surface, a @@ -520,6 +545,19 @@ function LinkBattle.new(game, net, opts) if not s.result then endAsDraw(s, ("%s left the\nbattle."):format(theirName)) end + elseif msg.type == "forfeit" then + -- the peer's own shot clock ran out; unlike a mutual RUN/desync + -- draw, this has a definite winner (us) + if not s.result then + endWithResult(s, "win", ("%s ran out of\ntime!"):format(theirName)) + end + else + -- a tournament control message (bracket_update, the next + -- match_start, ...) can arrive while this match is still + -- finishing up; Tournament.lua drains this once it regains the + -- stack top rather than losing it to this poll loop + s.pendingTournamentMessages = s.pendingTournamentMessages or {} + table.insert(s.pendingTournamentMessages, msg) end end if net.closed and not s.linkEnded and not s.result then @@ -535,16 +573,48 @@ function LinkBattle.new(game, net, opts) end return end + if opts.turnLimit and s.phase == "menu" then + if not s.turnClockActive then + s.turnClockActive = true + s.turnClock = opts.turnLimit + end + s.turnClock = s.turnClock - dt + if s.turnClock <= 0 then + s.turnClockActive = false + send({ type = "forfeit" }) + endWithResult(s, "lose", "Time's up! You\nforfeit the match.") + end + elseif opts.turnLimit then + s.turnClockActive = false + end baseUpdate(s, dt) end + if opts.turnLimit then + local baseDraw = self.draw + self.draw = function(s, ...) + baseDraw(s, ...) + if s.phase == "menu" and s.turnClockActive then + love.graphics.setColor(1, 1, 1, 1) + Font.draw(tostring(math.max(0, math.ceil(s.turnClock))), 144, 4) + end + end + end + local baseFinish = self.finish self.finish = function(s) if not s.linkEnded then s.linkEnded = true send({ type = "bye" }) end - net:close() + -- opts.keepNetOpen: a tournament match's `net` is the caller's + -- long-lived tournament connection (still needed for the next round, + -- spectating, bracket updates, ...), not a dedicated match socket -- + -- closing it here the way a plain 1v1 link battle does would sever + -- the whole tournament, not just this match. + if not opts.keepNetOpen then + net:close() + end if game.linkNet == net then game.linkNet = nil end baseFinish(s) end @@ -552,6 +622,244 @@ function LinkBattle.new(game, net, opts) return self end +-- A tournament spectator: reconstructs the exact same lockstep battle a +-- live match's two real participants are playing, from a copy of the +-- traffic the relay fans out to onlookers (see pokeserver's `spectate` +-- envelope). No local input drives anything here -- both sides' actions +-- arrive over the wire, tagged by which real player sent them -- so it's +-- a read-only replay, not a third participant: no hash/desync checking +-- (a spectator has nothing to verify against), no shot clock (nothing to +-- act on), and `finish` must NOT close `net`, since that's the caller's +-- long-lived tournament connection, not a dedicated match socket. +-- +-- opts: { hostParty = packed, guestParty = packed, hostName, guestName, +-- seed, verdict, strict }. `self.player` is always the host's battler and +-- `self.enemy` the guest's, which is what makes TurnOrder.firstMover's +-- tie-break (below) land on the same result the host's own instance +-- already computed with invertTie=false. +function LinkBattle.newSpectator(game, net, opts) + local BattleState = require("src.battle.BattleState") + local hostName = opts.hostName or "HOST" + local guestName = opts.guestName or "GUEST" + + if not Handshake.battleAllowed(opts.verdict) then + return nil, "Link battle needs\nthe same mods on\nboth games." + end + + local unpackOpts = { strict = opts.strict or false } + local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts, function(p) + return ("%s's %s can't\nbattle on this\ngame."):format(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 ("%s's %s can't\nbattle on this\ngame.\n(%s)"):format( + guestName, tostring(p.species), tostring(why)) + end) + if not guestParty then return nil, guestErr end + if #hostParty == 0 or #guestParty == 0 then + Logger.warn("link: empty party on one side (spectator)") + end + + local self = BattleState.newWild(game, guestParty[1] and guestParty[1].species + or "RATTATA", 5) + self.kind = "link" -- exact same visual treatment as a real link battle + self.spectating = true -- Tournament.lua's marker: don't report a result for this one + self.net = net + game.linkNet = net + self.rng = makeRng(opts.seed or 1) + self.player = mkBattler(game.data, hostParty[1], true) + self.enemy = mkBattler(game.data, guestParty[1], false) + self.enemyParty = guestParty + self.playerParty = hostParty + self.opponentName = guestName + self.introText = ("%s vs %s!"):format(hostName, guestName) + + local function orderMove(action) + if action and action.id then return game.data.moves[action.id] end + return nil + end + + local function endSpectate(s, text) + if s.linkEnded then return end + s.result = s.result or "ended" + s.afterQueue = "finish" + s.phase = "messages" + if text then s:say(text) end + end + + local function sendOutHost(s, mon) + local previous = s.player + s.player = mkBattler(game.data, mon, true) + s:syncSides() + Runtime.emit("battle.battler_switched", { + battle = s, side = s.sides[1], battler = s.player, previous = previous, + }) + s.sendingOut = true + s:sayNext(s:sendOutText(s.player.name)) + s:animNext("POOF_ANIM", false) + s:actNext(function() + s.sendingOut = false + s:startGrowIn(s.player) + require("src.core.Sound").playCry(s.data, s.player.mon.species) + end) + end + + local function sendOutGuest(s, mon) + local previous = s.enemy + s.enemy = mkBattler(game.data, mon, false) + s:syncSides() + Runtime.emit("battle.battler_switched", { + battle = s, side = s.sides[2], battler = s.enemy, previous = previous, + }) + s.enemySendingOut = true + s:sayNext(("%s sent\nout %s!"):format(guestName, s.enemy.name)) + s:actNext(function() + s.enemySendingOut = false + s:startGrowIn(s.enemy) + s:actNext(function() + require("src.core.Sound").playCry(s.data, s.enemy.mon.species) + end) + end) + end + + local function resolveSpecTurn(s, hostMsg, guestMsg) + if hostMsg.kind == "run" or guestMsg.kind == "run" then + endSpectate(s, "The match ended.") + return + end + s.phase = "messages" + s.afterQueue = "linkNext" + s.turnCount = (s.turnCount or 0) + 1 + + if hostMsg.kind == "switch" then + local idx = hostMsg.index + s:act(function() sendOutHost(s, hostParty[idx]) end) + end + if guestMsg.kind == "switch" then + local idx = guestMsg.index + s:act(function() sendOutGuest(s, guestParty[idx]) end) + end + + s:act(function() + 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" + and decodeWireAction(s, guestMsg, s.enemy) or nil + Runtime.emit("battle.turn_started", { + battle = s, turn = s.turnCount, + playerAction = hostAction, enemyAction = guestAction, + }) + if hostAction and guestAction then + local hostMove, guestMove = orderMove(hostAction), orderMove(guestAction) + local first + if Runtime.wantsHook("battle.turn_order") then + first = Runtime.call("battle.turn_order", function(a, aMove, b, bMove, c) + return TurnOrder.firstMover(a, aMove, b, bMove, c.rng, c.invertTie) + end, s.player, hostMove, s.enemy, guestMove, { rng = s.rng, invertTie = false }) + else + first = TurnOrder.firstMover(s.player, hostMove, s.enemy, guestMove, s.rng, false) + end + local order + if first then + order = { { s.player, s.enemy, hostAction }, { s.enemy, s.player, guestAction } } + else + order = { { s.enemy, s.player, guestAction }, { s.player, s.enemy, hostAction } } + end + for _, entry in ipairs(order) do + s:act(function() s:executeAction(entry[1], entry[2], entry[3]) end) + end + elseif hostAction then + s:act(function() s:executeAction(s.player, s.enemy, hostAction) end) + elseif guestAction then + s:act(function() s:executeAction(s.enemy, s.player, guestAction) end) + end + s:act(function() s:endOfTurn() end) + end) + end + + self.resolveTurn = function() end -- a spectator's own input never drives anything + self.resolveSwitch = function() end + self.tryRun = function() end + self.openParty = function(s) s.phase = "waitBoth" end + + self.playerMonFainted = function(s) + for _, mon in ipairs(hostParty) do + if mon.hp > 0 then + s:act(function() sendOutHost(s, mon) end) + return + end + end + s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(hostName, guestName)) + s.result = "guestWin" + s.afterQueue = "finish" + end + + self.enemyMonFainted = function(s) + for _, mon in ipairs(guestParty) do + if mon.hp > 0 then + s:act(function() sendOutGuest(s, mon) end) + return + end + end + s:sayNext(("%s is out of\nPOKéMON!\f%s wins!"):format(guestName, hostName)) + s.result = "hostWin" + s.afterQueue = "finish" + end + + self.hostMsg, self.guestMsg = nil, nil + local baseUpdate = self.update + self.update = function(s, dt) + net:update() + for _, msg in ipairs(net:poll()) do + if msg.type == "spectate" then + local inner = msg.msg + if inner.type == "action" then + if msg.side == "host" then s.hostMsg = inner else s.guestMsg = inner end + if s.hostMsg and s.guestMsg then + local h, g = s.hostMsg, s.guestMsg + s.hostMsg, s.guestMsg = nil, nil + resolveSpecTurn(s, h, g) + end + elseif inner.type == "bye" or inner.type == "forfeit" then + if not s.result then endSpectate(s, "The match ended.") end + end + -- "hello"/"party"/"hash" ride along too (Tournament.lua already + -- consumed hello/party before building this battle); none of them + -- need any action here + else + -- same reasoning as the real-participant loop above: don't lose a + -- bracket_update/match_start_spectate that arrives mid-match + s.pendingTournamentMessages = s.pendingTournamentMessages or {} + table.insert(s.pendingTournamentMessages, msg) + end + end + if net.closed and not s.linkEnded and not s.result then + endSpectate(s) + end + if s.phase == "messages" and s.afterQueue == "linkNext" then + if not s:updateQueue() then + s.afterQueue = "waitBoth" + s.phase = "waitBoth" + end + return + end + if s.phase == "menu" or s.phase == "waitBoth" then + return -- frozen between resolved turns; never a real decision here + end + baseUpdate(s, dt) + end + + local baseFinish = self.finish + self.finish = function(s) + s.linkEnded = true + if game.linkNet == net then game.linkNet = nil end + baseFinish(s) -- deliberately doesn't touch net: it's the caller's + -- tournament connection, still needed after this match + end + + return self +end + -- backwards-compatible entry points (LinkState passes role explicitly) function LinkBattle.newHost(game, net, opts) opts.role = "host" diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 091578fd..2295dd6d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -2,6 +2,7 @@ -- the other joins by typing that address in. Direct peer-to-peer over -- lua-enet (bundled with LÖVE), no relay server. +local CodeEntry = require("src.link.CodeEntry") local Font = require("src.render.Font") local Handshake = require("src.link.Handshake") local Net = require("src.link.Net") @@ -16,6 +17,12 @@ LinkState.isOpaque = true local CURSOR = 0xED +-- stages before any Net object is meaningfully "this session's link" -- +-- .net can still be a leftover failed attempt sitting on self, so error/ +-- closed checks below skip these rather than keying off self.net's +-- presence alone +local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } + -- how long the host waits for a v2 hello before deciding the peer predates -- the handshake (a pre-mod guest sends nothing until it hears the mode) local HELLO_GRACE = 2 @@ -119,25 +126,54 @@ function LinkState:update(dt) local input = self.game.input if self.net then self.net:update() - if self.net.error and self.stage ~= "menu" then + if self.net.error and not PRE_CONNECT_STAGES[self.stage] then self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) return end -- the peer vanished without a bye (only once the inbox is drained, -- so a final message travelling with the disconnect still counts) if self.net.closed and #self.net.inbox == 0 - and self.stage ~= "menu" and self.stage ~= "addrEntry" - and self.stage ~= "notice" and self.stage ~= "battleRunning" then + and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" + and self.stage ~= "codeEntry" and self.stage ~= "notice" + and self.stage ~= "battleRunning" then self:exitWith("The link was\nbroken.") return end end if self.stage == "menu" then + if input:wasPressed("down") then + self.index = self.index % 3 + 1 + elseif input:wasPressed("up") then + self.index = (self.index - 2) % 3 + 1 + elseif input:wasPressed("b") then + self:exitWith(nil) + elseif input:wasPressed("a") then + if self.index == 1 then + self.stage = "lanMenu" + self.index = 1 + elseif self.index == 2 or self.index == 3 then + if not Handshake.onlineAllowed(self.game) then + self:exitWith("Online play needs\nno mods enabled.\fDisable them in\nSTART > MODS.") + return + end + if self.index == 2 then + self.stage = "onlineMenu" + else + local Tournament = require("src.link.Tournament") + self.game.stack:pop() + self.game.stack:push(Tournament.new(self.game)) + end + self.index = 1 + end + end + + elseif self.stage == "lanMenu" then if input:wasPressed("up") or input:wasPressed("down") then self.index = self.index == 1 and 2 or 1 elseif input:wasPressed("b") then - self:exitWith(nil) + self.stage = "menu" + self.index = 1 elseif input:wasPressed("a") then self.net = Net.new() if self.index == 1 then @@ -151,6 +187,62 @@ function LinkState:update(dt) end end + elseif self.stage == "onlineMenu" then + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("b") then + self.stage = "menu" + self.index = 2 + elseif input:wasPressed("a") then + if self.index == 1 then + self.net = Net.new() + if self.net:hostOnline() then + self.stage = "onlineHosting" + else + self:exitWith("Link error:\n" .. (self.net.error or "?")) + end + else + self.stage = "codeEntry" + self.codeEntry = CodeEntry.new() + end + end + + elseif self.stage == "onlineHosting" then + if input:wasPressed("b") then self:exitWith(nil) return end + if self.net.paired then + self.stage = "modeSelect" + self.index = 1 + end + + elseif self.stage == "codeEntry" then + if input:wasPressed("b") then + self.stage = "onlineMenu" + self.index = 2 + elseif input:wasPressed("up") then + CodeEntry.up(self.codeEntry) + elseif input:wasPressed("down") then + CodeEntry.down(self.codeEntry) + elseif input:wasPressed("left") then + CodeEntry.left(self.codeEntry) + elseif input:wasPressed("right") then + CodeEntry.right(self.codeEntry) + elseif input:wasPressed("a") then + local code = CodeEntry.text(self.codeEntry) + self.net = Net.new() + if self.net:joinOnline(nil, code) then + self.stage = "onlineJoining" + else + self:exitWith("Link error:\n" .. (self.net.error or "?")) + end + end + + elseif self.stage == "onlineJoining" then + if input:wasPressed("b") then self:exitWith(nil) return end + if self.net.paired then + self.stage = "waitMode" + self:sendHello(nil) -- the host owns the mode; this is just who we are + end + elseif self.stage == "hosting" then if input:wasPressed("b") then self:exitWith(nil) return end if self.net.paired then @@ -389,12 +481,49 @@ end function LinkState:draw() if self.stage == "menu" then - drawTitle("LINK CABLE CLUB") + drawTitle("BOIS CLUB LIVE") + Font.draw("LINK CABLE (LAN)", 32, 44) + Font.draw("ONLINE MATCH", 32, 60) + Font.draw("TOURNAMENT", 32, 76) + Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16) + + elseif self.stage == "lanMenu" then + drawTitle("LINK CABLE (LAN)") Font.draw("HOST A GAME", 32, 48) Font.draw("JOIN A GAME", 32, 68) Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) Font.draw("UDP port " .. Net.defaultPort(), 8, 128) + elseif self.stage == "onlineMenu" then + drawTitle("ONLINE MATCH") + Font.draw("HOST ONLINE", 32, 48) + Font.draw("JOIN ONLINE", 32, 68) + Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) + + elseif self.stage == "onlineHosting" then + drawTitle("HOSTING ONLINE") + Font.draw("Tell your friend", 16, 40) + Font.draw("the code:", 16, 52) + Font.draw(self.net.code or "??????", 32, 68) + Font.draw("Waiting for join...", 8, 96) + + elseif self.stage == "codeEntry" then + drawTitle("ENTER CODE") + for i = 1, CodeEntry.LENGTH do + local x = 16 + (i - 1) * 16 + local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i]) + Font.draw(ch, x, 64) + if i == self.codeEntry.pos then + Font.drawCode(0xEE, x, 76) -- ▼ under the active slot + end + end + Font.draw("A: connect B: back", 8, 128) + + elseif self.stage == "onlineJoining" then + drawTitle("CONNECTING...") + Font.draw("Calling...", 8, 56) + Font.draw(self.net.target or "", 8, 72) + elseif self.stage == "hosting" then drawTitle("HOSTING") Font.draw("Friend joins at:", 16, 48) diff --git a/src/link/Net.lua b/src/link/Net.lua index 76c61490..ca72d5a9 100644 --- a/src/link/Net.lua +++ b/src/link/Net.lua @@ -14,6 +14,15 @@ -- Plain luajit (headless tests) has no enet; Net.available() reports -- that, and Net.loopbackPair() returns two in-memory ends with the -- same API so the protocol/battle logic stays testable offline. +-- +-- A second backend, alongside the ENet one above, talks plain TCP to a +-- pokeserver relay instead of direct peer-to-peer: both sides dial out +-- to a public server (works through NAT with no hole-punching), the host +-- gets a 6-character room code instead of an IP, and the server forwards +-- messages between them. Same newline-delimited JSON on the wire, same +-- Net API (host/join/send/update/poll/.paired/.closed/.error) -- see +-- Net:hostOnline/Net:joinOnline below. LinkState/LinkBattle/Protocol don't +-- know or care which backend is in play. local Json = require("src.link.Json") local Logger = require("src.core.Logger") @@ -21,10 +30,14 @@ local Logger = require("src.core.Logger") local hasEnet, enet = pcall(require, "enet") if not hasEnet then enet = nil end +local hasSocket, socket = pcall(require, "socket") +if not hasSocket then socket = nil end + local Net = {} Net.__index = Net Net.DEFAULT_PORT = 7777 +Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778" function Net.available() return enet ~= nil @@ -34,6 +47,10 @@ function Net.defaultPort() return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT end +function Net.defaultRelayAddress() + return os.getenv("POKEPORT_RELAY_ADDR") or Net.DEFAULT_RELAY_ADDRESS +end + -- monotonic-ish clock for the join timeout local function now() if love and love.timer and love.timer.getTime then @@ -64,13 +81,17 @@ function Net.new() enetHost = nil, -- our enet host object (both ends have one) peer = nil, -- the connected remote peer inbox = {}, - outbox = {}, -- messages queued before pairing completes + outbox = {}, -- messages queued before pairing completes (enet only) paired = false, address = nil, -- host: "ip:port" the other player types in error = nil, closed = false, mode = nil, joinTimeout = 10, + tcpSocket = nil, -- relay backend: the luasocket TCP connection + rxBuf = "", -- relay backend: bytes read but not yet a full line + txBuf = "", -- relay backend: bytes queued but not yet written + code = nil, -- relay backend, hosting: the room/tournament code }, Net) end @@ -133,8 +154,56 @@ function Net:join(address) return true end +-- opens a TCP connection to a pokeserver relay (blocking connect with a +-- short timeout -- this runs once, from a single explicit user action, not +-- from a per-frame poll, so blocking briefly is fine). Callers then send +-- whatever control message starts their session ({type="host"}, +-- {type="join",...}, {type="host_tournament",...}, ...); every reply that +-- isn't one of the four generic ones below lands in the normal inbox for +-- the caller (LinkState, or Tournament.lua) to interpret. +function Net:connectTCP(addr) + if not socket then + self.error = "online play needs luasocket (bundled with LOVE)" + return false + end + local host, port = addr:match("^(.-):(%d+)$") + host = host or addr + port = tonumber(port) or 7778 + local tcp = socket.tcp() + tcp:settimeout(5) + local ok, err = tcp:connect(host, port) + if not ok then + self.error = ("can't reach relay %s:%d\n(%s)"):format(host, port, tostring(err)) + return false + end + tcp:settimeout(0) + self.tcpSocket = tcp + self.rxBuf = "" + self.txBuf = "" + return true +end + +function Net:hostOnline(addr) + if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end + self.mode = "onlineHosting" + self:send({ type = "host" }) + return true +end + +function Net:joinOnline(addr, code) + if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end + self.mode = "onlineJoining" + self.target = code + self:send({ type = "join", code = code }) + return true +end + function Net:send(msg) if self.closed then return end + if self.tcpSocket then + self.txBuf = self.txBuf .. Json.encode(msg) .. "\n" + return + end if self.peerEnd then -- loopback: re-encode through json like the wire local decoded = Json.decode(Json.encode(msg)) if decoded and not self.peerEnd.closed then @@ -155,8 +224,102 @@ function Net:send(msg) end end +-- one control message recognized on every relay connection, regardless of +-- what it's being used for (a 1v1 room or a tournament): "peer_gone" only +-- ever fires for a paired 1v1 room (tournaments signal disconnects through +-- bracket_update/tournament_over instead), so it's unambiguous here. +local function handleGenericRelayControl(self, msg) + if msg.type == "hosted" then + self.code = msg.code + return true + elseif msg.type == "paired" then + self.paired = true + return true + elseif msg.type == "join_error" then + self.error = ({ + not_found = "That code wasn't\nfound.", + full = "That game already\nhas two players.", + expired = "That code has\nexpired.", + })[msg.reason] or ("Couldn't join:\n%s"):format(tostring(msg.reason)) + self.closed = true + return true + elseif msg.type == "peer_gone" then + self.closed = true + return true + end + return false +end + +function Net:handleTCPLine(line) + local msg = Json.decode(line) + if not msg then + Logger.warn("link: bad relay message %q", line:sub(1, 60)) + return + end + if not handleGenericRelayControl(self, msg) then + table.insert(self.inbox, msg) + end +end + +-- pulls every complete "\n"-terminated line out of rxBuf (leaving a +-- trailing partial line, if any, for the next call to complete) and hands +-- each to handleTCPLine. Pure buffer manipulation, no socket -- factored +-- out of updateTCP so the framing logic is testable without a real +-- connection (see the relay-path tests in tests/run_link_tests.lua). +function Net:drainLines() + while true do + local nl = self.rxBuf:find("\n", 1, true) + if not nl then break end + local line = self.rxBuf:sub(1, nl - 1) + self.rxBuf = self.rxBuf:sub(nl + 1) + if #line > 0 then self:handleTCPLine(line) end + end +end + +-- non-blocking pump for the relay TCP backend: flush queued writes, drain +-- whatever's arrived into complete lines. Uses a byte-count receive +-- (rather than the "*l" pattern) because luasocket's "*l" doesn't let a +-- non-blocking caller recover the partial line across calls -- a +-- byte-count read hands back whatever's available via the third return +-- value on timeout, which we can buffer ourselves. +function Net:updateTCP() + if self.closed then return end + local sock = self.tcpSocket + if #self.txBuf > 0 then + local sent, err, lastByte = sock:send(self.txBuf) + if sent then + self.txBuf = "" + elseif err == "timeout" then + self.txBuf = self.txBuf:sub((lastByte or 0) + 1) + else + self.error = "send failed: " .. tostring(err) + self.closed = true + return + end + end + while true do + local data, err, partial = sock:receive(8192) + local chunk = data or partial or "" + if #chunk > 0 then self.rxBuf = self.rxBuf .. chunk end + if err == "closed" then + self.closed = true + break + elseif err and err ~= "timeout" then + self.error = tostring(err) + self.closed = true + break + end + if not data then break end -- nothing more buffered this frame + end + self:drainLines() +end + -- pump enet events; decoded JSON messages are queued for poll() function Net:update() + if self.tcpSocket then + self:updateTCP() + return + end if self.peerEnd then return end -- loopback needs no pumping if not self.enetHost or self.closed then return end while true do @@ -219,6 +382,12 @@ function Net:close() self.closed = true return end + if self.tcpSocket then + pcall(function() self.tcpSocket:close() end) + self.tcpSocket = nil + self.closed = true + return + end if self.enetHost then if self.peer and self.paired and not self.closed then -- graceful goodbye: disconnect_later delivers the queued diff --git a/src/link/Tournament.lua b/src/link/Tournament.lua new file mode 100644 index 00000000..1bd9e840 --- /dev/null +++ b/src/link/Tournament.lua @@ -0,0 +1,577 @@ +-- Tournament play: host or join a bracket over the pokeserver relay. +-- Single-elimination, server-managed (pokeserver's `tournaments` map). +-- Matches run one at a time in bracket order; everyone not currently +-- playing -- still waiting their turn, or already eliminated -- watches +-- the live match play out via LinkBattle.newSpectator, reconstructed from +-- a copy of the real traffic the server fans out. Battle mode only (no +-- trade); vanilla only (Handshake.onlineAllowed already gated entry here +-- from LinkState). Elite Four music (Music_IndigoPlateau) loops the whole +-- time, uninterrupted by individual matches. + +local CodeEntry = require("src.link.CodeEntry") +local Font = require("src.render.Font") +local Handshake = require("src.link.Handshake") +local LinkBattle = require("src.link.LinkBattle") +local Net = require("src.link.Net") +local Protocol = require("src.link.Protocol") +local Runtime = require("src.mods.Runtime") +local Sound = require("src.core.Sound") +local TextBox = require("src.render.TextBox") + +local Tournament = {} +Tournament.__index = Tournament +Tournament.isOpaque = true + +local CURSOR = 0xED +local MUSIC = "Music_IndigoPlateau" +local TURN_LIMITS = { 3, 6, 9 } +local PARTY_SIZES = { 1, 2, 3, 4, 5, 6 } +local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs + -- (LuaJIT's # still reports the literal's full size, but + -- ipairs stops dead at the hole), so level bounds use + -- this string in self.settings instead of nil, converted + -- to nil only on the wire +local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, + 70, 75, 80, 85, 90, 95, 100 } +local SETTINGS_ROWS = 5 -- POKEMON, MIN LV, MAX LV, TIMER, PLAYING + +local function indexOf(list, value) + for i, v in ipairs(list) do + if v == value then return i end + end + return 1 +end + +-- accepts either the internal ANY sentinel or a raw wire value (nil means +-- "any" there too, since the server never round-trips the sentinel string) +local function levelLabel(v) + return (v == ANY or v == nil) and "ANY" or tostring(v) +end + +local function levelForWire(v) + return v == ANY and nil or v +end + +local function partyStats(party) + local size, minLevel, maxLevel = 0, nil, nil + for _, mon in ipairs(party or {}) do + size = size + 1 + local lvl = mon.level or 1 + minLevel = minLevel and math.min(minLevel, lvl) or lvl + maxLevel = maxLevel and math.max(maxLevel, lvl) or lvl + end + return size, minLevel or 0, maxLevel or 0 +end + +function Tournament.new(game) + local self = setmetatable({}, Tournament) + self.game = game + self.stage = "menu" + self.index = 1 + self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY, + participating = true } + self.settingsIndex = 1 + self.roster = {} + self.spectatorRoster = {} + Sound.startLoop(game.data, MUSIC) + return self +end + +function Tournament:exitWith(message) + Sound.stopLoop(MUSIC) + Runtime.emit("link.ended", { reason = message and "error" or "bye" }) + if self.net then self.net:close() end + self.game.stack:pop() + if message then + self.game.stack:push(TextBox.new(self.game, message)) + end +end + +-- ------------------------------------------------------------------- +-- host / join +-- ------------------------------------------------------------------- + +function Tournament:startHosting() + self.net = Net.new() + if not self.net:connectTCP(Net.defaultRelayAddress()) then + self:exitWith("Link error:\n" .. (self.net.error or "?")) + return + end + local size, minL, maxL = partyStats(self.game.save.party) + self.isCreator = true + self.participating = self.settings.participating + self.net:send({ + type = "host_tournament", + turnLimit = self.settings.turnLimit, + requiredPartySize = self.settings.requiredPartySize, + minLevel = levelForWire(self.settings.minLevel), + maxLevel = levelForWire(self.settings.maxLevel), + participating = self.settings.participating, + name = self.game.save.player.name, + partySize = size, partyMinLevel = minL, partyMaxLevel = maxL, + }) + self.stage = "registering" +end + +function Tournament:startJoining(code) + self.net = Net.new() + if not self.net:connectTCP(Net.defaultRelayAddress()) then + self:exitWith("Link error:\n" .. (self.net.error or "?")) + return + end + local size, minL, maxL = partyStats(self.game.save.party) + self.isCreator = false + self.participating = true -- joining is always to compete; only hosting can opt out + self.code = code + self.net:send({ + type = "join_tournament", code = code, name = self.game.save.player.name, + partySize = size, partyMinLevel = minL, partyMaxLevel = maxL, + }) + self.stage = "registering" +end + +-- ------------------------------------------------------------------- +-- message handling (shared between "registering"/"bracket" and drained +-- again from a just-finished match's pendingTournamentMessages) +-- ------------------------------------------------------------------- + +local JOIN_ERROR_TEXT = { + not_found = "That code wasn't\nfound.", + already_started = "That tournament\nhas already begun.", + expired = "That code has\nexpired.", +} + +function Tournament:handleMessage(msg) + if msg.type == "tournament_hosted" then + self.code = msg.code + self.settings.turnLimit = msg.turnLimit + self.participating = msg.participating + self.stage = "bracket" + elseif msg.type == "tournament_host_error" then + if msg.reason == "party_ineligible" then + self:exitWith(("Can't host:\nneed %d Pokemon\nLv %s-%s."):format( + msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) + else + self:exitWith("Couldn't host\nthat tournament.") + end + elseif msg.type == "tournament_join_error" then + if msg.reason == "party_ineligible" then + self:exitWith(("Your party needs\n%d Pokemon, Lv\n%s-%s."):format( + msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel))) + else + self:exitWith(JOIN_ERROR_TEXT[msg.reason] or "Couldn't join\nthat tournament.") + end + elseif msg.type == "tournament_roster" then + self.roster = msg.players + self.spectatorRoster = msg.spectators or {} + self.settings.turnLimit = msg.turnLimit + self.settings.requiredPartySize = msg.requiredPartySize + self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel + self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel + self.stage = "bracket" + elseif msg.type == "bracket_update" then + self.bracket = msg.tournament + self.code = self.bracket.code + if self.stage == "registering" then self.stage = "bracket" end + elseif msg.type == "match_start" then + self:enterMatch(msg) + elseif msg.type == "match_start_spectate" then + self:enterSpectate(msg) + elseif msg.type == "tournament_bye" then + self.byeRound = msg.round + elseif msg.type == "tournament_over" then + self.champion = msg.champion + self.stage = "done" + end +end + +-- ------------------------------------------------------------------- +-- entering a match: real participant +-- ------------------------------------------------------------------- + +function Tournament:sendHello(mode) + self.myHello = Handshake.hello(self.game, mode) + self.net:send(self.myHello) +end + +function Tournament:pollHello() + local msgs = self.net:poll() + local keep, got = {}, false + for _, msg in ipairs(msgs) do + if msg.type == "hello" and not self.peerHello then + self.peerHello = msg + got = true + else + keep[#keep + 1] = msg + end + end + for i = #keep, 1, -1 do + table.insert(self.net.inbox, 1, keep[i]) + end + return got +end + +function Tournament:enterMatch(msg) + self.isHost = (msg.role == "host") + self.opponentName = msg.opponent + self.matchRound = msg.round + self.matchTurnLimit = msg.turnLimit + self.peerHello = nil + self:sendHello(self.isHost and "battle" or nil) + self.stage = "matchHello" +end + +function Tournament:beginMatchBattle() + local verdict = Handshake.checkCompat(self.myHello, self.peerHello) + if not (verdict == "full" or verdict == "vanilla_peer") then + -- shouldn't happen (both sides already passed the online-play mods + -- gate), but a mismatched engine/build is still possible -- bail out + -- of just this match rather than crash the tournament + self:exitWith("Link error:\nversion mismatch\nwith opponent.") + return + end + self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil + local opts = { + myParty = Protocol.packParty(self.game.save.party), + theirName = self.opponentName or "FOE", + seed = self.isHost and self.linkSeed or nil, + verdict = verdict, + strict = Handshake.strict(verdict), + turnLimit = self.matchTurnLimit, + keepNetOpen = true, -- this is the tournament's own connection, not a + -- dedicated match socket -- don't let finish() close it + } + self.stage = "matchWaitParty" + self.pendingBattleOpts = opts + self.net:send({ type = "party", + mons = Protocol.packParty(self.game.save.party), + seed = self.linkSeed }) +end + +-- ------------------------------------------------------------------- +-- entering a match: spectator +-- ------------------------------------------------------------------- + +function Tournament:enterSpectate(msg) + self.matchRound = msg.round + self.spectate = { + hostName = msg.playerHost, guestName = msg.playerGuest, + hostParty = nil, guestParty = nil, seed = nil, + } + self.stage = "spectateWait" +end + +-- ------------------------------------------------------------------- +-- update +-- ------------------------------------------------------------------- + +function Tournament:update(dt) + local input = self.game.input + + if self.stage == "matchRunning" or self.stage == "spectateRunning" then + if self.game.stack:top() == self then + -- the battle popped; drain anything the network handed to it but it + -- didn't itself understand (a bracket_update, the next match...) + local battle = self.activeBattle + self.activeBattle = nil + if self.stage == "matchRunning" and battle and battle.result and self.net + and not self.net.closed then + -- a real participant reports its own outcome; the server resolves + -- the match once both sides have (or one disconnects) + self.net:send({ type = "tournament_result", result = battle.result }) + end + if battle and battle.pendingTournamentMessages then + for _, msg in ipairs(battle.pendingTournamentMessages) do + self:handleMessage(msg) + end + end + if self.stage == "matchRunning" or self.stage == "spectateRunning" then + self.stage = "bracket" + end + end + return + end + + if self.net then + self.net:update() + if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings" + and self.stage ~= "codeEntry" then + self:exitWith("Link error:\n" .. self.net.error:sub(1, 60)) + return + end + if self.net.closed and self.stage ~= "done" then + self:exitWith("The tournament\nconnection was\nlost.") + return + end + end + + if self.stage == "matchHello" then + if input:wasPressed("b") then self:exitWith(nil) return end + self:pollHello() + if self.peerHello then self:beginMatchBattle() end + for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end + return + elseif self.stage == "matchWaitParty" then + if input:wasPressed("b") then self:exitWith(nil) return end + local msgs = self.net:poll() + for i, msg in ipairs(msgs) do + if msg.type == "party" then + self.pendingBattleOpts.theirParty = msg.mons + if self.isHost then + self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed + else + self.pendingBattleOpts.seed = msg.seed + end + local battle, why = self.isHost + and LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts) + or LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts) + if not battle then + self:exitWith(why or "Link battle\ncan't start.") + return + end + -- anything after `party` in this same batch belongs to the + -- battle now, not to Tournament -- put it back for its own poll() + for j = #msgs, i + 1, -1 do + table.insert(self.net.inbox, 1, msgs[j]) + end + self.activeBattle = battle + self.game.stack:push(battle) + self.stage = "matchRunning" + return + else + self:handleMessage(msg) + end + end + return + elseif self.stage == "spectateWait" then + if input:wasPressed("b") then self:exitWith(nil) return end + local msgs = self.net:poll() + for i, msg in ipairs(msgs) do + if msg.type == "spectate" and msg.msg.type == "party" then + local inner = msg.msg + if msg.side == "host" then + self.spectate.hostParty = inner.mons + self.spectate.seed = inner.seed + else + self.spectate.guestParty = inner.mons + end + if self.spectate.hostParty and self.spectate.guestParty then + local battle, why = LinkBattle.newSpectator(self.game, self.net, { + hostParty = self.spectate.hostParty, guestParty = self.spectate.guestParty, + hostName = self.spectate.hostName, guestName = self.spectate.guestName, + seed = self.spectate.seed, + }) + if not battle then + self:exitWith(why or "Can't watch this\nmatch.") + return + end + for j = #msgs, i + 1, -1 do + table.insert(self.net.inbox, 1, msgs[j]) + end + self.activeBattle = battle + self.game.stack:push(battle) + self.stage = "spectateRunning" + return + end + else + self:handleMessage(msg) + end + end + return + end + + if self.net then + for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end + end + + if self.stage == "menu" then + if input:wasPressed("up") or input:wasPressed("down") then + self.index = self.index == 1 and 2 or 1 + elseif input:wasPressed("b") then + self:exitWith(nil) + elseif input:wasPressed("a") then + if self.index == 1 then + self.stage = "hostSettings" + self.settingsIndex = 1 + else + self.stage = "codeEntry" + self.codeEntry = CodeEntry.new() + end + end + + elseif self.stage == "hostSettings" then + if input:wasPressed("b") then + self.stage = "menu" + self.index = 1 + elseif input:wasPressed("up") then + self.settingsIndex = self.settingsIndex == 1 and SETTINGS_ROWS or self.settingsIndex - 1 + elseif input:wasPressed("down") then + self.settingsIndex = self.settingsIndex % SETTINGS_ROWS + 1 + elseif input:wasPressed("left") or input:wasPressed("right") then + local delta = input:wasPressed("right") and 1 or -1 + if self.settingsIndex == 1 then + local i = indexOf(PARTY_SIZES, self.settings.requiredPartySize) + i = ((i - 1 + delta) % #PARTY_SIZES) + 1 + self.settings.requiredPartySize = PARTY_SIZES[i] + elseif self.settingsIndex == 2 then + local i = indexOf(LEVEL_STEPS, self.settings.minLevel) + i = ((i - 1 + delta) % #LEVEL_STEPS) + 1 + self.settings.minLevel = LEVEL_STEPS[i] + elseif self.settingsIndex == 3 then + local i = indexOf(LEVEL_STEPS, self.settings.maxLevel) + i = ((i - 1 + delta) % #LEVEL_STEPS) + 1 + self.settings.maxLevel = LEVEL_STEPS[i] + elseif self.settingsIndex == 4 then + local i = indexOf(TURN_LIMITS, self.settings.turnLimit) + i = ((i - 1 + delta) % #TURN_LIMITS) + 1 + self.settings.turnLimit = TURN_LIMITS[i] + elseif self.settingsIndex == 5 then + self.settings.participating = not self.settings.participating + end + elseif input:wasPressed("a") or input:wasPressed("start") then + self:startHosting() + end + + elseif self.stage == "codeEntry" then + if input:wasPressed("b") then + self.stage = "menu" + self.index = 2 + elseif input:wasPressed("up") then + CodeEntry.up(self.codeEntry) + elseif input:wasPressed("down") then + CodeEntry.down(self.codeEntry) + elseif input:wasPressed("left") then + CodeEntry.left(self.codeEntry) + elseif input:wasPressed("right") then + CodeEntry.right(self.codeEntry) + elseif input:wasPressed("a") then + self:startJoining(CodeEntry.text(self.codeEntry)) + end + + elseif self.stage == "registering" then + if input:wasPressed("b") then self:exitWith(nil) end + + elseif self.stage == "bracket" then + if input:wasPressed("b") then self:exitWith(nil) return end + if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then + self.net:send({ type = "start_tournament" }) + end + + elseif self.stage == "done" then + if input:wasPressed("a") or input:wasPressed("b") then + self:exitWith(nil) + end + end +end + +-- ------------------------------------------------------------------- +-- draw +-- ------------------------------------------------------------------- + +local function drawTitle(text) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.rectangle("fill", 0, 0, 160, 144) + love.graphics.setColor(0, 0, 0, 1) + Font.draw(text, 8, 6) +end + +local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "PLAYING" } + +function Tournament:draw() + if self.stage == "menu" then + drawTitle("TOURNAMENT") + Font.draw("HOST", 32, 48) + Font.draw("JOIN", 32, 68) + Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68) + + elseif self.stage == "hostSettings" then + drawTitle("TOURNAMENT RULES") + local values = { + tostring(self.settings.requiredPartySize), + levelLabel(self.settings.minLevel), + levelLabel(self.settings.maxLevel), + self.settings.turnLimit .. "s", + self.settings.participating and "YES" or "NO", + } + for i, label in ipairs(SETTINGS_LABELS) do + local y = 32 + (i - 1) * 16 + Font.draw(label, 16, y) + Font.draw(values[i], 96, y) + if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end + end + Font.draw("START: create", 8, 128) + + elseif self.stage == "codeEntry" then + drawTitle("ENTER CODE") + for i = 1, CodeEntry.LENGTH do + local x = 16 + (i - 1) * 16 + local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i]) + Font.draw(ch, x, 64) + if i == self.codeEntry.pos then + Font.drawCode(0xEE, x, 76) + end + end + Font.draw("A: join B: back", 8, 128) + + elseif self.stage == "registering" then + drawTitle("CONNECTING...") + Font.draw("B: cancel", 8, 128) + + elseif self.stage == "bracket" or self.stage == "matchHello" + or self.stage == "matchWaitParty" or self.stage == "spectateWait" then + drawTitle(("TOURNAMENT %s"):format(self.code or "??????")) + if self.bracket then + local y = 20 + for _, round in ipairs(self.bracket.rounds) do + Font.draw(("ROUND %d"):format(round.round), 8, y) + y = y + 10 + for _, m in ipairs(round.matches) do + local line + if m.bye then + line = ("%s (bye)"):format(m.a or m.b or "?") + else + local mark = m.state == "live" and "*" or (m.winner and "" or "") + line = ("%s%s vs %s%s"):format( + m.winner == m.a and ">" or " ", m.a or "?", + m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or "")) + end + Font.draw(line, 12, y) + y = y + 10 + if y > 120 then break end + end + end + else + if self.participating == false then + Font.draw("(organizing --", 16, 32) + Font.draw("not playing)", 16, 42) + end + Font.draw("Waiting for", 16, 48) + Font.draw("players to join:", 16, 60) + local y = 60 + for i, name in ipairs(self.roster) do + y = 60 + i * 10 + Font.draw(name, 24, y) + end + for _, name in ipairs(self.spectatorRoster) do + y = y + 10 + Font.draw(name .. " (watch)", 24, y) + end + end + if self.isCreator and #self.roster >= 2 and not self.bracket then + Font.draw("A: START B: cancel", 8, 132) + else + Font.draw("B: cancel", 8, 132) + end + + elseif self.stage == "done" then + drawTitle("TOURNAMENT OVER") + if self.champion then + Font.draw(("%s is the"):format(self.champion), 16, 56) + Font.draw("champion!", 16, 68) + end + Font.draw("A: continue", 8, 128) + end + love.graphics.setColor(1, 1, 1, 1) +end + +return Tournament diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index d2fa39d0..64da0727 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -295,6 +295,16 @@ function PaletteFX.monPal(data, species, transformed) return p.palettes.GRAYMON or (data and data.palettes and data.palettes.palettes.GRAYMON) end + -- a species' own `palette` field (per-record override) wins over the + -- vanilla species->name map. Mod-registered palettes live in + -- data.palettes.palettes even when the active pack is the RED++ gbc pack, + -- so fall back to it when the pack itself doesn't carry the name. + local def = data and data.pokemon and data.pokemon[species] + if def and def.palette then + local pal = p.palettes[def.palette] + or (data and data.palettes and data.palettes.palettes[def.palette]) + if pal then return pal end + end local name = p.pokemon[species] or "MEWMON" local c = p.palettes[name] if c then return c end @@ -308,6 +318,12 @@ end -- palette name a species currently resolves to (for image-cache keys) function PaletteFX.monPalName(data, species, transformed) if transformed then return "GRAYMON" end + -- honor the per-record palette override, matching monPal + local def = data and data.pokemon and data.pokemon[species] + if def and def.palette and data.palettes + and data.palettes.palettes[def.palette] then + return def.palette + end local p = PaletteFX.pack(data) if p and p.pokemon[species] then return p.pokemon[species] end if data and data.palettes and data.palettes.pokemon[species] then diff --git a/tests/drivers/net_relay_smoke.lua b/tests/drivers/net_relay_smoke.lua new file mode 100644 index 00000000..1412535c --- /dev/null +++ b/tests/drivers/net_relay_smoke.lua @@ -0,0 +1,63 @@ +-- Driver: exercises src/link/Net.lua's TCP relay backend for real, inside +-- LOVE (real lua-enet/luasocket), against a pokeserver already running at +-- 127.0.0.1:7778 (POKEPORT_RELAY_ADDR overrides). Doesn't touch the game +-- UI at all -- just proves host/join/relay/close over a real socket. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Net = require("src.link.Net") + local addr = os.getenv("POKEPORT_RELAY_ADDR") or "127.0.0.1:7778" + + local host = Net.new() + local ok = host:hostOnline(addr) + U.log("hostOnline ok:", ok, "error:", host.error) + + local frames = 0 + while not host.code and not host.error and frames < 180 do + host:update() + frames = frames + 1 + coroutine.yield() + end + U.log("host code:", host.code, "error:", host.error) + + local guest = Net.new() + local gok = guest:joinOnline(addr, host.code) + U.log("joinOnline ok:", gok, "error:", guest.error) + + frames = 0 + while (not host.paired or not guest.paired) and frames < 180 do + host:update() + guest:update() + frames = frames + 1 + coroutine.yield() + end + U.log("host.paired:", host.paired, "guest.paired:", guest.paired) + + host:send({ type = "hello", name = "RED" }) + local relayed = nil + frames = 0 + while not relayed and frames < 180 do + host:update() -- flushes host's queued send + guest:update() + for _, msg in ipairs(guest:poll()) do + if msg.type == "hello" then relayed = msg end + end + frames = frames + 1 + coroutine.yield() + end + U.log("relayed hello name:", relayed and relayed.name) + + guest:close() + frames = 0 + while not host.closed and frames < 180 do + host:update() + frames = frames + 1 + coroutine.yield() + end + U.log("host saw peer_gone / closed:", host.closed) + + host:close() + + local pass = host.code ~= nil and host.paired and guest.paired + and relayed ~= nil and relayed.name == "RED" and host.closed + U.log("NET_RELAY_SMOKE:", pass and "PASS" or "FAIL") +end diff --git a/tests/drivers/online_play_test.lua b/tests/drivers/online_play_test.lua new file mode 100644 index 00000000..b2c163b4 --- /dev/null +++ b/tests/drivers/online_play_test.lua @@ -0,0 +1,53 @@ +-- Driver: screenshots the new online-play menu surface (LinkState's +-- restructured top menu, the online host/join flow). Pushed directly +-- (bypassing Start-menu navigation, matching options_test.lua's +-- convention) so it doesn't depend on party/save state. +-- +-- Run with no mods discoverable (mod enable/disable only takes effect at +-- the next boot -- see Handshake.onlineAllowed's comment -- so a live +-- toggle can't simulate "vanilla" mid-session; the mod directory has to +-- actually be absent for this run). +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + + local LinkState = require("src.link.LinkState") + + local function closeAnyOpenScreens() + while game.stack:top() and game.stack:top().exitWith do + game.stack:top():exitWith(nil) + U.wait(2) + end + end + + -- top menu (3 rows: LAN / ONLINE MATCH / TOURNAMENT) + game.stack:push(LinkState.new(game)) + U.wait(5) + U.shot(game, DIR .. "/link_0_top_menu.png") + U.tap(game, "down"); U.wait(2) + U.shot(game, DIR .. "/link_1_top_menu_online.png") + + -- ONLINE MATCH -> HOST ONLINE (needs pokeserver reachable at the + -- default relay address; POKEPORT_RELAY_ADDR overrides) + U.tap(game, "a"); U.wait(3) -- into onlineMenu + U.shot(game, DIR .. "/link_2_online_menu.png") + U.tap(game, "a"); U.wait(20) -- HOST ONLINE -> connect + U.shot(game, DIR .. "/link_3_online_hosting.png") + + -- back out, try JOIN ONLINE's code-entry screen + closeAnyOpenScreens() + game.stack:push(LinkState.new(game)) + U.wait(5) + U.tap(game, "down"); U.wait(2) -- ONLINE MATCH row + U.tap(game, "a"); U.wait(3) + U.tap(game, "down"); U.wait(2) -- JOIN ONLINE row + U.tap(game, "a"); U.wait(3) + U.shot(game, DIR .. "/link_4_code_entry.png") + U.tap(game, "up"); U.wait(1) + U.tap(game, "right"); U.wait(1) + U.shot(game, DIR .. "/link_5_code_entry_scrubbed.png") + closeAnyOpenScreens() + + U.log("ONLINE_PLAY_DRIVER: done") +end diff --git a/tests/drivers/tournament_guest_test.lua b/tests/drivers/tournament_guest_test.lua new file mode 100644 index 00000000..781afb4d --- /dev/null +++ b/tests/drivers/tournament_guest_test.lua @@ -0,0 +1,72 @@ +-- Driver: joins the tournament tournament_host_test.lua creates, reading +-- the code from a shared file since the two LOVE processes otherwise +-- can't coordinate. Meant for a fresh throwaway POKEPORT_IDENTITY, so it +-- injects a test party directly (mirroring run_link_tests.lua's +-- makeFakeGame) rather than playing through a whole new-game intro. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney" + local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + + -- the default tournament rule is exactly 3 Pokemon; overwrite whatever + -- this identity's party actually is (safe: a match uses clamped copies) + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "BLASTOISE", 50), + Pokemon.new(game.data, "GENGAR", 35), + Pokemon.new(game.data, "ALAKAZAM", 45), + } + game.save.player.name = "BLUE" + + local CodeEntry = require("src.link.CodeEntry") + local Tournament = require("src.link.Tournament") + local t = Tournament.new(game) + game.stack:push(t) + U.wait(3) + + local code = nil + local waited = 0 + while not code and waited < 1800 do + local f = io.open(CODE_FILE, "r") + if f then + local c = f:read("*l") + f:close() + if c and #c == 6 then code = c end + end + U.wait(1) + waited = waited + 1 + end + U.log("guest read code:", code) + if not code then + U.log("TOURNAMENT_GUEST_DRIVER: never saw a code, aborting") + return + end + + U.tap(game, "down"); U.wait(2) -- JOIN row + U.tap(game, "a"); U.wait(3) -- into codeEntry + U.shot(game, DIR .. "/guest_0_code_entry.png") + for i = 1, CodeEntry.LENGTH do + local idx = CodeEntry.CHARSET:find(code:sub(i, i), 1, true) + if idx then t.codeEntry.chars[i] = idx end + end + U.tap(game, "a"); U.wait(3) -- confirm -> startJoining + U.shot(game, DIR .. "/guest_1_joining.png") + + waited = 0 + while #t.roster == 0 and t.stage ~= "done" and waited < 1800 do + U.wait(1) + waited = waited + 1 + end + U.log("guest roster:", table.concat(t.roster or {}, ",")) + U.shot(game, DIR .. "/guest_2_roster.png") + + local doneWait = 0 + while t.stage ~= "done" and doneWait < 5400 do + U.tap(game, "a") + U.wait(2) + doneWait = doneWait + 1 + end + U.shot(game, DIR .. "/guest_3_done.png") + U.log("TOURNAMENT_GUEST_DRIVER: champion=", t.champion, "stage=", t.stage) +end diff --git a/tests/drivers/tournament_host_test.lua b/tests/drivers/tournament_host_test.lua new file mode 100644 index 00000000..45bf0347 --- /dev/null +++ b/tests/drivers/tournament_host_test.lua @@ -0,0 +1,57 @@ +-- Driver: hosts a real 2-player tournament against a real running +-- pokeserver, paired with tournament_guest_test.lua running in a second +-- LOVE process. Coordinates over a shared code file (TOURNEY_CODE_FILE) +-- since the two processes have no other way to talk before the code +-- exists. The default tournament rule is exactly 3 Pokemon, so this +-- overwrites whatever party the identity actually has with 3 fixed mons +-- -- a tournament match never touches the real save (clamped copies, same +-- as any other link battle), so clobbering it here for the test is safe. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots_tourney" + local CODE_FILE = os.getenv("TOURNEY_CODE_FILE") or "/tmp/tourney_code.txt" + U.teleport(game, "PALLET_TOWN", 10, 8, "down") + + local Pokemon = require("src.pokemon.Pokemon") + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 50), + Pokemon.new(game.data, "PIKACHU", 30), + Pokemon.new(game.data, "SNORLAX", 40), + } + game.save.player.name = "RED" + + local Tournament = require("src.link.Tournament") + local t = Tournament.new(game) + game.stack:push(t) + U.wait(3) + + U.shot(game, DIR .. "/host_0_menu.png") + U.tap(game, "a"); U.wait(3) -- HOST -> hostSettings + U.shot(game, DIR .. "/host_1_settings.png") + U.tap(game, "start"); U.wait(30) -- create with defaults (3 mons, any/any, 6s) + U.shot(game, DIR .. "/host_2_hosting.png") + U.log("host code:", t.code, "error:", t.net and t.net.error) + + local f = io.open(CODE_FILE, "w") + if f then f:write(t.code or ""); f:close() end + + local waited = 0 + while #t.roster < 2 and waited < 1800 do + U.wait(1) + waited = waited + 1 + end + U.log("host roster:", table.concat(t.roster, ",")) + U.shot(game, DIR .. "/host_3_roster.png") + + U.tap(game, "a"); U.wait(1) -- start_tournament + U.shot(game, DIR .. "/host_4_bracket.png") + + local doneWait = 0 + while t.stage ~= "done" and doneWait < 5400 do + U.tap(game, "a") + U.wait(2) + doneWait = doneWait + 1 + end + U.shot(game, DIR .. "/host_5_done.png") + U.log("TOURNAMENT_HOST_DRIVER: champion=", t.champion, "stage=", t.stage) +end diff --git a/tests/run_link_tests.lua b/tests/run_link_tests.lua index e6c8cd9a..aa586b18 100644 --- a/tests/run_link_tests.lua +++ b/tests/run_link_tests.lua @@ -127,6 +127,130 @@ else print("skip real enet pairing (lua-enet not available under this interpreter)") end +-- ---------------------------------------------------------------- relay (TCP) transport +-- Pure framing logic needs no socket at all: Net:drainLines()/handleTCPLine +-- operate directly on rxBuf, so this much runs even under plain luajit. +do + local n = Net.new() + local encoded = Json.encode({ type = "hosted", code = "ABCDEF" }) + n.rxBuf = encoded .. "\n" + n:drainLines() + eq(n.code, "ABCDEF", "relay framing: a complete hosted line sets net.code") + check(n.rxBuf == "", "relay framing: a complete line is fully consumed") + + local n2 = Net.new() + n2.rxBuf = encoded:sub(1, 5) -- the line straddles two reads + n2:drainLines() + check(n2.code == nil, "relay framing: a partial line doesn't parse yet") + n2.rxBuf = n2.rxBuf .. encoded:sub(6) .. "\n" + n2:drainLines() + eq(n2.code, "ABCDEF", "relay framing: completing the line resolves it") + + local n3 = Net.new() + n3.rxBuf = Json.encode({ type = "join_error", reason = "not_found" }) .. "\n" + n3:drainLines() + check(n3.error ~= nil and n3.closed, "relay framing: join_error sets error and closes") + + local n4 = Net.new() + n4.rxBuf = Json.encode({ type = "paired" }) .. "\n" + n4:drainLines() + check(n4.paired, "relay framing: paired flips net.paired") + + local n5 = Net.new() + n5.paired = true + n5.rxBuf = Json.encode({ type = "peer_gone" }) .. "\n" + n5:drainLines() + check(n5.closed, "relay framing: peer_gone closes the connection") + + local n6 = Net.new() + n6.rxBuf = Json.encode({ type = "hello", name = "RED" }) .. "\n" + n6:drainLines() + eq(#n6.inbox, 1, "relay framing: an unrecognized control type lands in the inbox") + eq(n6.inbox[1] and n6.inbox[1].name, "RED", "relay framing: ...with its payload intact") +end + +-- real pokeserver over TCP localhost (only when luasocket is present, i.e. +-- inside LOVE or a luajit with luasocket installed, AND node is on PATH to +-- spawn the real relay; otherwise this section is skipped, same spirit as +-- the enet gate above) +local hasSocket = pcall(require, "socket") +local nodeCheck = os.execute("command -v node >/dev/null 2>&1") +local hasNode = nodeCheck == true or nodeCheck == 0 +if not hasSocket then + print("skip real relay pairing (luasocket not available under this interpreter)") +elseif not hasNode then + print("skip real relay pairing (node not on PATH to spawn pokeserver)") +else + local PORT = 17778 + local pidFile = os.tmpname() + os.execute(("(cd ../pokeserver && PORT=%d HTTP_PORT=%d node server.js >/tmp/pokeserver_test.log 2>&1 & echo $! > %q)") + :format(PORT, PORT + 1, pidFile)) + + local function busyWait(seconds) + local t0 = os.clock() + while os.clock() - t0 < seconds do end + end + + local function tcpConnectable(host, port) + local socket = require("socket") + local tcp = socket.tcp() + tcp:settimeout(0.2) + local ok = tcp:connect(host, port) + tcp:close() + return ok ~= nil + end + + local ready = false + for _ = 1, 50 do + ready = tcpConnectable("127.0.0.1", PORT) + if ready then break end + busyWait(0.1) + end + + if not ready then + print("skip real relay pairing (couldn't reach the spawned pokeserver)") + else + local host = Net.new() + check(host:hostOnline("127.0.0.1:" .. PORT), "relay: hostOnline connects: " .. tostring(host.error)) + local deadline = os.clock() + 3 + while not host.code and os.clock() < deadline do host:update() end + check(host.code ~= nil, "relay: a real server assigns a room code") + + local guest = Net.new() + check(guest:joinOnline("127.0.0.1:" .. PORT, host.code or ""), + "relay: joinOnline connects: " .. tostring(guest.error)) + deadline = os.clock() + 3 + while (not host.paired or not guest.paired) and os.clock() < deadline do + host:update() + guest:update() + end + check(host.paired and guest.paired, "relay: both sides pair over a real TCP server") + + host:send({ type = "hello", name = "RED" }) + local relayed = nil + deadline = os.clock() + 3 + while not relayed and os.clock() < deadline do + host:update() + guest:update() + for _, m in ipairs(guest:poll()) do + if m.type == "hello" then relayed = m end + end + end + eq(relayed and relayed.name, "RED", "relay: a message round-trips through the real server") + + host:close() + guest:close() + end + + local pidHandle = io.open(pidFile, "r") + if pidHandle then + local pid = pidHandle:read("*l") + pidHandle:close() + if pid and pid ~= "" then os.execute("kill " .. pid .. " >/dev/null 2>&1") end + end + os.remove(pidFile) +end + -- ---------------------------------------------------------------- trade session local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) } local partyB = { Pokemon.new(Data, "MACHOKE", 32) } @@ -240,6 +364,133 @@ eq(gameA.save.money, 3000, "no prize money in link battles") eq(gameA.save.party[1].hp, gameA.save.party[1].stats.hp, "the real party is untouched (battle used clamped copies)") +-- ---------------------------------------------------------------- tournament shot clock +-- opts.turnLimit only applies to tournament matches; the guest mashes +-- through its own menu every frame while the host never presses anything, +-- so the host's clock is the only one that can expire. +local gameC = makeFakeGame("PIKACHU") +local gameD = makeFakeGame("SNORLAX") +gameD.save.player.name = "YELLOW" +local netC, netD = Net.loopbackPair() +local packedC = Protocol.packParty(gameC.save.party) +local packedD = Protocol.packParty(gameD.save.party) +local battleC = LinkBattle.newHost(gameC, netC, { + myParty = packedC, theirParty = packedD, theirName = "YELLOW", seed = 42, + turnLimit = 0.05, +}) +local battleD = LinkBattle.newGuest(gameD, netD, { + myParty = packedD, theirParty = packedC, theirName = "RED", seed = 42, + turnLimit = 0.05, +}) +local resC, resD = nil, nil +battleC.onFinish = function(r) resC = r end +battleD.onFinish = function(r) resD = r end +gameC.stack:push(battleC) +gameD.stack:push(battleD) + +-- both sides need "a" to get through the intro's messages/animations +-- (send-out poofs, cries...) before the host's own menu even shows; only +-- once the host's decision point is actually up does withholding input +-- from it mean anything +local guardIntro = 0 +while battleC.phase ~= "menu" and guardIntro < 6000 do + guardIntro = guardIntro + 1 + Input.pressed = { a = true } + gameC.stack:update(1 / 60) + gameD.stack:update(1 / 60) +end +check(battleC.phase == "menu", "shot clock: host reaches its own decision point") + +local guard2 = 0 +while resD == nil and guard2 < 6000 do + guard2 = guard2 + 1 + Input.pressed = { a = true } + gameD.stack:update(1 / 60) -- guest mashes through its menu + Input.pressed = {} + gameC.stack:update(1 / 60) -- host presses nothing; its clock ticks down +end +check(resD == "win", "shot clock: the timed-out player's opponent wins immediately") + +-- the timed-out host still has to dismiss its own "time's up" message to +-- reach finish() -- exactly like a slow-but-present player would; this +-- isn't the clock's business, just the ordinary message queue +local guard3 = 0 +while resC == nil and guard3 < 6000 do + guard3 = guard3 + 1 + Input.pressed = { a = true } + gameC.stack:update(1 / 60) +end +check(resC == "lose", "shot clock: the timed-out host is recorded as the loser") + +-- ---------------------------------------------------------------- tournament spectator replay +-- A spectator (LinkBattle.newSpectator) reconstructs the same lockstep +-- battle from a copy of the wire traffic tagged by side -- exactly what +-- pokeserver's tournament fan-out gives it. Feed it directly here rather +-- than standing up a real relay: wrap the host/guest loopback sends so +-- every message they exchange also lands, tagged, in a fake spectator net. +local gameE = makeFakeGame("CHARIZARD") +local gameF = makeFakeGame("BLASTOISE") +gameF.save.player.name = "BLUE" +local gameSpec = makeFakeGame("RATTATA") +local netE, netF = Net.loopbackPair() +local packedE = Protocol.packParty(gameE.save.party) +local packedF = Protocol.packParty(gameF.save.party) +local specSeed = 13579 + +local specInbox = {} +local origSendE, origSendF = netE.send, netF.send +netE.send = function(self, msg) + origSendE(self, msg) + table.insert(specInbox, { type = "spectate", side = "host", msg = msg }) +end +netF.send = function(self, msg) + origSendF(self, msg) + table.insert(specInbox, { type = "spectate", side = "guest", msg = msg }) +end +local specNet = { + closed = false, + update = function() end, + poll = function() + local msgs = specInbox + specInbox = {} + return msgs + end, +} + +local battleE = LinkBattle.newHost(gameE, netE, { + myParty = packedE, theirParty = packedF, theirName = "BLUE", seed = specSeed, +}) +local battleF = LinkBattle.newGuest(gameF, netF, { + myParty = packedF, theirParty = packedE, theirName = "RED", seed = specSeed, +}) +local battleSpec = LinkBattle.newSpectator(gameSpec, specNet, { + hostParty = packedE, guestParty = packedF, hostName = "RED", guestName = "BLUE", + seed = specSeed, +}) +check(battleSpec ~= nil, "spectator battle constructs") +eq(battleSpec.spectating, true, "spectator battle is marked as such (not a reportable match)") + +local resE, resF = nil, nil +battleE.onFinish = function(r) resE = r end +battleF.onFinish = function(r) resF = r end +gameE.stack:push(battleE) +gameF.stack:push(battleF) +gameSpec.stack:push(battleSpec) + +local guard3 = 0 +while (resE == nil or resF == nil) and guard3 < 60000 do + guard3 = guard3 + 1 + Input.pressed = { a = true } + gameE.stack:update(1 / 60) + gameF.stack:update(1 / 60) + gameSpec.stack:update(1 / 60) +end +check(resE ~= nil and resF ~= nil, "spectator test: the underlying match completes") +eq(battleSpec.player.mon.hp, battleE.player.mon.hp, + "spectator's host-side HP matches the host's own view") +eq(battleSpec.enemy.mon.hp, battleF.player.mon.hp, + "spectator's guest-side HP matches the guest's own view") + -- ---------------------------------------------------------------- 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.