class_name NetworkManager extends Node ## NetworkManager - Singleton for handling network communication ## Manages authentication, WebSocket connection, and game messaging # ======= SIGNALS ======= # Connection and auth signal connection_state_changed(state: ConnectionState) signal authenticated(user_data: Dictionary) signal authentication_failed(error: String) signal logged_out # Matchmaking - maps from server messages: queue_joined, queue_left, match_found, room_* signal matchmaking_update(data: Dictionary) signal queue_joined signal queue_left signal match_found(game_data: Dictionary) signal room_created(room_data: Dictionary) signal room_joined(room_data: Dictionary) signal room_updated(room_data: Dictionary) # Game messages - maps from server 'opponent_action' message signal opponent_action_received(action: Dictionary) # Maps from server 'turn_timer' message signal turn_timer_update(seconds_remaining: int) signal game_started(game_data: Dictionary) signal game_ended(result: Dictionary) signal phase_changed(phase_data: Dictionary) signal action_confirmed(action_type: String) signal action_failed(action_type: String, error: String) signal opponent_disconnected(reconnect_timeout: int) signal opponent_reconnected signal game_state_sync(state: Dictionary) # Error handling signal network_error(error: String) # ======= ENUMS ======= enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTHENTICATING, AUTHENTICATED, IN_QUEUE, IN_ROOM, IN_GAME } # ======= CONSTANTS ======= const DEFAULT_HTTP_URL = "http://localhost:3000" const DEFAULT_WS_URL = "ws://localhost:3001" const HEARTBEAT_INTERVAL = 10.0 # seconds const RECONNECT_DELAY = 5.0 const MAX_RECONNECT_ATTEMPTS = 3 const TOKEN_FILE = "user://auth_token.dat" # ======= STATE ======= var connection_state: ConnectionState = ConnectionState.DISCONNECTED var http_base_url: String = DEFAULT_HTTP_URL var ws_url: String = DEFAULT_WS_URL # Auth state var auth_token: String = "" var current_user: Dictionary = {} var is_authenticated: bool = false # WebSocket var _websocket: WebSocketPeer = null var _heartbeat_timer: Timer = null var _reconnect_attempts: int = 0 # HTTP request pool var _http_requests: Array[HTTPRequest] = [] # Game session var current_game_id: String = "" var current_room_code: String = "" var opponent_info: Dictionary = {} var local_player_index: int = 0 func _ready() -> void: # Try to load saved token on startup _load_token() # Setup heartbeat timer _heartbeat_timer = Timer.new() _heartbeat_timer.wait_time = HEARTBEAT_INTERVAL _heartbeat_timer.timeout.connect(_on_heartbeat_timeout) add_child(_heartbeat_timer) func _process(_delta: float) -> void: # Poll WebSocket if connected if _websocket: _websocket.poll() var state = _websocket.get_ready_state() match state: WebSocketPeer.STATE_OPEN: while _websocket.get_available_packet_count() > 0: var packet = _websocket.get_packet() _on_websocket_message(packet) WebSocketPeer.STATE_CLOSING: pass # Wait for close WebSocketPeer.STATE_CLOSED: var code = _websocket.get_close_code() var reason = _websocket.get_close_reason() print("WebSocket closed: ", code, " - ", reason) _on_websocket_closed() # ======= CONFIGURATION ======= func configure(http_url: String, ws_url_param: String) -> void: http_base_url = http_url ws_url = ws_url_param # ======= HTTP AUTH API ======= func register(email: String, password: String, username: String) -> Dictionary: var result = await _http_post("/api/auth/register", { "email": email, "password": password, "username": username }) return result func login(email: String, password: String) -> Dictionary: var result = await _http_post("/api/auth/login", { "email": email, "password": password }) if result.success: auth_token = result.token current_user = result.user is_authenticated = true _save_token() authenticated.emit(current_user) else: authentication_failed.emit(result.message) return result func logout() -> void: auth_token = "" current_user = {} is_authenticated = false _clear_token() disconnect_websocket() logged_out.emit() func verify_email(token: String) -> Dictionary: return await _http_post("/api/auth/verify-email", { "token": token }) func forgot_password(email: String) -> Dictionary: return await _http_post("/api/auth/forgot-password", { "email": email }) func reset_password(token: String, new_password: String) -> Dictionary: return await _http_post("/api/auth/reset-password", { "token": token, "newPassword": new_password }) func resend_verification(email: String) -> Dictionary: return await _http_post("/api/auth/resend-verification", { "email": email }) func get_profile() -> Dictionary: return await _http_get("/api/user/profile", true) func get_match_history(limit: int = 20, offset: int = 0) -> Dictionary: return await _http_get("/api/user/match-history?limit=%d&offset=%d" % [limit, offset], true) func get_leaderboard(limit: int = 50, offset: int = 0) -> Dictionary: return await _http_get("/api/leaderboard?limit=%d&offset=%d" % [limit, offset], false) func save_deck(name: String, card_ids: Array) -> Dictionary: return await _http_post("/api/user/decks", { "name": name, "cardIds": card_ids }, true) func delete_deck(deck_id: String) -> Dictionary: return await _http_delete("/api/user/decks/" + deck_id) # ======= HTTP HELPERS ======= func _get_http_request() -> HTTPRequest: # Reuse or create HTTP request node for req in _http_requests: if not req.is_inside_tree(): add_child(req) # Check if request is not busy (simplified check) return req var new_req = HTTPRequest.new() _http_requests.append(new_req) add_child(new_req) return new_req func _http_post(endpoint: String, body: Dictionary, auth_required: bool = false) -> Dictionary: var http = _get_http_request() var url = http_base_url + endpoint var headers = PackedStringArray(["Content-Type: application/json"]) if auth_required and auth_token != "": headers.append("Authorization: Bearer " + auth_token) var json_body = JSON.stringify(body) var error = http.request(url, headers, HTTPClient.METHOD_POST, json_body) if error != OK: return { "success": false, "message": "HTTP request failed" } var result = await http.request_completed return _parse_http_response(result) func _http_get(endpoint: String, auth_required: bool = false) -> Dictionary: var http = _get_http_request() var url = http_base_url + endpoint var headers = PackedStringArray() if auth_required and auth_token != "": headers.append("Authorization: Bearer " + auth_token) var error = http.request(url, headers, HTTPClient.METHOD_GET) if error != OK: return { "success": false, "message": "HTTP request failed" } var result = await http.request_completed return _parse_http_response(result) func _http_delete(endpoint: String) -> Dictionary: var http = _get_http_request() var url = http_base_url + endpoint var headers = PackedStringArray() if auth_token != "": headers.append("Authorization: Bearer " + auth_token) var error = http.request(url, headers, HTTPClient.METHOD_DELETE) if error != OK: return { "success": false, "message": "HTTP request failed" } var result = await http.request_completed return _parse_http_response(result) func _parse_http_response(result: Array) -> Dictionary: var response_code = result[1] var body = result[3] if response_code == 0: return { "success": false, "message": "Connection failed" } var json = JSON.new() var parse_result = json.parse(body.get_string_from_utf8()) if parse_result != OK: return { "success": false, "message": "Invalid response" } var data = json.data if data is Dictionary: return data return { "success": false, "message": "Unexpected response format" } # ======= WEBSOCKET ======= func connect_websocket() -> void: if _websocket != null: disconnect_websocket() _set_connection_state(ConnectionState.CONNECTING) _websocket = WebSocketPeer.new() var error = _websocket.connect_to_url(ws_url) if error != OK: print("WebSocket connection error: ", error) _set_connection_state(ConnectionState.DISCONNECTED) network_error.emit("Failed to connect to server") return # Wait for connection await get_tree().create_timer(0.5).timeout if _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN: _on_websocket_connected() func disconnect_websocket() -> void: if _websocket: _websocket.close() _websocket = null _heartbeat_timer.stop() _set_connection_state(ConnectionState.DISCONNECTED) current_game_id = "" current_room_code = "" func _on_websocket_connected() -> void: print("WebSocket connected") _set_connection_state(ConnectionState.CONNECTED) _reconnect_attempts = 0 # Authenticate with JWT if auth_token != "": _set_connection_state(ConnectionState.AUTHENTICATING) _send_ws_message("auth", { "token": auth_token }) _heartbeat_timer.start() func _on_websocket_message(data: PackedByteArray) -> void: var json = JSON.new() var parse_result = json.parse(data.get_string_from_utf8()) if parse_result != OK: push_error("Failed to parse WebSocket message: " + str(parse_result)) network_error.emit("Invalid message from server") return var message = json.data if not message is Dictionary or not message.has("type"): push_error("Invalid message format: missing 'type' field") network_error.emit("Invalid message format from server") return _handle_ws_message(message) func _on_websocket_closed() -> void: _websocket = null _heartbeat_timer.stop() var was_authenticated = connection_state == ConnectionState.AUTHENTICATED or connection_state == ConnectionState.IN_GAME _set_connection_state(ConnectionState.DISCONNECTED) # Try to reconnect if we were authenticated if was_authenticated and _reconnect_attempts < MAX_RECONNECT_ATTEMPTS: _reconnect_attempts += 1 print("Attempting reconnect... (attempt ", _reconnect_attempts, ")") await get_tree().create_timer(RECONNECT_DELAY).timeout connect_websocket() func _handle_ws_message(message: Dictionary) -> void: var msg_type = message.get("type", "") var payload = message.get("payload", {}) match msg_type: "auth_success": print("WebSocket authenticated as: ", payload.get("username", "")) _set_connection_state(ConnectionState.AUTHENTICATED) "auth_error": print("WebSocket auth error: ", payload.get("message", "")) _set_connection_state(ConnectionState.CONNECTED) authentication_failed.emit(payload.get("message", "Authentication failed")) "pong": pass # Heartbeat response "error": print("Server error: ", payload.get("message", "")) network_error.emit(payload.get("message", "Unknown error")) "disconnected": print("Disconnected: ", payload.get("message", "")) disconnect_websocket() # Matchmaking messages "queue_joined": _set_connection_state(ConnectionState.IN_QUEUE) queue_joined.emit() matchmaking_update.emit({ "type": "queue_joined", "position": payload.get("position", 0) }) "queue_left": _set_connection_state(ConnectionState.AUTHENTICATED) queue_left.emit() matchmaking_update.emit({ "type": "queue_left" }) "match_found": _set_connection_state(ConnectionState.IN_GAME) current_game_id = payload.get("game_id", "") opponent_info = payload.get("opponent", {}) local_player_index = payload.get("your_player_index", 0) match_found.emit(payload) "room_created": _set_connection_state(ConnectionState.IN_ROOM) current_room_code = payload.get("code", "") room_created.emit(payload) "room_joined": _set_connection_state(ConnectionState.IN_ROOM) current_room_code = payload.get("code", "") room_joined.emit(payload) "room_updated": room_updated.emit(payload) "room_left": _set_connection_state(ConnectionState.AUTHENTICATED) current_room_code = "" matchmaking_update.emit({ "type": "room_left" }) # Game messages "game_start": _set_connection_state(ConnectionState.IN_GAME) current_game_id = payload.get("game_id", "") game_started.emit(payload) "opponent_action": opponent_action_received.emit(payload) "turn_timer": turn_timer_update.emit(payload.get("seconds_remaining", 0)) "phase_changed": phase_changed.emit(payload) "action_confirmed": action_confirmed.emit(payload.get("action_type", "")) "action_failed": action_failed.emit(payload.get("action_type", ""), payload.get("error", "Unknown error")) network_error.emit("Action failed: " + payload.get("error", "Unknown error")) "opponent_disconnected": opponent_disconnected.emit(payload.get("reconnect_timeout_seconds", 60)) "opponent_reconnected": opponent_reconnected.emit() "game_state_sync": game_state_sync.emit(payload) "game_ended": game_ended.emit(payload) _set_connection_state(ConnectionState.AUTHENTICATED) current_game_id = "" _: print("Unknown message type: ", msg_type) func _send_ws_message(type: String, payload: Dictionary) -> void: if _websocket == null or _websocket.get_ready_state() != WebSocketPeer.STATE_OPEN: print("Cannot send message - WebSocket not connected") return var message = { "type": type, "payload": payload } var json = JSON.stringify(message) _websocket.send_text(json) func _on_heartbeat_timeout() -> void: if _websocket and _websocket.get_ready_state() == WebSocketPeer.STATE_OPEN: _send_ws_message("ping", { "client_time": Time.get_unix_time_from_system() }) func _set_connection_state(new_state: ConnectionState) -> void: if connection_state != new_state: connection_state = new_state connection_state_changed.emit(new_state) # ======= MATCHMAKING ======= func join_queue(deck_id: String) -> void: _send_ws_message("queue_join", { "deck_id": deck_id }) func leave_queue() -> void: _send_ws_message("queue_leave", {}) func create_room(deck_id: String) -> void: _send_ws_message("room_create", { "deck_id": deck_id }) func join_room(room_code: String, deck_id: String) -> void: _send_ws_message("room_join", { "room_code": room_code.to_upper(), "deck_id": deck_id }) func leave_room() -> void: _send_ws_message("room_leave", {}) func set_room_ready(ready: bool) -> void: _send_ws_message("room_ready", { "ready": ready }) # ======= GAME ACTIONS ======= func send_game_action(action_type: String, payload: Dictionary) -> void: payload["game_id"] = current_game_id _send_ws_message("action_" + action_type, payload) func send_play_card(card_instance_id: int) -> void: send_game_action("play_card", { "card_instance_id": card_instance_id }) func send_attack(attacker_instance_id: int) -> void: send_game_action("attack", { "attacker_instance_id": attacker_instance_id }) func send_block(blocker_instance_id) -> void: # Can be int or null send_game_action("block", { "blocker_instance_id": blocker_instance_id }) func send_pass() -> void: send_game_action("pass", {}) func send_concede() -> void: send_game_action("concede", {}) func send_discard_for_cp(card_instance_id: int) -> void: send_game_action("discard_cp", { "card_instance_id": card_instance_id }) func send_dull_backup_for_cp(card_instance_id: int) -> void: send_game_action("dull_backup_cp", { "card_instance_id": card_instance_id }) func send_attack_resolved() -> void: send_game_action("attack_resolved", {}) func send_report_game_end(winner_id: String, reason: String) -> void: send_game_action("report_game_end", { "winner_id": winner_id, "reason": reason }) # ======= TOKEN PERSISTENCE ======= func _save_token() -> void: if auth_token == "": return var file = FileAccess.open(TOKEN_FILE, FileAccess.WRITE) if file: file.store_string(auth_token) file.close() func _load_token() -> void: if not FileAccess.file_exists(TOKEN_FILE): return var file = FileAccess.open(TOKEN_FILE, FileAccess.READ) if file: auth_token = file.get_as_text().strip_edges() file.close() if auth_token != "": # Validate token by fetching profile var profile = await get_profile() if profile.success: current_user = profile.user is_authenticated = true authenticated.emit(current_user) else: # Token invalid, clear it _clear_token() func _clear_token() -> void: auth_token = "" if FileAccess.file_exists(TOKEN_FILE): DirAccess.remove_absolute(TOKEN_FILE) # ======= UTILITY ======= func get_connection_state_name() -> String: match connection_state: ConnectionState.DISCONNECTED: return "Disconnected" ConnectionState.CONNECTING: return "Connecting" ConnectionState.CONNECTED: return "Connected" ConnectionState.AUTHENTICATING: return "Authenticating" ConnectionState.AUTHENTICATED: return "Online" ConnectionState.IN_QUEUE: return "In Queue" ConnectionState.IN_ROOM: return "In Room" ConnectionState.IN_GAME: return "In Game" return "Unknown"