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:
2026-06-22 15:53:17 +12:00
parent 7efe407421
commit 9ceb6601ae
18 changed files with 1318 additions and 25 deletions
+5 -1
View File
@@ -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
+115
View File
@@ -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,
}
+12
View File
@@ -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()