Adds LAN server discovery and a Local tab in the multiplayer UI so clients can find relays on the same LAN. Introduces a new F4T::LanDiscovery module (plugin/include/F4TLanDiscovery.h, plugin/src/F4TLanDiscovery.cpp) that probes LAN hosts via UDP discover (port 7778) and TCP welcome fallback (port 7777). Server-side support includes a UDP responder (server/lan_discovery.py) and integration in server_core.py to start/stop discovery. Plugin changes (F4TServerBrowserBridge/Data) add background scanning, dispatch results to the game thread, new local-server storage and events (scanLocalServers, localServersUpdated, localScanStarted/localScanFinished), and small join/recent handling updates. UI changes (ui/.../app.js and components) add localServers state, scanning UX, Recent persistence (localStorage), and join logic for local/direct entries. Docs updated (docs/protocol.md, docs/dev-log.md, server/README.md) and build script links iphlpapi. Also removes two unused exported sprite PNGs.
116 lines
3.6 KiB
Python
116 lines
3.6 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
import threading
|
|
from typing import Any
|
|
|
|
|
|
DISCOVERY_PORT = 7778
|
|
PROTOCOL_NAME = "commonwealth-online"
|
|
SERVER_NAME = "Commonwealth Online Server"
|
|
DEFAULT_MAX_PLAYERS = 16
|
|
|
|
|
|
class LanDiscoveryResponder:
|
|
"""UDP responder so LAN clients can find running Commonwealth Online servers."""
|
|
|
|
def __init__(self, server: Any, discovery_port: int = DISCOVERY_PORT) -> None:
|
|
self._server = server
|
|
self._discovery_port = discovery_port
|
|
self._socket: socket.socket | None = None
|
|
self._thread: threading.Thread | None = None
|
|
self._running = False
|
|
|
|
def start(self) -> None:
|
|
if self._running:
|
|
return
|
|
|
|
discovery_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
|
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
else:
|
|
discovery_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
discovery_socket.bind(("0.0.0.0", self._discovery_port))
|
|
discovery_socket.settimeout(0.5)
|
|
except OSError:
|
|
discovery_socket.close()
|
|
raise
|
|
|
|
self._socket = discovery_socket
|
|
self._running = True
|
|
self._thread = threading.Thread(target=self._listen_loop, daemon=True)
|
|
self._thread.start()
|
|
|
|
def stop(self) -> None:
|
|
self._running = False
|
|
discovery_socket = self._socket
|
|
self._socket = None
|
|
|
|
if discovery_socket is not None:
|
|
try:
|
|
discovery_socket.close()
|
|
except OSError:
|
|
pass
|
|
|
|
thread = self._thread
|
|
if thread is not None and thread is not threading.current_thread():
|
|
thread.join(timeout=1.0)
|
|
|
|
def _listen_loop(self) -> None:
|
|
while self._running:
|
|
discovery_socket = self._socket
|
|
if discovery_socket is None:
|
|
break
|
|
|
|
try:
|
|
data, address = discovery_socket.recvfrom(2048)
|
|
except socket.timeout:
|
|
continue
|
|
except OSError:
|
|
if self._running:
|
|
break
|
|
continue
|
|
|
|
response = self._build_response(data)
|
|
if response is None:
|
|
continue
|
|
|
|
self._server._log(
|
|
f"LAN discovery probe from {address[0]}:{address[1]} — replying with game port {response['port']}"
|
|
)
|
|
|
|
try:
|
|
encoded = json.dumps(response, separators=(",", ":")).encode("utf-8")
|
|
discovery_socket.sendto(encoded, address)
|
|
except OSError:
|
|
pass
|
|
|
|
def _build_response(self, data: bytes) -> dict[str, Any] | None:
|
|
try:
|
|
packet = json.loads(data.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return None
|
|
|
|
if not isinstance(packet, dict):
|
|
return None
|
|
if packet.get("type") != "discover":
|
|
return None
|
|
if packet.get("protocol") != PROTOCOL_NAME:
|
|
return None
|
|
|
|
stats = self._server.get_stats()
|
|
connected_clients = int(stats.get("connectedClients", 0))
|
|
game_port = int(stats.get("port", 7777))
|
|
|
|
return {
|
|
"type": "discoverResponse",
|
|
"protocol": PROTOCOL_NAME,
|
|
"version": 1,
|
|
"name": SERVER_NAME,
|
|
"port": game_port,
|
|
"players": connected_clients,
|
|
"maxPlayers": DEFAULT_MAX_PLAYERS,
|
|
}
|