Route link play through multiplayer sessions

This commit is contained in:
Andrew Barnes
2026-08-06 16:03:40 -04:00
parent 2a7c8ec81b
commit 52c96437fa
2 changed files with 113 additions and 82 deletions
+88 -82
View File
@@ -10,6 +10,7 @@ local Net = require("src.link.Net")
local Protocol = require("src.link.Protocol") local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens") local Screens = require("src.ui.Screens")
local Session = require("src.link.Session")
local TextBox = require("src.render.TextBox") local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings") 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)) return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end end
-- stages before any Net object is meaningfully "this session's link" -- -- stages before a successful transport has become this link's Session;
-- .net can still be a leftover failed attempt sitting on self, so error/ -- terminal checks skip them rather than keying off self.net's presence
-- closed checks below skip these rather than keying off self.net's
-- presence alone
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true } local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
-- how long the host waits for a v2 hello before deciding the peer predates -- how long the host waits for a v2 hello before deciding the peer predates
@@ -70,6 +69,16 @@ local function ipDigits(ip)
return digits return digits
end 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) function LinkState.new(game)
local self = setmetatable({}, LinkState) local self = setmetatable({}, LinkState)
self.game = game self.game = game
@@ -89,12 +98,15 @@ end
-- "connecting with this code", same as if the player had typed it in -- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code) function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game) local self = LinkState.new(game)
self.net = Net.new() local session, detail = openSession("guest", function(transport)
if self.net:joinOnline(nil, code) then return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self.stage = "menu" -- exitWith below needs a real stage to unwind from 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 end
return self return self
end end
@@ -163,21 +175,13 @@ end
-- take the peer's hello out of the inbox without eating anything that -- take the peer's hello out of the inbox without eating anything that
-- shares the batch with it -- shares the batch with it
function LinkState:pollHello() function LinkState:pollHello()
local msgs = self.net:poll() local message
local keep, got = {}, false if not self.peerHello then message = self.net:take("hello") end
for _, msg in ipairs(msgs) do if message then
if msg.type == "hello" and not self.peerHello then self.peerHello = message
self.peerHello = msg self.peerName = message.name
self.peerName = msg.name
got = true
else
keep[#keep + 1] = msg
end
end end
for i = #keep, 1, -1 do return message ~= nil, self.net:hasPending()
table.insert(self.net.inbox, 1, keep[i])
end
return got, #keep > 0
end end
function LinkState:sendHello(mode) function LinkState:sendHello(mode)
@@ -220,13 +224,15 @@ function LinkState:update(dt)
local input = self.game.input local input = self.game.input
if self.net then if self.net then
self.net:update() self.net:update()
if self.net.error and not PRE_CONNECT_STAGES[self.stage] then local status = self.net:getStatus()
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60))) 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 return
end 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) -- 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 not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice" and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then and self.stage ~= "battleRunning" then
@@ -269,12 +275,15 @@ function LinkState:update(dt)
self.stage = "menu" self.stage = "menu"
self.index = 1 self.index = 1
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self.net = Net.new()
if self.index == 1 then 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" self.stage = "hosting"
else else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", detail))
end end
else else
self.stage = "addrEntry" self.stage = "addrEntry"
@@ -289,11 +298,14 @@ function LinkState:update(dt)
self.index = 2 self.index = 2
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
if self.index == 1 then if self.index == 1 then
self.net = Net.new() local session, detail = openSession("host", function(transport)
if self.net:hostOnline() then return transport:hostOnline()
end)
if session then
self.net = session
self.stage = "onlineHosting" self.stage = "onlineHosting"
else else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", detail))
end end
else else
self.stage = "codeEntry" self.stage = "codeEntry"
@@ -327,11 +339,14 @@ function LinkState:update(dt)
CodeEntry.right(self.codeEntry) CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry) local code = CodeEntry.text(self.codeEntry)
self.net = Net.new() local session, detail = openSession("guest", function(transport)
if self.net:joinOnline(nil, code) then return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining" self.stage = "onlineJoining"
else else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", detail))
end end
end end
@@ -367,10 +382,15 @@ function LinkState:update(dt)
+ self.addr[base + 2] * 10 + self.addr[base + 2] * 10
+ self.addr[base + 3]) + self.addr[base + 3])
end 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" self.stage = "joining"
else else
self:exitWith(Strings("Link error:\n%s", self.net.error or "?")) self:exitWith(Strings("Link error:\n%s", detail))
end end
end end
@@ -428,19 +448,11 @@ function LinkState:update(dt)
elseif self.stage == "waitMode" then -- guest waits for host's pick elseif self.stage == "waitMode" then -- guest waits for host's pick
if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll() local message = self.net:take("hello")
for i, msg in ipairs(msgs) do if message then
if msg.type == "hello" then self.peerHello = message
self.peerHello = msg self.peerName = message.name
self.peerName = msg.name self:decideCompat(message.mode, false)
-- 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
end end
elseif self.stage == "notice" then elseif self.stage == "notice" then
@@ -456,40 +468,34 @@ function LinkState:update(dt)
elseif self.stage == "battleWait" then elseif self.stage == "battleWait" then
if input:wasPressed("b") then self:exitWith(nil) return end if input:wasPressed("b") then self:exitWith(nil) return end
local msgs = self.net:poll() local message = self.net:take("party")
for i, msg in ipairs(msgs) do if message then
if msg.type == "party" then -- the host owns this rule (same as mode); the guest only learns
-- the host owns this rule (same as mode); the guest only learns -- it here, off the host's own party message
-- it here, off the host's own party message if not self.isHost then self.forceLevel = message.forceLevel end
if not self.isHost then self.forceLevel = msg.forceLevel end local LinkBattle = require("src.link.LinkBattle")
local LinkBattle = require("src.link.LinkBattle") local opts = {
local opts = { myParty = Protocol.packParty(self.game.save.party),
myParty = Protocol.packParty(self.game.save.party), theirParty = message.mons,
theirParty = msg.mons, theirName = self.peerName or "FOE",
theirName = self.peerName or "FOE", seed = self.isHost and self.linkSeed or message.seed,
seed = self.isHost and self.linkSeed or msg.seed, verdict = self.verdict,
verdict = self.verdict, strict = Handshake.strict(self.verdict),
strict = Handshake.strict(self.verdict), forceLevel = self.forceLevel,
forceLevel = self.forceLevel, }
} local battle, why
local battle, why if self.isHost then
if self.isHost then battle, why = LinkBattle.newHost(self.game, self.net, opts)
battle, why = LinkBattle.newHost(self.game, self.net, opts) else
else battle, why = LinkBattle.newGuest(self.game, self.net, opts)
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
end 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 end
elseif self.stage == "battleRunning" then elseif self.stage == "battleRunning" then
+25
View File
@@ -42,6 +42,13 @@ local function fakeTransport(options)
return transport return transport
end end
local function readFile(path)
local handle = assert(io.open(path, "rb"))
local body = handle:read("*a")
handle:close()
return body
end
do do
local host, guest = sessionPair() local host, guest = sessionPair()
T.eq(host:getRole(), "host", "host role is assigned locally") 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") T.eq(transport:poll()[1].name, "RED", "valid application packet stays intact")
end 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") T.finish("link_session")