extends Node # ============================================================================= # Server Browser — Heartbeat Sender # ============================================================================= # Periodically POSTs server state to the configured master server so the # server appears in the public server browser. # # Relies on cvars defined by the config system: # sv_master_server — Master server URL (e.g. "http://master.example.com:28961") # sv_server_id — Unique server identifier (auto-generated if empty) # sv_heartbeat_interval — Seconds between heartbeats (default 60, range [10,300]) # sv_server_tags — Comma-separated tags for filtering # sv_server_password — Whether the server requires a password # # Signals: # heartbeat_sent(success: bool, server_id: String, http_code: int) # master_connection_failed(error_message: String) # ============================================================================= signal heartbeat_sent(success: bool, server_id: String, http_code: int) signal master_connection_failed(error_message: String) const DEFAULT_HEARTBEAT_INTERVAL: float = 60.0 const MIN_INTERVAL: float = 10.0 const MAX_INTERVAL: float = 300.0 const DEFAULT_MASTER_SERVER: String = "http://127.0.0.1:28961" # HTTP request node for async requests var _http: HTTPRequest var _timer: Timer var _server_id: String = "" var _enabled: bool = false func _ready() -> void: # Create HTTP request node _http = HTTPRequest.new() _http.name = "MasterServerHTTP" _http.timeout = 10 _http.use_threads = true add_child(_http) _http.request_completed.connect(_on_heartbeat_completed) # Create timer for periodic heartbeats _timer = Timer.new() _timer.name = "HeartbeatTimer" _timer.one_shot = false add_child(_timer) _timer.timeout.connect(_send_heartbeat) func start() -> void: """Begin the heartbeat loop. Call after cvars are loaded and server is ready.""" var master_url = _get_cvar_string("sv_master_server", "") if master_url.is_empty(): push_warning("[ServerBrowser] sv_master_server is empty — heartbeat disabled") return # Strip trailing slash master_url = master_url.trim_suffix("/") if not master_url.begins_with("http"): master_url = "http://" + master_url if not master_url.begins_with("http://") and not master_url.begins_with("https://"): push_warning("[ServerBrowser] Invalid master server URL: ", master_url) return _server_id = _get_cvar_string("sv_server_id", "") _enabled = true var interval = _get_cvar_float("sv_heartbeat_interval", DEFAULT_HEARTBEAT_INTERVAL) interval = clamp(interval, MIN_INTERVAL, MAX_INTERVAL) _timer.wait_time = interval _timer.start() print("[ServerBrowser] Heartbeat enabled — master: ", master_url, " interval: ", interval, "s") # Send initial heartbeat immediately _send_heartbeat() func stop() -> void: """Stop sending heartbeats.""" _enabled = false _timer.stop() print("[ServerBrowser] Heartbeat disabled") func set_server_id(new_id: String) -> void: """Update the server id (e.g., after first heartbeat response).""" _server_id = new_id func _get_master_url() -> String: var raw = _get_cvar_string("sv_master_server", DEFAULT_MASTER_SERVER).trim_suffix("/") if raw.is_empty(): return "" if not raw.begins_with("http"): raw = "http://" + raw return raw func _send_heartbeat() -> void: if not _enabled: return var master_url = _get_master_url() if master_url.is_empty(): return # Build heartbeat payload var payload = { "server_id": _server_id, "name": _get_cvar_string("sv_server_name", "Tactical Shooter Server"), "map": _get_cvar_string("sv_map", "unknown"), "players": _get_player_count(), "max_players": _get_cvar_int("sv_max_players", 16), "game_mode": _get_cvar_string("sv_game_mode", "deathmatch"), "version": _get_cvar_string("sv_version", "1.0.0"), "tags": _get_cvar_string("sv_server_tags", ""), "password": _get_cvar_int("sv_server_password", 0) != 0, "host": _get_external_ip(), "port": _get_cvar_int("sv_port", 0), "steam_id": _get_cvar_string("sv_steam_id", ""), } var json_string = JSON.stringify(payload) var headers = ["Content-Type: application/json"] var endpoint = master_url + "/api/v1/heartbeat" var err = _http.request(endpoint, headers, HTTPClient.METHOD_POST, json_string) if err != OK: push_error("[ServerBrowser] Failed to send heartbeat: HTTP request error ", err) master_connection_failed.emit("HTTP request failed (code " + str(err) + ")") func _on_heartbeat_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: if result != HTTPRequest.RESULT_SUCCESS: var msg = "Heartbeat failed — network error " + str(result) push_warning("[ServerBrowser] ", msg) master_connection_failed.emit(msg) heartbeat_sent.emit(false, _server_id, response_code) return if response_code != 200: var msg = "Heartbeat failed — HTTP " + str(response_code) push_warning("[ServerBrowser] ", msg) master_connection_failed.emit(msg) heartbeat_sent.emit(false, _server_id, response_code) return # Parse response for server_id (first heartbeat may get one assigned) var body_text = body.get_string_from_utf8() var json = JSON.new() var parse_err = json.parse(body_text) if parse_err == OK and json.data is Dictionary: if json.data.has("server_id") and not json.data["server_id"].is_empty(): _server_id = json.data["server_id"] heartbeat_sent.emit(true, _server_id, response_code) # ------------------------------------------------------------------------- # Helper: read cvars from the config system (if available) # ------------------------------------------------------------------------- func _get_cvar_string(name: String, default_value: String) -> String: """Try the CvarRegistry autoload first, fall back to ProjectSettings.""" if Engine.has_singleton("CvarRegistry"): var reg = Engine.get_singleton("CvarRegistry") if reg.has_method("get_string"): return reg.get_string(name, default_value) # Fallback to ProjectSettings if no cvar registry exists if ProjectSettings.has_setting(name): return ProjectSettings.get_setting(name) return default_value func _get_cvar_int(name: String, default_value: int) -> int: if Engine.has_singleton("CvarRegistry"): var reg = Engine.get_singleton("CvarRegistry") if reg.has_method("get_int"): return reg.get_int(name, default_value) if ProjectSettings.has_setting(name): return ProjectSettings.get_setting(name) return default_value func _get_cvar_float(name: String, default_value: float) -> float: if Engine.has_singleton("CvarRegistry"): var reg = Engine.get_singleton("CvarRegistry") if reg.has_method("get_float"): return reg.get_float(name, default_value) if ProjectSettings.has_setting(name): return ProjectSettings.get_setting(name) return default_value func _get_player_count() -> int: """Get current connected player count.""" # Try to read from game's player manager singleton if Engine.has_singleton("PlayerManager"): var pm = Engine.get_singleton("PlayerManager") if pm.has_method("get_player_count"): return pm.get_player_count() return 0 func _get_external_ip() -> String: """Return our public/external IP if known, or empty for auto-detect.""" return _get_cvar_string("sv_external_ip", "")