class_name OnlineManager extends Node signal connection_state_changed(state: String) signal authenticated(user_id: String, username: String) signal leaderboard_loaded(leaderboard_id: String, records: Array) signal score_submitted(result: Dictionary) signal matchmaking_state_changed(state: String) signal match_found(match_data) signal match_snapshot_received(snapshot: Dictionary) signal match_event_received(event: Dictionary) signal private_room_created(room_code: String) signal friends_changed() signal match_invite_received(invite: Dictionary) const SERVER_KEY := "voidbastion-public-v1" const PRODUCTION_HOST := "api.void-bastion.gergames.de" const DEVELOPMENT_HOST := "192.168.178.20" const DEFAULT_PORT := 7350 const OP_SNAPSHOT := 1 const OP_EVENT := 2 const OP_BUILD := 10 const OP_UPGRADE := 11 const OP_SELL := 12 const OP_START_WAVE := 13 const OP_READY := 14 const MAX_PENDING_SNAPSHOTS := 5 var client: NakamaClient var session: NakamaSession var socket: NakamaSocket var connection_state := "offline" var last_error := "" var current_host := "" var current_match = null var matchmaker_ticket := "" var private_room_code := "" var lobby_ready := false var intentional_disconnect := false var reconnect_in_progress := false var account_manager: AccountManager var auto_connect_enabled := true var snapshot_previous_ms := 0 var snapshot_report_ms := 0 var snapshot_count := 0 var snapshot_gap_max_ms := 0 var snapshot_bytes_max := 0 var snapshot_parse_max_us := 0 var snapshot_large_gap_count := 0 var snapshot_coalesced_count := 0 var snapshot_queue_dropped_count := 0 var snapshot_queue_max := 0 var snapshot_bytes_total := 0 var snapshot_tick_previous := -1 var snapshot_tick_jump_max := 0 var snapshot_tick_jumps := 0 var snapshot_gap_samples: Array[int] = [] var diagnostic_file: FileAccess var diagnostic_path := "" var diagnostic_match_key := "" var diagnostic_match_id := "" var diagnostic_finished_match_key := "" var diagnostic_open_failed := false var diagnostic_slot := -1 var diagnostic_frame_max_ms := 0 var diagnostic_frame_stalls := 0 var diagnostic_apply_max_us := 0 var diagnostic_enemy_count := 0 var diagnostic_tower_count := 0 var diagnostic_last_summary := "" var diagnostic_records: Array[Dictionary] = [] var pending_snapshot_payloads: Array[String] = [] var friends: Array[Dictionary] = [] var friend_statuses: Dictionary = {} var presence_status := "" func _ready() -> void: account_manager = get_parent().get_node_or_null("AccountManager") as AccountManager if _is_headless_or_editor(): return call_deferred("connect_and_authenticate") func _process(_delta: float) -> void: _flush_pending_snapshots() if diagnostic_file != null and Time.get_ticks_msec() - snapshot_report_ms >= 10000: _report_diagnostic_window() func _flush_pending_snapshots() -> void: if pending_snapshot_payloads.is_empty(): return var payloads := pending_snapshot_payloads.duplicate() pending_snapshot_payloads.clear() var latest: Dictionary = {} var recent_shots: Array = [] for payload in payloads: var parse_start_us := Time.get_ticks_usec() var parsed = JSON.parse_string(payload) snapshot_parse_max_us = maxi(snapshot_parse_max_us, Time.get_ticks_usec() - parse_start_us) if not parsed is Dictionary: continue var server_tick := int(parsed.get("tick", -1)) if server_tick >= 0: if snapshot_tick_previous >= 0: var tick_jump := server_tick - snapshot_tick_previous snapshot_tick_jump_max = maxi(snapshot_tick_jump_max, tick_jump) if tick_jump > 1: snapshot_tick_jumps += 1 if tick_jump > 3: _write_diagnostic_record("tick_jump", {"from_tick": snapshot_tick_previous, "to_tick": server_tick, "steps": tick_jump}) snapshot_tick_previous = server_tick latest = parsed var shots = parsed.get("shots", []) if shots is Array: recent_shots.append_array(shots) if latest.is_empty(): return if payloads.size() > 1: latest["shots"] = recent_shots snapshot_coalesced_count += payloads.size() - 1 match_snapshot_received.emit(latest) func _begin_match_diagnostics() -> void: if current_match == null: return var match_key := String(current_match.match_id).sha256_text().substr(0, 12) if match_key == diagnostic_finished_match_key: return if diagnostic_match_key == match_key and (diagnostic_file != null or diagnostic_open_failed): return if diagnostic_file != null: _finish_match_diagnostics("new_match") diagnostic_match_key = match_key diagnostic_match_id = String(current_match.match_id) diagnostic_finished_match_key = "" diagnostic_open_failed = false diagnostic_slot = -1 diagnostic_records.clear() snapshot_previous_ms = 0 snapshot_report_ms = Time.get_ticks_msec() snapshot_tick_previous = -1 _reset_diagnostic_window() var stamp := Time.get_datetime_string_from_system(true).replace(":", "-") diagnostic_path = "user://online-diag-%s-%s-%d.jsonl" % [stamp, match_key, Time.get_ticks_usec()] diagnostic_file = FileAccess.open(diagnostic_path, FileAccess.WRITE) if diagnostic_file == null: diagnostic_open_failed = true push_warning("Online-Diagnose konnte nicht gespeichert werden: %s" % diagnostic_path) return _write_diagnostic_record("start", { "match": match_key, "host": current_host, "version": str(ProjectSettings.get_setting("application/config/version", "")), "transport": "nakama_websocket", "expected_snapshots_per_second": 10 }) print("ONLINE DIAG file=%s" % ProjectSettings.globalize_path(diagnostic_path)) func set_diagnostic_slot(slot: int, viewing_right: bool) -> void: if diagnostic_slot == slot: return diagnostic_slot = slot _write_diagnostic_record("side", {"slot": slot, "viewing_right": viewing_right}) func record_online_frame(delta: float) -> void: if diagnostic_file == null: return var frame_ms := roundi(delta * 1000.0) diagnostic_frame_max_ms = maxi(diagnostic_frame_max_ms, frame_ms) if frame_ms > 200: diagnostic_frame_stalls += 1 if frame_ms > 1000: _write_diagnostic_record("frame_stall", {"duration_ms": frame_ms, "last_server_tick": snapshot_tick_previous}) func record_snapshot_apply(duration_us: int, enemy_count: int, tower_count: int) -> void: diagnostic_apply_max_us = maxi(diagnostic_apply_max_us, duration_us) diagnostic_enemy_count = enemy_count diagnostic_tower_count = tower_count func _write_diagnostic_record(event_name: String, details: Dictionary) -> void: var record := details.duplicate(true) record["event"] = event_name record["utc"] = Time.get_datetime_string_from_system(true) record["slot"] = diagnostic_slot if event_name != "start": diagnostic_records.append(record) if diagnostic_records.size() > 160: diagnostic_records.pop_front() if diagnostic_file == null: return diagnostic_file.store_line(JSON.stringify(record)) diagnostic_file.flush() func _report_diagnostic_window() -> void: var now_ms := Time.get_ticks_msec() var sorted_gaps := snapshot_gap_samples.duplicate() sorted_gaps.sort() var p95_gap := 0 if not sorted_gaps.is_empty(): p95_gap = sorted_gaps[clampi(int(ceil(float(sorted_gaps.size()) * 0.95)) - 1, 0, sorted_gaps.size() - 1)] var seconds := maxf(0.001, float(now_ms - snapshot_report_ms) / 1000.0) var record := { "seconds": snappedf(seconds, 0.001), "snapshots": snapshot_count, "snapshots_per_second": snappedf(float(snapshot_count) / seconds, 0.01), "gap_p95_ms": p95_gap, "gap_max_ms": snapshot_gap_max_ms, "gaps_over_500_ms": snapshot_large_gap_count, "last_server_tick": snapshot_tick_previous, "tick_jump_max": snapshot_tick_jump_max, "tick_jumps_over_1": snapshot_tick_jumps, "bytes_avg": snapshot_bytes_total / maxi(1, snapshot_count), "bytes_max": snapshot_bytes_max, "parse_max_us": snapshot_parse_max_us, "apply_max_us": diagnostic_apply_max_us, "frame_max_ms": diagnostic_frame_max_ms, "frame_stalls_over_200_ms": diagnostic_frame_stalls, "coalesced": snapshot_coalesced_count, "queue_dropped": snapshot_queue_dropped_count, "queue_max": snapshot_queue_max, "enemies": diagnostic_enemy_count, "towers": diagnostic_tower_count } diagnostic_last_summary = JSON.stringify(record) _write_diagnostic_record("window", record) print("ONLINE DIAG %s" % diagnostic_last_summary) snapshot_report_ms = now_ms _reset_diagnostic_window() func _reset_diagnostic_window() -> void: snapshot_count = 0 snapshot_gap_max_ms = 0 snapshot_bytes_max = 0 snapshot_parse_max_us = 0 snapshot_large_gap_count = 0 snapshot_coalesced_count = 0 snapshot_queue_dropped_count = 0 snapshot_queue_max = 0 snapshot_bytes_total = 0 snapshot_tick_jump_max = 0 snapshot_tick_jumps = 0 snapshot_gap_samples.clear() diagnostic_frame_max_ms = 0 diagnostic_frame_stalls = 0 diagnostic_apply_max_us = 0 func _finish_match_diagnostics(reason: String) -> void: if diagnostic_match_key.is_empty(): return if snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000: _report_diagnostic_window() _write_diagnostic_record("end", {"reason": reason}) var match_id := diagnostic_match_id if not match_id.is_empty() and is_authenticated() and not diagnostic_records.is_empty(): var report := {"match_id": match_id, "version": str(ProjectSettings.get_setting("application/config/version", "development")), "records": diagnostic_records.duplicate(true)} while JSON.stringify(report).to_utf8_buffer().size() > 60000 and report["records"].size() > 1: report["records"].pop_front() _upload_match_diagnostics.call_deferred(report) diagnostic_file = null diagnostic_finished_match_key = diagnostic_match_key diagnostic_match_key = "" diagnostic_match_id = "" diagnostic_open_failed = false diagnostic_records.clear() func _upload_match_diagnostics(report: Dictionary) -> void: for attempt in 3: if not is_authenticated(): break var result = await client.rpc_async(session, "submit_online_diagnostics", JSON.stringify(report)) var parsed := _parse_rpc_result(result) if bool(parsed.get("accepted", false)): print("ONLINE DIAG hochgeladen: Runde %s, Seite %d" % [String(parsed.get("round_id", "")), int(parsed.get("slot", -1))]) return await get_tree().create_timer(2.0 * (attempt + 1)).timeout push_warning("Online-Diagnose konnte nicht hochgeladen werden; lokale Datei bleibt erhalten.") func get_diagnostic_bundle() -> String: if diagnostic_file != null and (snapshot_count > 0 or Time.get_ticks_msec() - snapshot_report_ms >= 1000): _report_diagnostic_window() if diagnostic_path.is_empty() or not FileAccess.file_exists(diagnostic_path): return "Noch keine Online-Diagnose vorhanden." return "Void Bastion Online-Diagnose\nDatei: %s\n\n%s" % [ProjectSettings.globalize_path(diagnostic_path), FileAccess.get_file_as_string(diagnostic_path)] func _is_headless_or_editor() -> bool: return DisplayServer.get_name() == "headless" or Engine.is_editor_hint() func connect_and_authenticate(force_production := false) -> bool: if connection_state in ["connecting", "online"]: return connection_state == "online" _set_connection_state("connecting") last_error = "" var production_ok := await _authenticate_on(PRODUCTION_HOST, 443, "https") if production_ok: await _after_authentication() return true if OS.is_debug_build() and not force_production: var development_ok := await _authenticate_on(DEVELOPMENT_HOST, DEFAULT_PORT, "http") if development_ok: await _after_authentication() return true _set_connection_state("offline") return false func _authenticate_on(host: String, port: int, scheme: String) -> bool: current_host = host client = Nakama.create_client(SERVER_KEY, host, port, scheme, 5, NakamaLogger.LOG_LEVEL.ERROR) var device_id := _device_id() var username = "pilot_%s" % device_id.md5_text().left(20) var result: NakamaSession = await client.authenticate_device_async(device_id, username, true) if result == null or result.is_exception(): last_error = "Server %s nicht erreichbar" % host return false session = result if account_manager: await client.update_account_async(session, null, account_manager.display_name) account_manager.attach_online_identity(session.user_id, session.username) return true func _after_authentication() -> void: await _record_activity() socket = Nakama.create_socket_from(client) _bind_socket_signals() var result = await socket.connect_async(session, true, 5) if result == null or result.is_exception(): last_error = "Echtzeitverbindung konnte nicht aufgebaut werden" socket = null _set_connection_state("authenticated") else: intentional_disconnect = false _set_connection_state("online") var parent_game = get_parent() await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu") authenticated.emit(session.user_id, session.username) await flush_pending_scores() func _record_activity() -> void: if not is_authenticated(): return var payload := { "platform": OS.get_name().to_lower(), "version": str(ProjectSettings.get_setting("application/config/version", "development")) } await client.rpc_async(session, "record_activity", JSON.stringify(payload)) func _bind_socket_signals() -> void: socket.received_matchmaker_matched.connect(_on_matchmaker_matched) socket.received_match_state.connect(_on_match_state) socket.received_status_presence.connect(_on_status_presence) socket.received_notification.connect(_on_notification) socket.closed.connect(_on_socket_closed) func disconnect_from_server() -> void: intentional_disconnect = true pending_snapshot_payloads.clear() if socket: socket.close() socket = null session = null current_match = null friends.clear() friend_statuses.clear() presence_status = "" matchmaker_ticket = "" private_room_code = "" lobby_ready = false _set_connection_state("offline") func server_info() -> Dictionary: if not is_authenticated(): return {} var result = await client.rpc_async(session, "server_info", "{}") return _parse_rpc_result(result) func submit_run(record: Dictionary) -> bool: if not is_authenticated(): return false var payload := record.duplicate(true) payload["client_version"] = str(ProjectSettings.get_setting("application/config/version", "development")) var result = await client.rpc_async(session, "submit_run", JSON.stringify(payload)) var parsed := _parse_rpc_result(result) if parsed.is_empty() or not bool(parsed.get("accepted", false)): return false if account_manager: account_manager.mark_submission_complete(record) score_submitted.emit(parsed) return true func flush_pending_scores() -> void: if not account_manager or not is_authenticated(): return var queued: Array = account_manager.pending_submissions.duplicate(true) for record in queued: if record is Dictionary: await submit_run(record) func update_display_name(value: String) -> Dictionary: if not account_manager: return {"accepted": false, "error": "Lokales Profil fehlt."} var validation_error := account_manager.validate_display_name(value) if not validation_error.is_empty(): return {"accepted": false, "error": validation_error} var normalized := account_manager.normalize_display_name(value) if is_authenticated(): var result = await client.rpc_async(session, "update_display_name", JSON.stringify({"display_name": normalized})) var parsed := _parse_rpc_result(result) if parsed.is_empty() or not bool(parsed.get("accepted", false)): return {"accepted": false, "error": str(parsed.get("error", "Der Name konnte online nicht gespeichert werden."))} normalized = str(parsed.get("display_name", normalized)) account_manager.set_display_name(normalized) return {"accepted": true, "display_name": normalized, "online": is_authenticated()} func delete_online_account() -> Dictionary: if not is_authenticated(): return {"accepted": false, "error": "Für die Kontolöschung ist eine Serververbindung erforderlich."} var result = await client.rpc_async(session, "delete_account", "{}") var parsed := _parse_rpc_result(result) if parsed.is_empty() or not bool(parsed.get("accepted", false)): return {"accepted": false, "error": str(parsed.get("error", "Das Online-Konto konnte nicht gelöscht werden."))} disconnect_from_server() if account_manager: account_manager.reset_after_account_deletion() return {"accepted": true} func redeem_unlock_code(code: String) -> Dictionary: if not is_authenticated(): return {"accepted": false, "error": "offline"} var result = await client.rpc_async(session, "redeem_unlock_code", JSON.stringify({"code": code})) var parsed := _parse_rpc_result(result) if parsed.is_empty(): return {"accepted": false, "error": "invalid"} return parsed func get_promo_unlocks() -> Dictionary: if not is_authenticated(): return {} var result = await client.rpc_async(session, "get_promo_unlocks", "{}") var parsed := _parse_rpc_result(result) return parsed if bool(parsed.get("accepted", false)) else {} func set_presence_status(value: String) -> void: if not is_online() or value == presence_status: return var safe_value := "playing" if value == "playing" else "menu" var result = await socket.update_status_async(safe_value) if result != null and not result.is_exception(): presence_status = safe_value func refresh_friends() -> bool: if not is_authenticated(): return false var result = await client.list_friends_async(session, null, 100) if result == null or result.is_exception(): return false var rows: Array[Dictionary] = [] var follow_ids := PackedStringArray() for item in result.friends: if item.user == null: continue var user_id: String = item.user.id var state: int = item.state rows.append({"id": user_id, "username": item.user.username, "name": item.user.display_name if not item.user.display_name.is_empty() else item.user.username, "state": state, "online": item.user.online}) if state == 0: follow_ids.append(user_id) friends = rows friend_statuses.clear() if is_online() and not follow_ids.is_empty(): var statuses = await socket.follow_users_async(follow_ids, PackedStringArray()) if statuses != null and not statuses.is_exception(): for presence in statuses.presences: friend_statuses[presence.user_id] = presence.status friends_changed.emit() return true func add_friend_by_display_name(display_name: String) -> Dictionary: var normalized := display_name.strip_edges() if not is_authenticated() or normalized.is_empty(): return {"accepted": false, "error": "offline"} var lookup_result = await client.rpc_async(session, "find_player_by_name", JSON.stringify({"display_name": normalized})) var found := _parse_rpc_result(lookup_result) if not bool(found.get("found", false)): return {"accepted": false, "error": String(found.get("error", "not_found"))} var user_id := String(found.get("user_id", "")) if user_id.is_empty() or user_id == session.user_id: return {"accepted": false, "error": "self"} var result = await client.add_friends_async(session, PackedStringArray([user_id])) if result == null or result.is_exception(): return {"accepted": false, "error": "send_failed"} await refresh_friends() return {"accepted": true, "display_name": String(found.get("display_name", normalized))} func accept_friend(user_id: String) -> bool: if not is_authenticated() or user_id.is_empty(): return false var result = await client.add_friends_async(session, PackedStringArray([user_id])) if result == null or result.is_exception(): return false await refresh_friends() return true func remove_friend(user_id: String) -> bool: if not is_authenticated() or user_id.is_empty(): return false var result = await client.delete_friends_async(session, PackedStringArray([user_id])) if result == null or result.is_exception(): return false friend_statuses.erase(user_id) await refresh_friends() return true func invite_friend(user_id: String, room_code: String) -> bool: if not is_authenticated() or user_id.is_empty() or room_code.length() != 6: return false var result = await client.rpc_async(session, "invite_friend", JSON.stringify({"friend_id": user_id, "room_code": room_code})) return bool(_parse_rpc_result(result).get("accepted", false)) func _on_status_presence(event) -> void: for presence in event.joins: friend_statuses[presence.user_id] = presence.status for presence in event.leaves: friend_statuses.erase(presence.user_id) friends_changed.emit() func _on_notification(notification) -> void: if notification.code in [-2, -3]: refresh_friends() return if notification.code != 101: return var content = JSON.parse_string(notification.content) if not content is Dictionary: return var code := String(content.get("room_code", "")) if code.length() != 6: return match_invite_received.emit({"room_code": code, "from": String(content.get("from", "Mitspieler"))}) func load_leaderboard(map_index: int, difficulty_index: int, endless: bool, limit := 20) -> Array: if not is_authenticated(): return [] var leaderboard_id := make_leaderboard_id(map_index, difficulty_index, "endless" if endless else "mission") var result = await client.list_leaderboard_records_async(session, leaderboard_id, null, null, clampi(limit, 1, 100)) if result == null or result.is_exception(): return [] var owner_ids := PackedStringArray() for record in result.records: if not record.owner_id.is_empty() and not owner_ids.has(record.owner_id): owner_ids.append(record.owner_id) var current_names := {} if not owner_ids.is_empty(): var users_result = await client.get_users_async(session, owner_ids) if users_result != null and not users_result.is_exception(): for user in users_result.users: if not user.display_name.is_empty(): current_names[user.id] = user.display_name var rows: Array = [] for record in result.records: rows.append({ "rank": int(record.rank), "username": str(current_names.get(record.owner_id, record.username)), "score": int(record.score), "wave": int(record.subscore), "owner_id": record.owner_id, "map": map_index, "difficulty": difficulty_index, "mode": "endless" if endless else "mission" }) leaderboard_loaded.emit(leaderboard_id, rows) return rows func begin_matchmaking(mode := "quick", difficulty := 1) -> bool: intentional_disconnect = false if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false if not matchmaker_ticket.is_empty(): return true var string_properties := {"game": "void_bastion", "mode": mode} var numeric_properties := {"difficulty": float(difficulty)} var query := "+properties.game:void_bastion +properties.mode:%s +properties.difficulty:%d" % [mode, difficulty] var ticket = await socket.add_matchmaker_async(query, 2, 2, string_properties, numeric_properties) if ticket == null or ticket.is_exception(): matchmaking_state_changed.emit("error") return false matchmaker_ticket = ticket.ticket matchmaking_state_changed.emit("searching") return true func cancel_matchmaking() -> void: intentional_disconnect = true pending_snapshot_payloads.clear() _finish_match_diagnostics("cancelled_matchmaking") var match_to_leave = current_match current_match = null if socket and not matchmaker_ticket.is_empty(): await socket.remove_matchmaker_async(matchmaker_ticket) matchmaker_ticket = "" if socket and match_to_leave != null: await socket.leave_match_async(match_to_leave.match_id) private_room_code = "" matchmaking_state_changed.emit("idle") func create_private_room(difficulty := 1) -> bool: intentional_disconnect = false pending_snapshot_payloads.clear() if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false matchmaking_state_changed.emit("creating_room") var result = await client.rpc_async(session, "create_private_room", JSON.stringify({"difficulty": clampi(difficulty, 0, 2)})) var data := _parse_rpc_result(result) var match_id := String(data.get("match_id", "")) private_room_code = String(data.get("room_code", "")) if match_id.is_empty() or private_room_code.length() != 6: private_room_code = "" matchmaking_state_changed.emit("error") return false current_match = await socket.join_match_async(match_id) if current_match == null or current_match.is_exception(): current_match = null private_room_code = "" matchmaking_state_changed.emit("error") return false private_room_created.emit(private_room_code) matchmaking_state_changed.emit("hosting") lobby_ready = false await set_lobby_ready(false) return true func join_private_room(room_code: String) -> bool: intentional_disconnect = false pending_snapshot_payloads.clear() if not is_online() or socket == null: matchmaking_state_changed.emit("offline") return false private_room_code = room_code.to_upper().strip_edges() matchmaking_state_changed.emit("joining_room") var result = await client.rpc_async(session, "join_private_room", JSON.stringify({"room_code": private_room_code})) var data := _parse_rpc_result(result) var match_id := String(data.get("match_id", "")) if not bool(data.get("found", false)) or match_id.is_empty(): private_room_code = "" matchmaking_state_changed.emit("room_not_found") return false current_match = await socket.join_match_async(match_id) if current_match == null or current_match.is_exception(): current_match = null private_room_code = "" matchmaking_state_changed.emit("room_not_found") return false matchmaking_state_changed.emit("matched") lobby_ready = false await set_lobby_ready(false) return true func _on_matchmaker_matched(matched) -> void: intentional_disconnect = false pending_snapshot_payloads.clear() matchmaker_ticket = "" current_match = await socket.join_matched_async(matched) if current_match == null or current_match.is_exception(): matchmaking_state_changed.emit("error") return matchmaking_state_changed.emit("matched") match_found.emit(current_match) lobby_ready = false await set_lobby_ready(false) func set_lobby_ready(value: bool) -> bool: lobby_ready = value var player_name := account_manager.display_name if account_manager else session.username return await send_match_command(OP_READY, {"ready": value, "name": player_name}) func send_match_command(op_code: int, payload: Dictionary) -> bool: if not is_online() or current_match == null: return false var result = await socket.send_match_state_async(current_match.match_id, op_code, JSON.stringify(payload)) return result != null and not result.is_exception() func request_tower_build(tower_type: int, position: Vector2) -> bool: return await send_match_command(OP_BUILD, {"tower_type": tower_type, "x": position.x, "y": position.y}) func request_tower_upgrade(tower_id: int) -> bool: return await send_match_command(OP_UPGRADE, {"tower_id": tower_id}) func request_tower_sell(tower_id: int) -> bool: return await send_match_command(OP_SELL, {"tower_id": tower_id}) func request_wave_start() -> bool: return await send_match_command(OP_START_WAVE, {}) func leave_current_match() -> void: pending_snapshot_payloads.clear() _finish_match_diagnostics("left_match") var match_to_leave = current_match current_match = null if socket and match_to_leave != null: await socket.leave_match_async(match_to_leave.match_id) private_room_code = "" lobby_ready = false func _on_match_state(data) -> void: if current_match == null or data.match_id != current_match.match_id: return if data.op_code == OP_SNAPSHOT: _begin_match_diagnostics() var now_ms := Time.get_ticks_msec() if snapshot_previous_ms > 0: var gap_ms := now_ms - snapshot_previous_ms snapshot_gap_max_ms = maxi(snapshot_gap_max_ms, gap_ms) snapshot_gap_samples.append(gap_ms) if gap_ms > 500: snapshot_large_gap_count += 1 _write_diagnostic_record("snapshot_gap", {"duration_ms": gap_ms, "last_server_tick": snapshot_tick_previous}) snapshot_previous_ms = now_ms snapshot_count += 1 var payload_bytes := String(data.data).to_utf8_buffer().size() snapshot_bytes_max = maxi(snapshot_bytes_max, payload_bytes) snapshot_bytes_total += payload_bytes pending_snapshot_payloads.append(String(data.data)) snapshot_queue_max = maxi(snapshot_queue_max, pending_snapshot_payloads.size()) if pending_snapshot_payloads.size() > MAX_PENDING_SNAPSHOTS: pending_snapshot_payloads.pop_front() snapshot_queue_dropped_count += 1 elif data.op_code == OP_EVENT: var parsed = JSON.parse_string(data.data) if parsed is Dictionary: match_event_received.emit(parsed) func _on_socket_closed() -> void: print("ONLINE DIAG websocket_closed reconnect=%s" % str(not intentional_disconnect and current_match != null)) _write_diagnostic_record("socket_closed", {"reconnect": not intentional_disconnect and current_match != null}) pending_snapshot_payloads.clear() presence_status = "" friend_statuses.clear() friends_changed.emit() snapshot_previous_ms = 0 var reconnect_match_id := "" if not intentional_disconnect and current_match != null: reconnect_match_id = String(current_match.match_id) current_match = null if connection_state != "offline": _set_connection_state("authenticated") if not reconnect_match_id.is_empty() and not reconnect_in_progress: reconnect_in_progress = true matchmaking_state_changed.emit("reconnecting") _recover_match.call_deferred(reconnect_match_id) elif not intentional_disconnect and reconnect_match_id.is_empty() and not reconnect_in_progress and is_authenticated(): reconnect_in_progress = true _recover_presence.call_deferred() func _recover_presence() -> void: for attempt in 5: await get_tree().create_timer(1.5 + attempt).timeout if intentional_disconnect or not is_authenticated(): break socket = Nakama.create_socket_from(client) _bind_socket_signals() var connected = await socket.connect_async(session, true, 5) if connected == null or connected.is_exception(): continue reconnect_in_progress = false _set_connection_state("online") var parent_game = get_parent() await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over", "endless_offer", "endless_result"] else "menu") await refresh_friends() return reconnect_in_progress = false func _recover_match(match_id: String) -> void: for attempt in 5: await get_tree().create_timer(1.5 + attempt).timeout if intentional_disconnect or not is_authenticated(): break socket = Nakama.create_socket_from(client) _bind_socket_signals() var connected = await socket.connect_async(session, true, 5) if connected == null or connected.is_exception(): continue var rejoined = await socket.join_match_async(match_id) if rejoined != null and not rejoined.is_exception(): print("ONLINE DIAG websocket_reconnected attempt=%d" % (attempt + 1)) _write_diagnostic_record("socket_reconnected", {"attempt": attempt + 1}) current_match = rejoined intentional_disconnect = false reconnect_in_progress = false _set_connection_state("online") var parent_game = get_parent() await set_presence_status("playing" if parent_game != null and parent_game.game_mode in ["playing", "paused", "victory", "game_over"] else "menu") matchmaking_state_changed.emit("reconnected") return reconnect_in_progress = false print("ONLINE DIAG websocket_reconnect_failed") _write_diagnostic_record("socket_reconnect_failed", {}) matchmaking_state_changed.emit("connection_lost") func _device_id() -> String: if account_manager and not account_manager.player_id.is_empty(): return account_manager.player_id var fallback := "voidbastion_%s" % Crypto.new().generate_random_bytes(16).hex_encode() return fallback func is_authenticated() -> bool: return session != null and session.is_valid() and not session.expired func is_online() -> bool: return is_authenticated() and socket != null and socket.is_connected_to_host() func make_leaderboard_id(map_index: int, difficulty_index: int, mode: String) -> String: var safe_mode := mode if mode in ["mission", "endless", "coop_ai", "coop_online"] else "mission" return "%s_map%d_difficulty%d" % [safe_mode, clampi(map_index, 0, 6), clampi(difficulty_index, 0, 2)] func _parse_rpc_result(result) -> Dictionary: if result == null or result.is_exception(): return {} var parsed = JSON.parse_string(result.payload) return parsed if parsed is Dictionary else {} func _set_connection_state(value: String) -> void: if connection_state == value: return connection_state = value connection_state_changed.emit(connection_state)