from __future__ import annotations import json import socket import threading from typing import Any DISCOVERY_PORT = 7778 PROTOCOL_NAME = "commonwealth-online" DEFAULT_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 self._lock = threading.RLock() def start(self) -> None: with self._lock: 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.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) discovery_socket.bind(("0.0.0.0", self._discovery_port)) discovery_socket.settimeout(0.5) except OSError as error: discovery_socket.close() raise OSError( f"Could not bind LAN discovery to 0.0.0.0:{self._discovery_port}. {error}" ) from error self._socket = discovery_socket self._running = True self._thread = threading.Thread(target=self._listen_loop, daemon=True) self._thread.start() def stop(self) -> None: with self._lock: self._running = False discovery_socket = self._socket self._socket = None thread = self._thread self._thread = None if discovery_socket is not None: try: discovery_socket.close() except OSError: pass if thread is not None and thread is not threading.current_thread(): thread.join(timeout=1.0) def _listen_loop(self) -> None: while True: with self._lock: if not self._running: break discovery_socket = self._socket if discovery_socket is None: break try: data, address = discovery_socket.recvfrom(2048) except socket.timeout: continue except OSError: with self._lock: 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]} — " f"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)) server_name = str(stats.get("serverName") or DEFAULT_SERVER_NAME) server_description = str(stats.get("serverDescription") or "") max_players = int(stats.get("maxPlayers", DEFAULT_MAX_PLAYERS)) response: dict[str, Any] = { "type": "discoverResponse", "protocol": PROTOCOL_NAME, "version": 1, "name": server_name, "description": server_description, "port": game_port, "players": connected_clients, "maxPlayers": max_players, } return response