mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-27 14:02:01 -04:00
Merge pull request #925 from Bortlesboat/multiplayer-session-foundation
Add a reusable multiplayer session layer
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# RFC 0003 — Add a reusable multiplayer session layer
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Engine: `Session.lua`, `Net.lua`, `LinkState.lua`,
|
||||
`Tournament.lua`. Tests: `link_session.lua`.
|
||||
|
||||
## Motivation
|
||||
|
||||
Link play and tournaments currently own transport lifecycle details and
|
||||
temporarily remove and reinsert packets in `Net.inbox` when a handshake or
|
||||
battle starts. That makes packet ownership fragile and gives a future
|
||||
shared-world mode no stable host/guest-aware boundary to reuse.
|
||||
|
||||
The engine needs one small layer that preserves today's wire protocol while
|
||||
owning received-packet order and terminal cleanup. Pokémon, battle, tournament,
|
||||
save, and overworld rules remain outside that layer.
|
||||
|
||||
## The decision it extends
|
||||
|
||||
Extends the existing split between `Net` (backend setup, framing, and relay
|
||||
controls), `Handshake`/`Protocol` (mode payloads), and the states that
|
||||
interpret those payloads. It does not replace any of those components.
|
||||
|
||||
## The exact API delta
|
||||
|
||||
Backward-compatible and internal-only.
|
||||
|
||||
### `Session.new(transport, options)`
|
||||
|
||||
Wraps one successfully configured Net-compatible transport. `options.role`
|
||||
is exactly `"host"` or `"guest"`; `options.kind` is a non-empty
|
||||
local label such as `"link"` or `"tournament"`. Role and kind are
|
||||
immutable session metadata selected locally and are never inferred from peer
|
||||
packets.
|
||||
|
||||
The facade forwards the narrow fields current consumers need:
|
||||
`paired`, `code`, `address`, `target`,
|
||||
`error`, and `closed`.
|
||||
It forwards valid outbound tables unchanged through `send(message)`.
|
||||
|
||||
### Receive and lifecycle methods
|
||||
|
||||
- `update()` pumps the transport, validates decoded inbound values, and
|
||||
appends accepted messages to a private FIFO.
|
||||
- `pollOne()` removes the oldest queued message.
|
||||
- `poll()` removes every queued message in order.
|
||||
- `take(type)` removes the first queued message with that type without
|
||||
disturbing any other message.
|
||||
- `hasPending()` reports whether the FIFO is non-empty.
|
||||
- `getRole()`, `getKind()`, `getStatus()`, and
|
||||
`getFailure()` expose local metadata and lifecycle.
|
||||
- `close()` closes the underlying transport once and is safe to repeat.
|
||||
|
||||
Statuses are `connecting`, `paired`, `draining`, `closed`,
|
||||
and `failed`. A transport close or failure becomes `draining` while
|
||||
accepted packets remain queued. The terminal `closed`/`error`
|
||||
compatibility projection appears only after that FIFO drains, so a last packet
|
||||
travelling with a disconnect remains observable.
|
||||
|
||||
An inbound value is structurally valid only when it is a table with a string
|
||||
`type`. Invalid decoded values end the session with a protocol failure.
|
||||
Unknown but structurally valid types remain queued for the owning mode; the
|
||||
session does not contain a packet allowlist.
|
||||
|
||||
## Authority direction
|
||||
|
||||
A later `WorldSession` may compose this facade. In that mode the host
|
||||
will own the world snapshot, map state, NPC state, event results, and shared
|
||||
progression. A guest will bring a trainer identity plus their Pokémon party,
|
||||
inventory, and other explicitly selected profile snapshot.
|
||||
|
||||
Guest profile data and commands will be untrusted input. The host must validate
|
||||
them and must authorize every world mutation before rebroadcasting the result.
|
||||
The concrete snapshot schema, command vocabulary, conflict rules, and
|
||||
persistence policy require a separate RFC and are not introduced here.
|
||||
|
||||
## Compatibility and security
|
||||
|
||||
No packet envelope, message name, payload shape, framing rule, relay protocol,
|
||||
save schema, or engine protocol version changes. Existing valid outbound
|
||||
messages encode exactly as before, and existing link and tournament screens
|
||||
keep their current player-facing behavior.
|
||||
|
||||
The layer does not authenticate players or encrypt traffic. Existing LAN and
|
||||
relay access assumptions remain unchanged; knowledge of a join address or code
|
||||
still grants the same access it grants today. Authentication, reconnect
|
||||
identity, rate limits, and abuse controls remain future protocol decisions.
|
||||
|
||||
## Migration note for players, mods, and peers
|
||||
|
||||
**Nothing.** `LinkState` and `Tournament` adopt the facade
|
||||
internally. Existing peers receive the same messages, mods gain no new API, and
|
||||
players do not migrate saves or settings.
|
||||
|
||||
## Parity tests
|
||||
|
||||
- **ROM-free facade:** constructor validation, immutable role/kind, unchanged
|
||||
send shape, FIFO ordering, typed retrieval, unknown typed packets, draining,
|
||||
terminal failure latching, protected transport calls, and decoded-value
|
||||
rejection.
|
||||
- **Existing modes:** source guards prohibit direct inbox mutation; headless
|
||||
module loads and the complete engine tier cover both migrated states.
|
||||
- **ROM-backed link play:** run the existing link driver when generated ROM
|
||||
data is available; the normal quick suite remains the required baseline.
|
||||
|
||||
## Deprecation etiquette and non-goals
|
||||
|
||||
Nothing deprecated. This RFC adds an internal facade and removes no transport
|
||||
method.
|
||||
|
||||
It does not add shared-world packets, co-op screens, a remote actor, save
|
||||
transfer, server persistence, matchmaking, reconnect, or a protocol-version
|
||||
bump. Those changes require the world-specific layer and its own review.
|
||||
+88
-82
@@ -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
|
||||
|
||||
+5
-6
@@ -207,7 +207,7 @@ function Net:send(msg)
|
||||
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
|
||||
if decoded ~= nil and not self.peerEnd.closed then
|
||||
table.insert(self.peerEnd.inbox, decoded)
|
||||
end
|
||||
return
|
||||
@@ -256,13 +256,12 @@ end
|
||||
|
||||
function Net:handleTCPLine(line)
|
||||
local msg = Json.decode(line)
|
||||
if not msg then
|
||||
if msg == nil 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
|
||||
if type(msg) == "table" and handleGenericRelayControl(self, msg) then return end
|
||||
table.insert(self.inbox, msg)
|
||||
end
|
||||
|
||||
-- pulls every complete "\n"-terminated line out of rxBuf (leaving a
|
||||
@@ -352,7 +351,7 @@ function Net:update()
|
||||
end
|
||||
elseif event.type == "receive" then
|
||||
local msg = Json.decode(event.data)
|
||||
if msg then
|
||||
if msg ~= nil then
|
||||
table.insert(self.inbox, msg)
|
||||
else
|
||||
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
local Session = {}
|
||||
Session.__index = Session
|
||||
|
||||
local VALID_ROLES = { host = true, guest = true }
|
||||
local REQUIRED_METHODS = { "update", "poll", "send", "close" }
|
||||
|
||||
function Session.new(transport, options)
|
||||
assert(type(transport) == "table", "Session.new requires a transport")
|
||||
assert(type(options) == "table", "Session.new requires options")
|
||||
assert(VALID_ROLES[options.role], "Session role must be host or guest")
|
||||
assert(type(options.kind) == "string" and options.kind ~= "",
|
||||
"Session kind must be a non-empty string")
|
||||
for _, method in ipairs(REQUIRED_METHODS) do
|
||||
assert(type(transport[method]) == "function",
|
||||
"Session transport requires " .. method)
|
||||
end
|
||||
|
||||
local self = setmetatable({
|
||||
_transport = transport,
|
||||
_role = options.role,
|
||||
_kind = options.kind,
|
||||
_inbox = {},
|
||||
_status = "connecting",
|
||||
_terminal = nil,
|
||||
_transportCloseCalled = false,
|
||||
paired = false,
|
||||
closed = false,
|
||||
error = nil,
|
||||
code = nil,
|
||||
address = nil,
|
||||
target = nil,
|
||||
}, Session)
|
||||
self:_syncMetadata()
|
||||
self:_refreshStatus()
|
||||
return self
|
||||
end
|
||||
|
||||
function Session:_syncMetadata()
|
||||
local transport = self._transport
|
||||
self.paired = transport.paired == true
|
||||
self.code = transport.code
|
||||
self.address = transport.address
|
||||
self.target = transport.target
|
||||
end
|
||||
|
||||
function Session:_refreshStatus()
|
||||
if not self._terminal then
|
||||
self._status = self.paired and "paired" or "connecting"
|
||||
self.closed = false
|
||||
self.error = nil
|
||||
return
|
||||
end
|
||||
if #self._inbox > 0 then
|
||||
self._status = "draining"
|
||||
self.closed = false
|
||||
self.error = nil
|
||||
return
|
||||
end
|
||||
self._status = self._terminal.status
|
||||
self.closed = true
|
||||
self.error = self._terminal.status == "failed"
|
||||
and (self._terminal.detail or self._terminal.reason) or nil
|
||||
end
|
||||
|
||||
function Session:_latchTerminal(status, reason, detail)
|
||||
if self._terminal then return false end
|
||||
self._terminal = { status = status, reason = reason, detail = detail }
|
||||
self:_refreshStatus()
|
||||
return true
|
||||
end
|
||||
|
||||
function Session:_closeTransport()
|
||||
if self._transportCloseCalled then return true end
|
||||
self._transportCloseCalled = true
|
||||
local ok, detail = pcall(self._transport.close, self._transport)
|
||||
return ok, ok and nil or tostring(detail)
|
||||
end
|
||||
|
||||
function Session:getRole() return self._role end
|
||||
function Session:getKind() return self._kind end
|
||||
function Session:getStatus() return self._status end
|
||||
function Session:getFailure()
|
||||
if not self._terminal or self._terminal.status ~= "failed" then
|
||||
return nil, nil
|
||||
end
|
||||
return self._terminal.reason, self._terminal.detail
|
||||
end
|
||||
function Session:hasPending() return #self._inbox > 0 end
|
||||
|
||||
function Session:send(message)
|
||||
if self._terminal then return nil end
|
||||
return self._transport:send(message)
|
||||
end
|
||||
|
||||
function Session:update()
|
||||
if self._terminal then
|
||||
self:_refreshStatus()
|
||||
return
|
||||
end
|
||||
|
||||
local failureReason, failureDetail
|
||||
local updateOk, updateDetail = pcall(self._transport.update, self._transport)
|
||||
self:_syncMetadata()
|
||||
if not updateOk then
|
||||
failureReason, failureDetail = "transport_error", tostring(updateDetail)
|
||||
elseif self._transport.error then
|
||||
failureReason = "transport_error"
|
||||
failureDetail = tostring(self._transport.error)
|
||||
end
|
||||
|
||||
local pollOk, messages = pcall(self._transport.poll, self._transport)
|
||||
if not pollOk then
|
||||
if not failureReason then
|
||||
failureReason, failureDetail = "transport_error", tostring(messages)
|
||||
end
|
||||
elseif type(messages) ~= "table" then
|
||||
if not failureReason then
|
||||
failureReason, failureDetail = "transport_error",
|
||||
"transport poll returned non-table"
|
||||
end
|
||||
else
|
||||
for index = 1, #messages do
|
||||
local message = messages[index]
|
||||
if type(message) ~= "table" or type(message.type) ~= "string" then
|
||||
if not failureReason then
|
||||
failureReason = "protocol_error"
|
||||
failureDetail = ("message %d must be a table with string type")
|
||||
:format(index)
|
||||
end
|
||||
break
|
||||
end
|
||||
self._inbox[#self._inbox + 1] = message
|
||||
end
|
||||
end
|
||||
|
||||
self:_syncMetadata()
|
||||
if failureReason then
|
||||
self:_latchTerminal("failed", failureReason, failureDetail)
|
||||
self:_closeTransport()
|
||||
elseif self._transport.closed then
|
||||
local closeOk, closeDetail = self:_closeTransport()
|
||||
if closeOk then
|
||||
self:_latchTerminal("closed")
|
||||
else
|
||||
self:_latchTerminal("failed", "transport_error", closeDetail)
|
||||
end
|
||||
end
|
||||
self:_refreshStatus()
|
||||
end
|
||||
|
||||
local function finishRead(self)
|
||||
self:_refreshStatus()
|
||||
end
|
||||
|
||||
function Session:take(messageType)
|
||||
assert(type(messageType) == "string", "Session.take requires a message type")
|
||||
for index, message in ipairs(self._inbox) do
|
||||
if message.type == messageType then
|
||||
local found = table.remove(self._inbox, index)
|
||||
finishRead(self)
|
||||
return found
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Session:pollOne()
|
||||
if #self._inbox == 0 then return nil end
|
||||
local message = table.remove(self._inbox, 1)
|
||||
finishRead(self)
|
||||
return message
|
||||
end
|
||||
|
||||
function Session:poll()
|
||||
local messages = self._inbox
|
||||
self._inbox = {}
|
||||
finishRead(self)
|
||||
return messages
|
||||
end
|
||||
|
||||
function Session:close()
|
||||
if self._status == "closed" or self._status == "failed" then return end
|
||||
if self._terminal then
|
||||
self:_closeTransport()
|
||||
self:_refreshStatus()
|
||||
return
|
||||
end
|
||||
local ok, detail = self:_closeTransport()
|
||||
if ok then
|
||||
self:_latchTerminal("closed")
|
||||
else
|
||||
self:_latchTerminal("failed", "transport_error", detail)
|
||||
end
|
||||
self:_syncMetadata()
|
||||
self:_refreshStatus()
|
||||
end
|
||||
|
||||
return Session
|
||||
+50
-51
@@ -14,6 +14,7 @@ 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 Session = require("src.link.Session")
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -132,12 +133,23 @@ end
|
||||
-- host / join
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function openSession(role)
|
||||
local transport = Net.new()
|
||||
if transport:connectTCP(Net.defaultRelayAddress()) then
|
||||
return Session.new(transport, { role = role, kind = "tournament" })
|
||||
end
|
||||
local detail = transport.error or "?"
|
||||
transport:close()
|
||||
return nil, detail
|
||||
end
|
||||
|
||||
function Tournament:startHosting()
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
local session, detail = openSession("host")
|
||||
if not session then
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
return
|
||||
end
|
||||
self.net = session
|
||||
local size, minL, maxL = partyStats(self.game.save.party)
|
||||
self.isCreator = true
|
||||
self.participating = self.settings.participating
|
||||
@@ -156,11 +168,12 @@ function Tournament:startHosting()
|
||||
end
|
||||
|
||||
function Tournament:startJoining(code)
|
||||
self.net = Net.new()
|
||||
if not self.net:connectTCP(Net.defaultRelayAddress()) then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error or "?"))
|
||||
local session, detail = openSession("guest")
|
||||
if not session then
|
||||
self:exitWith(Strings("Link error:\n%s", detail))
|
||||
return
|
||||
end
|
||||
self.net = session
|
||||
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
|
||||
@@ -256,22 +269,14 @@ function Tournament:sendHello(mode)
|
||||
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
|
||||
local message
|
||||
if not self.peerHello then message = self.net:take("hello") end
|
||||
if message then self.peerHello = message end
|
||||
return message ~= nil
|
||||
end
|
||||
|
||||
-- The relay assigns a side for each match; this can differ from the immutable
|
||||
-- tournament creator/joiner role held by Session.
|
||||
function Tournament:enterMatch(msg)
|
||||
self.isHost = (msg.role == "host")
|
||||
self.opponentName = msg.opponent
|
||||
@@ -356,12 +361,13 @@ function Tournament:update(dt)
|
||||
|
||||
if self.net then
|
||||
self.net:update()
|
||||
if self.net.error and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
local status = self.net:getStatus()
|
||||
if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings"
|
||||
and self.stage ~= "codeEntry" then
|
||||
self:exitWith(Strings("Link error:\n%s", self.net.error:sub(1, 60)))
|
||||
self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60)))
|
||||
return
|
||||
end
|
||||
if self.net.closed and self.stage ~= "done" then
|
||||
if status == "closed" and self.stage ~= "done" then
|
||||
self:exitWith(Strings("The tournament\nconnection was\nlost."))
|
||||
return
|
||||
end
|
||||
@@ -370,24 +376,23 @@ function Tournament:update(dt)
|
||||
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
|
||||
if self.peerHello then
|
||||
self:beginMatchBattle()
|
||||
return
|
||||
end
|
||||
for _, message in ipairs(self.net:poll()) do self:handleMessage(message) 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
|
||||
while self.net:hasPending() do
|
||||
local message = self.net:pollOne()
|
||||
if message.type == "party" then
|
||||
self.pendingBattleOpts.theirParty = message.mons
|
||||
if self.isHost then
|
||||
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
|
||||
else
|
||||
self.pendingBattleOpts.seed = msg.seed
|
||||
self.pendingBattleOpts.seed = message.seed
|
||||
end
|
||||
-- Split rather than `cond and newHost() or newGuest()`: the and/or
|
||||
-- idiom truncates a call to its first result, so the second return
|
||||
-- (the specific reason) was always dropped and every failure showed
|
||||
-- the generic fallback instead of "same mods on both games" etc.
|
||||
local battle, why
|
||||
if self.isHost then
|
||||
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
|
||||
@@ -398,27 +403,22 @@ function Tournament:update(dt)
|
||||
self:exitWith(why or Strings("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)
|
||||
self:handleMessage(message)
|
||||
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
|
||||
while self.net:hasPending() do
|
||||
local message = self.net:pollOne()
|
||||
if message.type == "spectate" and message.msg.type == "party" then
|
||||
local inner = message.msg
|
||||
if message.side == "host" then
|
||||
self.spectate.hostParty = inner.mons
|
||||
self.spectate.seed = inner.seed
|
||||
else
|
||||
@@ -426,8 +426,10 @@ function Tournament:update(dt)
|
||||
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,
|
||||
hostParty = self.spectate.hostParty,
|
||||
guestParty = self.spectate.guestParty,
|
||||
hostName = self.spectate.hostName,
|
||||
guestName = self.spectate.guestName,
|
||||
seed = self.spectate.seed,
|
||||
forceLevel = levelForWire(self.settings.forceLevel),
|
||||
})
|
||||
@@ -435,16 +437,13 @@ function Tournament:update(dt)
|
||||
self:exitWith(why or Strings("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)
|
||||
self:handleMessage(message)
|
||||
end
|
||||
end
|
||||
return
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.link.Net")
|
||||
local Json = require("src.link.Json")
|
||||
local Session = require("src.link.Session")
|
||||
|
||||
local function sessionPair()
|
||||
local hostNet, guestNet = Net.loopbackPair()
|
||||
return Session.new(hostNet, { role = "host", kind = "link" }),
|
||||
Session.new(guestNet, { role = "guest", kind = "link" })
|
||||
end
|
||||
|
||||
local function fakeTransport(options)
|
||||
options = options or {}
|
||||
local transport = {
|
||||
paired = options.paired ~= false,
|
||||
closed = false,
|
||||
error = nil,
|
||||
inbox = options.inbox or {},
|
||||
closeCount = 0,
|
||||
}
|
||||
function transport:update()
|
||||
if options.onUpdate then options.onUpdate(self) end
|
||||
if options.updateError then error(options.updateError) end
|
||||
end
|
||||
function transport:poll()
|
||||
if options.pollError then error(options.pollError) end
|
||||
local messages = self.inbox
|
||||
self.inbox = {}
|
||||
return messages
|
||||
end
|
||||
function transport:send(message)
|
||||
self.sent = message
|
||||
return true
|
||||
end
|
||||
function transport:close()
|
||||
self.closeCount = self.closeCount + 1
|
||||
self.closed = true
|
||||
if options.closeError then error(options.closeError) end
|
||||
end
|
||||
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")
|
||||
T.eq(guest:getRole(), "guest", "guest role is assigned locally")
|
||||
T.eq(host:getKind(), "link", "session kind is retained")
|
||||
T.eq(host:getStatus(), "paired", "wrapped loopback starts paired")
|
||||
|
||||
guest:send({
|
||||
type = "hello", name = "BLUE", role = "host", kind = "tournament",
|
||||
})
|
||||
host:update()
|
||||
local hello = host:take("hello")
|
||||
T.eq(hello.name, "BLUE", "send forwards the original payload")
|
||||
T.eq(hello.session, nil, "send adds no session envelope")
|
||||
T.eq(host:getRole(), "host", "peer payload cannot replace local role")
|
||||
T.eq(host:getKind(), "link", "peer payload cannot replace local kind")
|
||||
end
|
||||
|
||||
do
|
||||
local host, guest = sessionPair()
|
||||
guest:send({ type = "before", sequence = 1 })
|
||||
guest:send({ type = "hello", sequence = 2 })
|
||||
guest:send({ type = "after", sequence = 3 })
|
||||
guest:send({ type = "hello", sequence = 4 })
|
||||
host:update()
|
||||
|
||||
local hello = host:take("hello")
|
||||
T.eq(hello.sequence, 2, "take removes the first matching packet")
|
||||
T.eq(host:pollOne().sequence, 1, "pollOne removes only the FIFO head")
|
||||
|
||||
local rest = host:poll()
|
||||
T.eq(#rest, 2, "poll returns every remaining packet once")
|
||||
T.eq(rest[1].sequence, 3, "take preserves the earlier remainder order")
|
||||
T.eq(rest[2].sequence, 4, "take preserves repeated-type order")
|
||||
T.eq(#host:poll(), 0, "poll clears the private FIFO")
|
||||
end
|
||||
|
||||
do
|
||||
local sent
|
||||
local transport = {
|
||||
paired = false,
|
||||
code = nil,
|
||||
address = "192.0.2.5:7777",
|
||||
target = "ROOM01",
|
||||
update = function(self)
|
||||
self.paired = true
|
||||
self.code = "ROOM02"
|
||||
end,
|
||||
poll = function() return {} end,
|
||||
send = function(_, message)
|
||||
sent = message
|
||||
return "queued", 7
|
||||
end,
|
||||
close = function(self) self.closed = true end,
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "tournament" })
|
||||
T.eq(session:getStatus(), "connecting", "unpaired transport starts connecting")
|
||||
T.eq(session.address, "192.0.2.5:7777", "address metadata is mirrored")
|
||||
T.eq(session.target, "ROOM01", "target metadata is mirrored")
|
||||
|
||||
local outbound = { type = "ping" }
|
||||
local result, count = session:send(outbound)
|
||||
T.eq(result, "queued", "send preserves the transport's first return")
|
||||
T.eq(count, 7, "send preserves the transport's second return")
|
||||
T.eq(sent, outbound, "send forwards the original table unchanged")
|
||||
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "paired", "update observes transport pairing")
|
||||
T.eq(session.code, "ROOM02", "update refreshes relay metadata")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox[#self.inbox + 1] = { type = "bye", final = true }
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getStatus(), "draining", "normal close drains its final packet")
|
||||
T.eq(session.closed, false, "compatibility closed waits for the FIFO")
|
||||
T.eq(session:take("bye").final, true, "final close packet remains observable")
|
||||
T.eq(session:getStatus(), "closed", "normal drain reaches closed")
|
||||
T.eq(transport.closeCount, 1, "transport cleanup runs once")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
onUpdate = function(self) self.closed = true end,
|
||||
closeError = "normal cleanup exploded",
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
T.check(pcall(session.update, session),
|
||||
"normal-close cleanup exception does not escape the game loop")
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error",
|
||||
"normal-close cleanup exception becomes a transport failure")
|
||||
T.check(detail:find("normal cleanup exploded", 1, true) ~= nil,
|
||||
"normal-close cleanup failure keeps its diagnostic detail")
|
||||
T.eq(session:getStatus(), "failed",
|
||||
"normal-close cleanup exception cannot report a clean close")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ onUpdate = function(self)
|
||||
self.inbox = { { type = "before", sequence = 1 } }
|
||||
self.error = "socket failed"
|
||||
self.closed = true
|
||||
end })
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error", "transport failure has a stable reason")
|
||||
T.eq(detail, "socket failed", "transport failure retains original detail")
|
||||
T.eq(session:getStatus(), "draining", "transport failure drains valid prefix")
|
||||
T.eq(session.error, nil, "legacy error stays hidden during drain")
|
||||
T.eq(session.closed, false, "legacy closed stays false during failed drain")
|
||||
T.eq(session:pollOne().sequence, 1, "failed drain returns its valid prefix")
|
||||
T.eq(session:getStatus(), "failed", "failed drain reaches failed")
|
||||
T.eq(session.error, "socket failed", "legacy error appears at terminal failure")
|
||||
transport.error = "later error"
|
||||
session:update()
|
||||
local _, latchedDetail = session:getFailure()
|
||||
T.eq(latchedDetail, "socket failed", "first terminal failure stays latched")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = {
|
||||
{ type = "before", sequence = 1 },
|
||||
false,
|
||||
{ type = "after", sequence = 3 },
|
||||
} })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
local reason = session:getFailure()
|
||||
T.eq(reason, "protocol_error", "malformed packet fails as protocol_error")
|
||||
T.eq(session:getStatus(), "draining", "malformed batch drains valid prefix")
|
||||
local messages = session:poll()
|
||||
T.eq(#messages, 1, "malformed value and untrusted tail are not exposed")
|
||||
T.eq(messages[1].sequence, 1, "valid prefix survives malformed packet")
|
||||
T.eq(session:getStatus(), "failed", "protocol drain reaches failed")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ inbox = { { type = 7 } } })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"table without string type is a protocol error")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
inbox = { { type = "future_world_packet", value = 9 } },
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:pollOne().value, 9, "unknown typed packet stays mode-owned")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({
|
||||
inbox = { { type = "already_decoded", value = 4 } },
|
||||
updateError = "update exploded",
|
||||
})
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
local ok = pcall(session.update, session)
|
||||
T.check(ok, "transport update exception does not escape the game loop")
|
||||
T.eq(session:getStatus(), "draining", "update exception still drains prior inbox")
|
||||
T.eq(session:pollOne().value, 4, "decoded packet survives update exception")
|
||||
T.eq(session:getStatus(), "failed", "update exception becomes terminal failure")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ pollError = "poll exploded" })
|
||||
local session = Session.new(transport, { role = "host", kind = "link" })
|
||||
T.check(pcall(session.update, session),
|
||||
"transport poll exception does not escape the game loop")
|
||||
local reason, detail = session:getFailure()
|
||||
T.eq(reason, "transport_error", "poll exception is a transport failure")
|
||||
T.check(detail:find("poll exploded", 1, true) ~= nil,
|
||||
"poll exception keeps its diagnostic detail")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport({ closeError = "close exploded" })
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
T.check(pcall(session.close, session),
|
||||
"transport close exception does not escape cleanup")
|
||||
T.eq(session:getFailure(), "transport_error",
|
||||
"close exception is a transport failure")
|
||||
session:close()
|
||||
T.eq(transport.closeCount, 1, "failed close is still attempted only once")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = fakeTransport()
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:close()
|
||||
session:close()
|
||||
session:update()
|
||||
T.eq(transport.closeCount, 1, "close and post-terminal update are idempotent")
|
||||
T.eq(session:getStatus(), "closed", "explicit close reaches closed")
|
||||
end
|
||||
|
||||
do
|
||||
T.check(not pcall(Session.new, nil, { role = "host", kind = "link" }),
|
||||
"constructor rejects missing transport")
|
||||
local transport = fakeTransport()
|
||||
T.check(not pcall(Session.new, transport, { role = "leader", kind = "link" }),
|
||||
"constructor rejects unsupported role")
|
||||
T.check(not pcall(Session.new, transport, { role = "host", kind = "" }),
|
||||
"constructor rejects empty kind")
|
||||
end
|
||||
|
||||
do
|
||||
local senderNet, receiverNet = Net.loopbackPair()
|
||||
local receiver = Session.new(receiverNet, { role = "guest", kind = "link" })
|
||||
senderNet:send(false)
|
||||
receiver:update()
|
||||
T.eq(receiver:getFailure(), "protocol_error",
|
||||
"loopback forwards decoded false to session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local delivered = false
|
||||
local transport = Net.new()
|
||||
transport.enetHost = {
|
||||
service = function()
|
||||
if delivered then return nil end
|
||||
delivered = true
|
||||
return { type = "receive", data = "false" }
|
||||
end,
|
||||
}
|
||||
local session = Session.new(transport, { role = "guest", kind = "link" })
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"ENet forwards decoded false to session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = Net.new()
|
||||
local session = Session.new(transport, { role = "host", kind = "tournament" })
|
||||
T.check(pcall(transport.handleTCPLine, transport, "42"),
|
||||
"TCP control handoff does not index a decoded scalar")
|
||||
session:update()
|
||||
T.eq(session:getFailure(), "protocol_error",
|
||||
"decoded TCP scalar reaches session validation")
|
||||
end
|
||||
|
||||
do
|
||||
local transport = Net.new()
|
||||
transport:handleTCPLine(Json.encode({ type = "hosted", code = "ABCDEF" }))
|
||||
T.eq(transport.code, "ABCDEF", "valid relay controls stay transport-owned")
|
||||
transport:handleTCPLine(Json.encode({ type = "hello", name = "RED" }))
|
||||
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
|
||||
|
||||
do
|
||||
local source = readFile("src/link/Tournament.lua")
|
||||
T.check(source:find('require("src.link.Session")', 1, true) ~= nil,
|
||||
"Tournament depends on the session boundary")
|
||||
T.check(source:find('kind = "tournament"', 1, true) ~= nil,
|
||||
"Tournament assigns its connection role and kind locally")
|
||||
T.check(source:find("self.net.inbox", 1, true) == nil,
|
||||
"Tournament never mutates a transport inbox")
|
||||
T.check(source:find("self.net = Net.new()", 1, true) == nil,
|
||||
"Tournament stores only a successful session wrapper")
|
||||
T.check(source:find(':take("hello")', 1, true) ~= nil,
|
||||
"Tournament retrieves match hello without draining its tail")
|
||||
T.check(source:find(":pollOne()", 1, true) ~= nil,
|
||||
"Tournament processes handoff prefixes one packet at a time")
|
||||
T.check(source:find("getStatus()", 1, true) ~= nil,
|
||||
"Tournament uses the session lifecycle instead of raw terminal flags")
|
||||
end
|
||||
|
||||
T.finish("link_session")
|
||||
Reference in New Issue
Block a user