diff --git a/src/link/LinkState.lua b/src/link/LinkState.lua index 4e97133e..9f3da68d 100644 --- a/src/link/LinkState.lua +++ b/src/link/LinkState.lua @@ -10,6 +10,7 @@ local Net = require("src.link.Net") local Protocol = require("src.link.Protocol") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") +local Session = require("src.link.Session") local TextBox = require("src.render.TextBox") local Strings = require("src.core.Strings") @@ -44,10 +45,8 @@ local function forceLevelLabel(v) return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v)) end --- 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 +-- stages before a successful transport has become this link's Session; +-- terminal checks skip them rather than keying off self.net's presence local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } -- how long the host waits for a v2 hello before deciding the peer predates @@ -70,6 +69,16 @@ local function ipDigits(ip) return digits end +local function openSession(role, connect) + local transport = Net.new() + if connect(transport) then + return Session.new(transport, { role = role, kind = "link" }) + end + local detail = transport.error or "?" + transport:close() + return nil, detail +end + function LinkState.new(game) local self = setmetatable({}, LinkState) self.game = game @@ -89,12 +98,15 @@ end -- "connecting with this code", same as if the player had typed it in function LinkState.newJoinOnline(game, code) local self = LinkState.new(game) - self.net = Net.new() - if self.net:joinOnline(nil, code) then + local session, detail = openSession("guest", function(transport) + return transport:joinOnline(nil, code) + end) + if session then + self.net = session self.stage = "onlineJoining" else self.stage = "menu" -- exitWith below needs a real stage to unwind from - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end return self end @@ -163,21 +175,13 @@ end -- take the peer's hello out of the inbox without eating anything that -- shares the batch with it function LinkState: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 - self.peerName = msg.name - got = true - else - keep[#keep + 1] = msg - end + local message + if not self.peerHello then message = self.net:take("hello") end + if message then + self.peerHello = message + self.peerName = message.name end - for i = #keep, 1, -1 do - table.insert(self.net.inbox, 1, keep[i]) - end - return got, #keep > 0 + return message ~= nil, self.net:hasPending() end function LinkState:sendHello(mode) @@ -220,13 +224,15 @@ function LinkState:update(dt) local input = self.game.input if self.net then self.net:update() - if self.net.error and not PRE_CONNECT_STAGES[self.stage] then - self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) + local status = self.net:getStatus() + if status == "failed" and not PRE_CONNECT_STAGES[self.stage] then + self:exitWith(Strings("Link error:\n%s", + (self.net.error or "?"):sub(1, 60))) return end - -- the peer vanished without a bye (only once the inbox is drained, + -- the peer vanished without a bye (only once the session FIFO drains, -- so a final message travelling with the disconnect still counts) - if self.net.closed and #self.net.inbox == 0 + if status == "closed" and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry" and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "battleRunning" then @@ -269,12 +275,15 @@ function LinkState:update(dt) self.stage = "menu" self.index = 1 elseif input:wasPressed("a") then - self.net = Net.new() if self.index == 1 then - if self.net:host() then + local session, detail = openSession("host", function(transport) + return transport:host() + end) + if session then + self.net = session self.stage = "hosting" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end else self.stage = "addrEntry" @@ -289,11 +298,14 @@ function LinkState:update(dt) self.index = 2 elseif input:wasPressed("a") then if self.index == 1 then - self.net = Net.new() - if self.net:hostOnline() then + local session, detail = openSession("host", function(transport) + return transport:hostOnline() + end) + if session then + self.net = session self.stage = "onlineHosting" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end else self.stage = "codeEntry" @@ -327,11 +339,14 @@ function LinkState:update(dt) 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 + local session, detail = openSession("guest", function(transport) + return transport:joinOnline(nil, code) + end) + if session then + self.net = session self.stage = "onlineJoining" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end end @@ -367,10 +382,15 @@ function LinkState:update(dt) + self.addr[base + 2] * 10 + self.addr[base + 3]) end - if self.net:join(table.concat(octets, ".")) then + local address = table.concat(octets, ".") + local session, detail = openSession("guest", function(transport) + return transport:join(address) + end) + if session then + self.net = session self.stage = "joining" else - self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) + self:exitWith(Strings("Link error:\n%s", detail)) end end @@ -428,19 +448,11 @@ function LinkState:update(dt) elseif self.stage == "waitMode" then -- guest waits for host's pick 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 == "hello" then - self.peerHello = msg - self.peerName = msg.name - -- the host's next messages (party, ...) can share this batch; - -- put them back so the new stage's poll sees them - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end - self:decideCompat(msg.mode, false) - break - end + local message = self.net:take("hello") + if message then + self.peerHello = message + self.peerName = message.name + self:decideCompat(message.mode, false) end elseif self.stage == "notice" then @@ -456,40 +468,34 @@ function LinkState:update(dt) elseif self.stage == "battleWait" 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 - -- the host owns this rule (same as mode); the guest only learns - -- it here, off the host's own party message - if not self.isHost then self.forceLevel = msg.forceLevel end - local LinkBattle = require("src.link.LinkBattle") - local opts = { - myParty = Protocol.packParty(self.game.save.party), - theirParty = msg.mons, - theirName = self.peerName or "FOE", - seed = self.isHost and self.linkSeed or msg.seed, - verdict = self.verdict, - strict = Handshake.strict(self.verdict), - forceLevel = self.forceLevel, - } - local battle, why - if self.isHost then - battle, why = LinkBattle.newHost(self.game, self.net, opts) - else - battle, why = LinkBattle.newGuest(self.game, self.net, opts) - end - if not battle then - self.net:send({ type = "bye" }) - self:exitWith(why or Strings("Link battle\ncan't start."), "error") - return - end - self.game.stack:push(battle) - self.stage = "battleRunning" - for j = #msgs, i + 1, -1 do - table.insert(self.net.inbox, 1, msgs[j]) - end - break + local message = self.net:take("party") + if message then + -- the host owns this rule (same as mode); the guest only learns + -- it here, off the host's own party message + if not self.isHost then self.forceLevel = message.forceLevel end + local LinkBattle = require("src.link.LinkBattle") + local opts = { + myParty = Protocol.packParty(self.game.save.party), + theirParty = message.mons, + theirName = self.peerName or "FOE", + seed = self.isHost and self.linkSeed or message.seed, + verdict = self.verdict, + strict = Handshake.strict(self.verdict), + forceLevel = self.forceLevel, + } + local battle, why + if self.isHost then + battle, why = LinkBattle.newHost(self.game, self.net, opts) + else + battle, why = LinkBattle.newGuest(self.game, self.net, opts) end + if not battle then + self.net:send({ type = "bye" }) + self:exitWith(why or Strings("Link battle\ncan't start."), "error") + return + end + self.game.stack:push(battle) + self.stage = "battleRunning" end elseif self.stage == "battleRunning" then diff --git a/tests/engine/link_session.lua b/tests/engine/link_session.lua index 9ec9d36b..0d051c5a 100644 --- a/tests/engine/link_session.lua +++ b/tests/engine/link_session.lua @@ -42,6 +42,13 @@ local function fakeTransport(options) return transport end +local function readFile(path) + local handle = assert(io.open(path, "rb")) + local body = handle:read("*a") + handle:close() + return body +end + do local host, guest = sessionPair() T.eq(host:getRole(), "host", "host role is assigned locally") @@ -299,4 +306,22 @@ do T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact") end +do + local source = readFile("src/link/LinkState.lua") + T.check(source:find('require("src.link.Session")', 1, true) ~= nil, + "LinkState depends on the session boundary") + T.check(source:find('kind = "link"', 1, true) ~= nil, + "LinkState assigns the link session kind locally") + T.check(source:find("self.net.inbox", 1, true) == nil, + "LinkState never mutates a transport inbox") + T.check(source:find("self.net = Net.new()", 1, true) == nil, + "LinkState stores only successful session wrappers") + T.check(source:find(':take("hello")', 1, true) ~= nil, + "LinkState retrieves hello without draining unrelated packets") + T.check(source:find(':take("party")', 1, true) ~= nil, + "LinkState leaves battle handoff packets in session order") + T.check(source:find("getStatus()", 1, true) ~= nil, + "LinkState uses the session lifecycle instead of raw terminal flags") +end + T.finish("link_session")