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, }