mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 21:46:51 +02:00
246 lines
7.3 KiB
Lua
246 lines
7.3 KiB
Lua
-- Peer-to-peer link transport over lua-enet (bundled with LÖVE).
|
|
-- One player hosts (binds a UDP port); the other joins by address.
|
|
-- No external server: messages are JSON objects on ENet's
|
|
-- reliable-ordered channel 0.
|
|
--
|
|
-- Usage:
|
|
-- local net = Net.new()
|
|
-- net:host() -- or net:join("192.168.1.20:7777")
|
|
-- every frame: net:update(); msgs = net:poll()
|
|
-- net.address -- host: "ip:port" to tell the friend
|
|
-- net.paired -- true once both ends are connected
|
|
-- net:send({ type = "hello" })
|
|
--
|
|
-- 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.
|
|
|
|
local Json = require("src.link.Json")
|
|
local Logger = require("src.core.Logger")
|
|
|
|
local hasEnet, enet = pcall(require, "enet")
|
|
if not hasEnet then enet = nil end
|
|
|
|
local Net = {}
|
|
Net.__index = Net
|
|
|
|
Net.DEFAULT_PORT = 7777
|
|
|
|
function Net.available()
|
|
return enet ~= nil
|
|
end
|
|
|
|
function Net.defaultPort()
|
|
return tonumber(os.getenv("POKEPORT_LINK_PORT") or "") or Net.DEFAULT_PORT
|
|
end
|
|
|
|
-- monotonic-ish clock for the join timeout
|
|
local function now()
|
|
if love and love.timer and love.timer.getTime then
|
|
return love.timer.getTime()
|
|
end
|
|
local ok, socket = pcall(require, "socket")
|
|
if ok and socket and socket.gettime then return socket.gettime() end
|
|
return os.time()
|
|
end
|
|
|
|
-- best-effort LAN IP to show the host (no packet is sent: connecting a
|
|
-- UDP socket just picks the outbound interface)
|
|
function Net.lanIP()
|
|
local ok, ip = pcall(function()
|
|
local socket = require("socket")
|
|
local udp = socket.udp()
|
|
udp:setpeername("192.0.2.1", 9) -- TEST-NET-1, never routed
|
|
local addr = udp:getsockname()
|
|
udp:close()
|
|
return addr
|
|
end)
|
|
if ok and ip and ip ~= "0.0.0.0" then return ip end
|
|
return nil
|
|
end
|
|
|
|
function Net.new()
|
|
return setmetatable({
|
|
enetHost = nil, -- our enet host object (both ends have one)
|
|
peer = nil, -- the connected remote peer
|
|
inbox = {},
|
|
outbox = {}, -- messages queued before pairing completes
|
|
paired = false,
|
|
address = nil, -- host: "ip:port" the other player types in
|
|
error = nil,
|
|
closed = false,
|
|
mode = nil,
|
|
joinTimeout = 10,
|
|
}, Net)
|
|
end
|
|
|
|
-- two in-memory ends with the Net API, for tests / offline logic
|
|
function Net.loopbackPair()
|
|
local function make()
|
|
local n = Net.new()
|
|
n.paired = true
|
|
n.mode = "loopback"
|
|
return n
|
|
end
|
|
local a, b = make(), make()
|
|
a.peerEnd, b.peerEnd = b, a
|
|
return a, b
|
|
end
|
|
|
|
function Net:host(port)
|
|
if not enet then
|
|
self.error = "link needs lua-enet (run the game with LOVE)"
|
|
return false
|
|
end
|
|
port = tonumber(port) or Net.defaultPort()
|
|
local ok, h, err = pcall(enet.host_create, ("*:%d"):format(port), 2, 1)
|
|
if not ok or not h then
|
|
self.error = ("can't open UDP port %d (%s)"):format(
|
|
port, tostring(ok and err or h))
|
|
return false
|
|
end
|
|
self.enetHost = h
|
|
self.mode = "hosting"
|
|
self.address = ("%s:%d"):format(Net.lanIP() or "?", port)
|
|
return true
|
|
end
|
|
|
|
function Net:join(address)
|
|
if not enet then
|
|
self.error = "link needs lua-enet (run the game with LOVE)"
|
|
return false
|
|
end
|
|
local host, port = address:match("^(.-):(%d+)$")
|
|
host = host or address
|
|
port = tonumber(port) or Net.defaultPort()
|
|
local target = ("%s:%d"):format(host, port)
|
|
local ok, h = pcall(enet.host_create) -- client: no bind address
|
|
if not ok or not h then
|
|
self.error = "can't create network socket"
|
|
return false
|
|
end
|
|
local okc, peer = pcall(h.connect, h, target, 1)
|
|
if not okc or not peer then
|
|
pcall(function() h:destroy() end)
|
|
self.error = ("bad address %s"):format(target)
|
|
return false
|
|
end
|
|
self.enetHost = h
|
|
self.peer = peer
|
|
self.mode = "joining"
|
|
self.target = target
|
|
self.joinDeadline = now() + self.joinTimeout
|
|
return true
|
|
end
|
|
|
|
function Net:send(msg)
|
|
if self.closed then 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
|
|
table.insert(self.peerEnd.inbox, decoded)
|
|
end
|
|
return
|
|
end
|
|
if not self.paired or not self.peer then
|
|
table.insert(self.outbox, msg) -- flushed when the connection opens
|
|
return
|
|
end
|
|
local ok, err = pcall(function()
|
|
return self.peer:send(Json.encode(msg), 0, "reliable")
|
|
end)
|
|
if not ok then
|
|
self.error = "send failed: " .. tostring(err)
|
|
self.closed = true
|
|
end
|
|
end
|
|
|
|
-- pump enet events; decoded JSON messages are queued for poll()
|
|
function Net:update()
|
|
if self.peerEnd then return end -- loopback needs no pumping
|
|
if not self.enetHost or self.closed then return end
|
|
while true do
|
|
local ok, event = pcall(self.enetHost.service, self.enetHost, 0)
|
|
if not ok then
|
|
-- an unreachable join target surfaces as a service error
|
|
-- (ICMP unreachable on the connected UDP socket)
|
|
if self.mode == "joining" and not self.paired then
|
|
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
|
else
|
|
self.error = tostring(event)
|
|
end
|
|
self.closed = true
|
|
return
|
|
end
|
|
if not event then break end
|
|
if event.type == "connect" then
|
|
if self.mode == "hosting" and self.peer and self.peer ~= event.peer then
|
|
pcall(function() event.peer:disconnect_now() end) -- room is taken
|
|
else
|
|
self.peer = event.peer
|
|
self.paired = true
|
|
local queued = self.outbox
|
|
self.outbox = {}
|
|
for _, msg in ipairs(queued) do self:send(msg) end
|
|
end
|
|
elseif event.type == "receive" then
|
|
local msg = Json.decode(event.data)
|
|
if msg then
|
|
table.insert(self.inbox, msg)
|
|
else
|
|
Logger.warn("link: bad message %q", tostring(event.data):sub(1, 60))
|
|
end
|
|
elseif event.type == "disconnect" then
|
|
if event.peer == self.peer then
|
|
self.closed = true
|
|
if not self.paired then
|
|
self.error = self.error or
|
|
("no answer from\n%s"):format(self.target or "the host")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if self.mode == "joining" and not self.paired
|
|
and self.joinDeadline and now() > self.joinDeadline then
|
|
self.error = ("no answer from\n%s"):format(self.target or "the host")
|
|
self.closed = true
|
|
pcall(function() self.peer:disconnect_now() end)
|
|
end
|
|
end
|
|
|
|
function Net:poll()
|
|
local msgs = self.inbox
|
|
self.inbox = {}
|
|
return msgs
|
|
end
|
|
|
|
function Net:close()
|
|
if self.peerEnd then
|
|
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
|
|
-- reliables (e.g. the final confirm/bye) before disconnecting;
|
|
-- disconnect_now would drop them on both ends. Pump briefly
|
|
-- until the handshake completes.
|
|
pcall(function() self.peer:disconnect_later() end)
|
|
local deadline = now() + 0.5
|
|
while now() < deadline do
|
|
local ok, event = pcall(self.enetHost.service, self.enetHost, 10)
|
|
if not ok or (event and event.type == "disconnect") then break end
|
|
end
|
|
elseif self.peer then
|
|
pcall(function() self.peer:disconnect_now() end)
|
|
end
|
|
pcall(function() self.enetHost:flush() end)
|
|
pcall(function() self.enetHost:destroy() end)
|
|
self.enetHost = nil
|
|
self.peer = nil
|
|
end
|
|
self.closed = true
|
|
end
|
|
|
|
return Net
|