Add LAN discovery and Local servers UI
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.
This commit is contained in:
+5
-1
@@ -9,6 +9,10 @@ packets, assigns incrementing `playerId` values, sends `welcome` packets, adds
|
||||
to every other connected client, and broadcasts disconnect packets when clients
|
||||
disconnect.
|
||||
|
||||
The server also listens on UDP port `7778` for LAN discovery probes. Clients
|
||||
can send a `discover` packet (see `docs/protocol.md`) and receive a
|
||||
`discoverResponse` with the game port and current player count.
|
||||
|
||||
Transform packets may include optional movement-state fields such as
|
||||
`isMoving`, `isSprinting`, `isSneaking`, `isJumping`, `isCrouching`, `weaponDrawn`, and
|
||||
`movementSpeed`. The server preserves these fields automatically because it
|
||||
@@ -49,7 +53,7 @@ interface on this PC.
|
||||
- **Outside your network:** forward TCP port `7777` on your router to the host
|
||||
PC, then give remote players your public IP (or hostname) and port.
|
||||
|
||||
You may need to allow inbound TCP `7777` through Windows Firewall on the host PC.
|
||||
You may need to allow inbound TCP `7777` and UDP `7778` through Windows Firewall on the host PC.
|
||||
|
||||
In the developer GUI, the Fake Clients tab can create simple synthetic TCP
|
||||
clients for local relay testing. Click `Start Server`, then click
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from client_session import ClientSession
|
||||
from lan_discovery import DISCOVERY_PORT, LanDiscoveryResponder
|
||||
|
||||
|
||||
HOST = "0.0.0.0"
|
||||
@@ -36,6 +37,7 @@ class FalloutTogetherServer:
|
||||
self._log_listeners: list[Callable[[str], None]] = []
|
||||
self._server_socket: socket.socket | None = None
|
||||
self._accept_thread: threading.Thread | None = None
|
||||
self._discovery: LanDiscoveryResponder | None = None
|
||||
self._running = False
|
||||
self._started_at: float | None = None
|
||||
self._next_player_id = 1
|
||||
@@ -57,6 +59,8 @@ class FalloutTogetherServer:
|
||||
return
|
||||
|
||||
self._prepare_server_socket()
|
||||
self._discovery = LanDiscoveryResponder(self)
|
||||
self._discovery.start()
|
||||
self._accept_thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||
self._accept_thread.start()
|
||||
|
||||
@@ -66,6 +70,8 @@ class FalloutTogetherServer:
|
||||
raise RuntimeError("Server is already running.")
|
||||
|
||||
self._prepare_server_socket()
|
||||
self._discovery = LanDiscoveryResponder(self)
|
||||
self._discovery.start()
|
||||
|
||||
self._accept_loop()
|
||||
|
||||
@@ -79,6 +85,11 @@ class FalloutTogetherServer:
|
||||
self._server_socket = None
|
||||
clients = list(self._clients.values())
|
||||
|
||||
discovery = self._discovery
|
||||
self._discovery = None
|
||||
if discovery is not None:
|
||||
discovery.stop()
|
||||
|
||||
if server_socket is not None:
|
||||
try:
|
||||
server_socket.close()
|
||||
@@ -149,6 +160,7 @@ class FalloutTogetherServer:
|
||||
self._started_at = time.time()
|
||||
|
||||
self._log(f"Commonwealth Online server listening on {self.host}:{self.port}")
|
||||
self._log(f"LAN discovery listening on UDP port {DISCOVERY_PORT}")
|
||||
if self.host == "0.0.0.0":
|
||||
self._log(f"Local clients can connect at 127.0.0.1:{self.port}")
|
||||
lan_addresses = get_lan_addresses()
|
||||
|
||||
Reference in New Issue
Block a user