diff --git a/weltenauge/AGENTS.md b/weltenauge/AGENTS.md new file mode 100644 index 0000000..88270aa --- /dev/null +++ b/weltenauge/AGENTS.md @@ -0,0 +1,6 @@ +# Godot 4.7 Entwicklungs-Regeln + +- Nutze striktes Static Typing in GDScript (`var speed: float = 100.0`). +- Bevorzuge `@onready var` für Knoten-Referenzen. +- Nutze das `godot`-MCP-Tool, um Szenenhierarchien zu prüfen, bevor du Knotenpfade im Code anpasst. +- Spiele-Logik immer in dedizierten Komponenten/Nodes kapseln. diff --git a/weltenauge/godot_ai_bridge/godot_ai_bridge.gd b/weltenauge/godot_ai_bridge/godot_ai_bridge.gd new file mode 100644 index 0000000..35aae99 --- /dev/null +++ b/weltenauge/godot_ai_bridge/godot_ai_bridge.gd @@ -0,0 +1,341 @@ +@tool +extends EditorPlugin +## Main plugin for Godot AI Bridge + +const WSServer = preload("res://addons/godot_ai_bridge/ws_server.gd") +const MessageHandler = preload("res://addons/godot_ai_bridge/message_handler.gd") +const RUNTIME_AUTOLOAD_NAME := "GodotAIBridgeRuntime" +const RUNTIME_AUTOLOAD_PATH := "res://addons/godot_ai_bridge/runtime_bridge.gd" + +var _ws_server: Node +var _message_handler: RefCounted +var _debugger_plugin: AIBridgeDebuggerPlugin +var _port: int = 6550 +const PAIRING_PATH := "res://.godot/godot-ai-bridge.json" + + +func _enter_tree() -> void: + _ensure_runtime_autoload() + + _message_handler = MessageHandler.new() + _message_handler.editor_interface = get_editor_interface() + _message_handler.undo_redo = get_undo_redo() + + # Capture runtime output, errors, and automation responses. + _debugger_plugin = AIBridgeDebuggerPlugin.new() + _debugger_plugin.message_handler = _message_handler + _message_handler.debugger_plugin = _debugger_plugin + add_debugger_plugin(_debugger_plugin) + + _ws_server = WSServer.new() + _ws_server.port = _port + _ws_server.pairing_token = _create_pairing_token() + if _ws_server.pairing_token.is_empty(): + push_error("[AI Bridge] Unable to create private pairing credentials; server disabled.") + return + _ws_server.message_received.connect(_on_message_received) + _ws_server.client_connected.connect(_on_client_connected) + _ws_server.client_disconnected.connect(_on_client_disconnected) + + add_child(_ws_server) + + var err = _ws_server.start() + if err == OK: + print("[AI Bridge] Server started on port ", _port) + else: + push_error("[AI Bridge] Failed to start server: " + str(err)) + + +func _exit_tree() -> void: + if _debugger_plugin: + remove_debugger_plugin(_debugger_plugin) + _debugger_plugin = null + if _ws_server: + _ws_server.stop() + _ws_server.queue_free() + _ws_server = null + _message_handler = null + + +func _create_pairing_token() -> String: + var bytes := Crypto.new().generate_random_bytes(32) + if bytes.size() != 32: + return "" + var token := bytes.hex_encode() + var absolute_path := ProjectSettings.globalize_path(PAIRING_PATH) + var mkdir_error := DirAccess.make_dir_recursive_absolute(absolute_path.get_base_dir()) + if mkdir_error != OK and mkdir_error != ERR_ALREADY_EXISTS: + return "" + var directory := DirAccess.open(absolute_path.get_base_dir()) + if directory == null or directory.is_link(absolute_path.get_file()): + return "" + # Create an empty file, restrict access, then write the secret. Never log it. + var file := FileAccess.open(absolute_path, FileAccess.WRITE) + if file == null: + return "" + # Editor safe-save writes through a temporary file until close(). Restrict + # the resulting empty file before reopening it without replacement. + file.close() + if OS.get_name() != "Windows": + if FileAccess.set_unix_permissions(absolute_path, 384) != OK: + return "" + file = FileAccess.open(absolute_path, FileAccess.READ_WRITE) + if file == null: + return "" + file.store_string(JSON.stringify({"token": token, "port": _port, "version": 1})) + file.close() + return token + + +func _ensure_runtime_autoload() -> void: + var autoload_key := "autoload/%s" % RUNTIME_AUTOLOAD_NAME + var existing_value := str(ProjectSettings.get_setting(autoload_key, "")) + var normalized_existing := existing_value.trim_prefix("*") + + if normalized_existing.is_empty(): + add_autoload_singleton(RUNTIME_AUTOLOAD_NAME, RUNTIME_AUTOLOAD_PATH) + # A separately launched runtime reads project.godot immediately. + if ProjectSettings.save() != OK: + push_error("[AI Bridge] Failed to persist runtime autoload configuration.") + return + + if normalized_existing != RUNTIME_AUTOLOAD_PATH: + push_warning( + "[AI Bridge] Autoload name conflict for %s: %s" + % [RUNTIME_AUTOLOAD_NAME, existing_value] + ) + + +func _on_message_received(peer_id: int, message: String) -> void: + call_deferred("_send_response_async", peer_id, message) + + +func _send_response_async(peer_id: int, message: String) -> void: + var response: String = await _message_handler.handle_message(message) + if _ws_server: + _ws_server.send_message(peer_id, response) + + +func _on_client_connected(peer_id: int) -> void: + print("[AI Bridge] Client connected: ", peer_id) + + +func _on_client_disconnected(peer_id: int) -> void: + print("[AI Bridge] Client disconnected: ", peer_id) + + +## Debugger plugin to capture output, errors, and runtime automation responses. +class AIBridgeDebuggerPlugin extends EditorDebuggerPlugin: + var message_handler: RefCounted + + const FILTERED_MESSAGES := [ + "game_view:cursor_set_shape", + "game_view:mouse_over", + ] + const RUNTIME_PREFIX := "godot_runtime" + const DEFAULT_RUNTIME_TIMEOUT_SEC := 5.0 + + var _tracked_session_ids: Array[int] = [] + var _session_runtime_ready: Dictionary = {} + var _pending_runtime_requests: Dictionary = {} + var _next_runtime_request_id: int = 1 + + func _has_capture(_prefix: String) -> bool: + return true + + func _capture(message: String, data: Array, session_id: int) -> bool: + if not message_handler: + return false + + match message: + "%s:ready" % RUNTIME_PREFIX: + _handle_runtime_ready(data, session_id) + return true + "%s:response" % RUNTIME_PREFIX: + _handle_runtime_response(data, session_id) + return true + + for filtered in FILTERED_MESSAGES: + if message == filtered: + return false + + # Ordinary engine output/error/stack packets are consumed by Godot before + # plugin capture. MessageHandler reads the configured runtime log instead. + + return false + + func send_runtime_request( + method: String, + params: Dictionary, + timeout_seconds: float = DEFAULT_RUNTIME_TIMEOUT_SEC + ) -> Dictionary: + var session_id := _get_active_session_id() + if session_id == -1: + return { + "error": { + "code": -32603, + "message": "No active runtime debug session. Run a scene before using runtime automation tools." + } + } + + var session = get_session(session_id) + if not session: + return { + "error": { + "code": -32603, + "message": "Failed to access the active runtime debug session." + } + } + + var request_id := _next_runtime_request_id + _next_runtime_request_id += 1 + + _pending_runtime_requests[request_id] = { + "ready": false, + "payload": {}, + "session_id": session_id + } + + session.send_message("%s:request" % RUNTIME_PREFIX, [request_id, method, params]) + + var deadline := Time.get_ticks_msec() + int(max(timeout_seconds, 0.1) * 1000.0) + while Time.get_ticks_msec() < deadline: + var pending = _pending_runtime_requests.get(request_id) + if pending is Dictionary and bool(pending.get("ready", false)): + _pending_runtime_requests.erase(request_id) + return _coerce_runtime_reply(pending.get("payload", {}), method) + await Engine.get_main_loop().process_frame + + _pending_runtime_requests.erase(request_id) + return { + "error": { + "code": -32603, + "message": ( + "Timed out waiting for runtime automation response for %s. " + + "Make sure the scene is running and the runtime harness is loaded." + ) % method + } + } + + func _setup_session(session_id: int) -> void: + if not _tracked_session_ids.has(session_id): + _tracked_session_ids.append(session_id) + + _session_runtime_ready[session_id] = false + + var session = get_session(session_id) + if not session: + return + + var started_callable := Callable(self, "_on_session_started").bind(session_id) + if not session.started.is_connected(started_callable): + session.started.connect(started_callable) + + var stopped_callable := Callable(self, "_on_session_stopped").bind(session_id) + if not session.stopped.is_connected(stopped_callable): + session.stopped.connect(stopped_callable) + + if session.is_active(): + _on_session_started(session_id) + + func _on_session_started(session_id: int) -> void: + _session_runtime_ready[session_id] = false + if message_handler: + message_handler.begin_runtime_log() + message_handler.log_output( + "[DEBUG] Game started (session %d)" % session_id, + "info", + "debugger" + ) + + func _on_session_stopped(session_id: int) -> void: + _session_runtime_ready.erase(session_id) + _fail_pending_requests_for_session( + session_id, + "Runtime debug session stopped before the automation request completed." + ) + + if message_handler: + message_handler.end_runtime_log() + message_handler.log_output( + "[DEBUG] Game stopped (session %d)" % session_id, + "info", + "debugger" + ) + + func _get_active_session_id() -> int: + for i in range(_tracked_session_ids.size() - 1, -1, -1): + var session_id = _tracked_session_ids[i] + var session = get_session(session_id) + if session and session.is_active(): + return session_id + return -1 + + func _handle_runtime_ready(data: Array, session_id: int) -> void: + _session_runtime_ready[session_id] = true + + var details := "" + if data.size() > 0 and data[0] is Dictionary: + var payload: Dictionary = data[0] + var scene_root := str(payload.get("scene_root", "")) + if not scene_root.is_empty(): + details = " for %s" % scene_root + + if message_handler: + message_handler.log_output( + "[RUNTIME] Automation harness ready%s (session %d)" % [details, session_id], + "info", + "debugger" + ) + + func _handle_runtime_response(data: Array, session_id: int) -> void: + if data.size() < 2: + if message_handler: + message_handler.log_output( + "[RUNTIME] Ignoring malformed runtime response for session %d" % session_id, + "warning", + "debugger" + ) + return + + var request_id := int(data[0]) + if not _pending_runtime_requests.has(request_id): + return + + var pending: Dictionary = _pending_runtime_requests[request_id] + pending["ready"] = true + pending["payload"] = data[1] + pending["session_id"] = session_id + _pending_runtime_requests[request_id] = pending + + func _coerce_runtime_reply(payload, method: String) -> Dictionary: + if payload is Dictionary: + var payload_dict: Dictionary = payload + if bool(payload_dict.get("success", false)): + return {"result": payload_dict.get("result", {})} + return { + "error": { + "code": -32603, + "message": str( + payload_dict.get( + "error", + "Runtime automation request failed for " + method + ) + ) + } + } + + return {"result": payload} + + func _fail_pending_requests_for_session(session_id: int, error_message: String) -> void: + var pending_ids := _pending_runtime_requests.keys() + for request_id in pending_ids: + var pending: Dictionary = _pending_runtime_requests.get(request_id, {}) + if int(pending.get("session_id", -1)) != session_id: + continue + pending["ready"] = true + pending["payload"] = { + "success": false, + "error": error_message + } + _pending_runtime_requests[request_id] = pending diff --git a/weltenauge/godot_ai_bridge/message_handler.gd b/weltenauge/godot_ai_bridge/message_handler.gd new file mode 100644 index 0000000..c8d0558 --- /dev/null +++ b/weltenauge/godot_ai_bridge/message_handler.gd @@ -0,0 +1,1128 @@ +@tool +extends RefCounted +## Handles JSON-RPC messages for AI Bridge + +var editor_interface: EditorInterface +var undo_redo: EditorUndoRedoManager +var debugger_plugin: Object +var output_buffer: Array[Dictionary] = [] +var error_buffer: Array[Dictionary] = [] +const MAX_OUTPUT_LINES: int = 500 +const MAX_ERROR_COUNT: int = 100 +var _runtime_log_active := false +var _runtime_log_cursor := 0 +const LOG_LEVELS := ["debug", "info", "warning", "error"] + + +func handle_message(message: String) -> String: + var json = JSON.new() + var parse_err = json.parse(message) + + if parse_err != OK: + return _error_response(null, -32700, "Parse error") + + var data = json.data + if not data is Dictionary: + return _error_response(null, -32600, "Invalid Request") + + var request: Dictionary = data + var id = request.get("id") + var method = request.get("method", "") + var params = request.get("params", {}) + + if not method is String or method.is_empty(): + return _error_response(id, -32600, "Invalid method") + + if not params is Dictionary: + params = {} + + var result = await _dispatch(method, params) + + if result.has("error"): + return _error_response(id, result["error"]["code"], result["error"]["message"]) + + return _success_response(id, result.get("result")) + + +func _dispatch(method: String, params: Dictionary) -> Dictionary: + match method: + "initialize": + return _handle_initialize(params) + "scene_tree.get": + return _handle_get_scene_tree(params) + "scene_tree.add_node": + return _handle_add_node(params) + "scene_tree.remove_node": + return _handle_remove_node(params) + "scene_tree.modify_node": + return _handle_modify_node(params) + "editor.open_scene": + return _handle_open_scene(params) + "editor.save_scene": + return _handle_save_scene(params) + "editor.run_scene": + return _handle_run_scene(params) + "editor.stop_scene": + return _handle_stop_scene(params) + "info.project": + return _handle_get_project_info(params) + "runtime.status": + return await _handle_runtime_status(params) + "runtime.wait": + return await _handle_runtime_wait(params) + "runtime.press_action": + return await _handle_runtime_press_action(params) + "runtime.release_action": + return await _handle_runtime_release_action(params) + "runtime.tap_action": + return await _handle_runtime_tap_action(params) + "runtime.mouse_move": + return await _handle_runtime_mouse_move(params) + "runtime.click": + return await _handle_runtime_click(params) + "runtime.type_text": + return await _handle_runtime_type_text(params) + "runtime.capture_screenshot": + return await _handle_runtime_capture_screenshot(params) + "fs.refresh": + return _handle_refresh_filesystem(params) + "info.errors": + return _handle_get_errors(params) + "info.output": + return _handle_get_output(params) + "info.log_file": + return _handle_get_log_file(params) + "editor.select_node": + return _handle_select_node(params) + "execute.gdscript": + return _handle_execute_gdscript(params) + _: + return {"error": {"code": -32601, "message": "Method not found: " + method}} + + +func _handle_initialize(_params: Dictionary) -> Dictionary: + return { + "result": { + "server": "godot-ai-bridge", + "godot_version": Engine.get_version_info().string, + "project": ProjectSettings.get_setting("application/config/name"), + "capabilities": ["scene_tree", "editor", "runtime"] + } + } + + +func _handle_get_scene_tree(_params: Dictionary) -> Dictionary: + var edited_scene = editor_interface.get_edited_scene_root() + if not edited_scene: + return {"result": {"nodes": [], "message": "No scene open"}} + + var nodes = _serialize_node_tree(edited_scene, "") + return { + "result": { + "root": edited_scene.name, + "scene_path": edited_scene.scene_file_path, + "nodes": nodes + } + } + + +func _serialize_node_tree(node: Node, parent_path: String) -> Array: + var nodes: Array = [] + var node_path = parent_path + "/" + node.name if parent_path else node.name + + var node_data = { + "name": node.name, + "type": node.get_class(), + "path": node_path, + "children_count": node.get_child_count() + } + + if node.get_script(): + node_data["script"] = node.get_script().resource_path + + nodes.append(node_data) + + for child in node.get_children(): + nodes.append_array(_serialize_node_tree(child, node_path)) + + return nodes + + +func _handle_add_node(params: Dictionary) -> Dictionary: + var parent_path: String = params.get("parent", ".") + var node_name: String = params.get("name", "") + var node_type: String = params.get("type", "Node") + var properties: Dictionary = params.get("properties", {}) + + if node_name.is_empty(): + return {"error": {"code": -32602, "message": "Missing name parameter"}} + + var edited_scene = editor_interface.get_edited_scene_root() + if not edited_scene: + return {"error": {"code": -32603, "message": "No scene open"}} + + var parent = _resolve_scene_node(parent_path, edited_scene) + + if not parent: + return {"error": {"code": -32603, "message": "Parent not found: " + parent_path}} + + var new_node = ClassDB.instantiate(node_type) + if not new_node: + return {"error": {"code": -32603, "message": "Failed to create node: " + node_type}} + + new_node.name = node_name + + undo_redo.create_action("Add Node: " + node_name) + undo_redo.add_do_method(parent, "add_child", new_node) + undo_redo.add_do_property(new_node, "owner", edited_scene) + undo_redo.add_do_reference(new_node) + undo_redo.add_undo_method(parent, "remove_child", new_node) + + var applied_properties: Array = [] + for prop_name in properties: + if prop_name in new_node: + var old_value = new_node.get(prop_name) + var new_value = _convert_value(properties[prop_name]) + undo_redo.add_do_property(new_node, prop_name, new_value) + undo_redo.add_undo_property(new_node, prop_name, old_value) + applied_properties.append(prop_name) + + undo_redo.commit_action() + + return { + "result": { + "added": node_name, + "path": _scene_tree_path_for(new_node, edited_scene), + "applied_properties": applied_properties + } + } + + +func _handle_remove_node(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + if path.is_empty(): + return {"error": {"code": -32602, "message": "Missing path parameter"}} + + var edited_scene = editor_interface.get_edited_scene_root() + if not edited_scene: + return {"error": {"code": -32603, "message": "No scene open"}} + + var node = _resolve_scene_node(path, edited_scene) + if not node: + return {"error": {"code": -32603, "message": "Node not found: " + path}} + + if node == edited_scene: + return {"error": {"code": -32603, "message": "Cannot remove scene root"}} + + var parent = node.get_parent() + var scene_path := _scene_tree_path_for(node, edited_scene) + var sibling_index := node.get_index() + var owners: Array = [] + _collect_subtree_owners(node, owners) + + undo_redo.create_action("Remove Node: " + node.name) + undo_redo.add_do_method(parent, "remove_child", node) + undo_redo.add_undo_method(parent, "add_child", node) + undo_redo.add_undo_method(parent, "move_child", node, sibling_index) + for entry in owners: + undo_redo.add_undo_property(entry[0], "owner", entry[1]) + undo_redo.add_undo_reference(node) + undo_redo.commit_action() + + return {"result": {"removed": scene_path}} + + +func _collect_subtree_owners(node: Node, owners: Array) -> void: + owners.append([node, node.owner]) + for child in node.get_children(): + _collect_subtree_owners(child, owners) + + +func _handle_modify_node(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + var properties: Dictionary = params.get("properties", {}) + + if path.is_empty(): + return {"error": {"code": -32602, "message": "Missing path parameter"}} + + var edited_scene = editor_interface.get_edited_scene_root() + if not edited_scene: + return {"error": {"code": -32603, "message": "No scene open"}} + + var node = _resolve_scene_node(path, edited_scene) + if not node: + return {"error": {"code": -32603, "message": "Node not found: " + path}} + + var modified: Array = [] + var scene_path := _scene_tree_path_for(node, edited_scene) + + undo_redo.create_action("Modify Node: " + node.name) + + for prop_name in properties: + if prop_name in node: + var old_value = node.get(prop_name) + var new_value = _convert_value(properties[prop_name]) + undo_redo.add_do_property(node, prop_name, new_value) + undo_redo.add_undo_property(node, prop_name, old_value) + modified.append(prop_name) + + undo_redo.commit_action() + + return {"result": {"modified": modified, "path": scene_path}} + + +func _convert_value(value): + if value is Dictionary: + var dict: Dictionary = value + + match str(dict.get("_type", "")): + "Vector2": + return Vector2(dict.get("x", 0), dict.get("y", 0)) + "Vector3": + return Vector3(dict.get("x", 0), dict.get("y", 0), dict.get("z", 0)) + "Color": + return Color(dict.get("r", 1), dict.get("g", 1), dict.get("b", 1), dict.get("a", 1)) + + if dict.has("x") and dict.has("y"): + if dict.has("z"): + return Vector3(dict.get("x", 0), dict.get("y", 0), dict.get("z", 0)) + return Vector2(dict.get("x", 0), dict.get("y", 0)) + + if dict.has("r") and dict.has("g") and dict.has("b"): + return Color(dict.get("r", 1), dict.get("g", 1), dict.get("b", 1), dict.get("a", 1)) + + return value + + +func _handle_open_scene(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + if path.is_empty(): + return {"error": {"code": -32602, "message": "Missing path parameter"}} + + editor_interface.open_scene_from_path(path) + return {"result": {"opened": path}} + + +func _handle_save_scene(_params: Dictionary) -> Dictionary: + editor_interface.save_scene() + var edited_scene = editor_interface.get_edited_scene_root() + var path = edited_scene.scene_file_path if edited_scene else "" + return {"result": {"saved": true, "path": path}} + + +func _handle_run_scene(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + if path.is_empty(): + editor_interface.play_current_scene() + else: + editor_interface.play_custom_scene(path) + return {"result": {"running": true}} + + +func _handle_stop_scene(_params: Dictionary) -> Dictionary: + editor_interface.stop_playing_scene() + return {"result": {"stopped": true}} + + +func _handle_get_project_info(_params: Dictionary) -> Dictionary: + return { + "result": { + "name": ProjectSettings.get_setting("application/config/name"), + "path": ProjectSettings.globalize_path("res://"), + "godot_version": Engine.get_version_info().string + } + } + + +func _handle_refresh_filesystem(_params: Dictionary) -> Dictionary: + editor_interface.get_resource_filesystem().scan() + return {"result": {"refreshed": true}} + + +func _handle_runtime_status(_params: Dictionary) -> Dictionary: + return await _send_runtime_request("status", {}) + + +func _handle_runtime_wait(params: Dictionary) -> Dictionary: + var request_params: Dictionary = {} + if params.has("frames"): + request_params["frames"] = int(params.get("frames", 0)) + if params.has("seconds"): + request_params["seconds"] = float(params.get("seconds", 0.0)) + return await _send_runtime_request("wait", request_params) + + +func _handle_runtime_press_action(params: Dictionary) -> Dictionary: + return await _send_runtime_request("press_action", { + "action": str(params.get("action", "")), + "strength": float(params.get("strength", 1.0)) + }) + + +func _handle_runtime_release_action(params: Dictionary) -> Dictionary: + return await _send_runtime_request("release_action", { + "action": str(params.get("action", "")) + }) + + +func _handle_runtime_tap_action(params: Dictionary) -> Dictionary: + return await _send_runtime_request("tap_action", { + "action": str(params.get("action", "")), + "frames": int(params.get("frames", 1)), + "strength": float(params.get("strength", 1.0)) + }) + + +func _handle_runtime_mouse_move(params: Dictionary) -> Dictionary: + return await _send_runtime_request("mouse_move", { + "x": float(params.get("x", 0.0)), + "y": float(params.get("y", 0.0)) + }) + + +func _handle_runtime_click(params: Dictionary) -> Dictionary: + var request_params: Dictionary = { + "button": int(params.get("button", MOUSE_BUTTON_LEFT)), + "holdFrames": int(params.get("holdFrames", 1)) + } + if params.has("x"): + request_params["x"] = float(params.get("x", 0.0)) + if params.has("y"): + request_params["y"] = float(params.get("y", 0.0)) + return await _send_runtime_request("click", request_params) + + +func _handle_runtime_type_text(params: Dictionary) -> Dictionary: + return await _send_runtime_request("type_text", { + "text": str(params.get("text", "")) + }) + + +func _handle_runtime_capture_screenshot(params: Dictionary) -> Dictionary: + return await _send_runtime_request("capture_screenshot", { + "path": str(params.get("path", "")) + }) + + +func _send_runtime_request(method: String, params: Dictionary) -> Dictionary: + if debugger_plugin == null: + return { + "error": { + "code": -32603, + "message": "Runtime automation bridge is unavailable." + } + } + + if not debugger_plugin.has_method("send_runtime_request"): + return { + "error": { + "code": -32603, + "message": "Runtime automation transport is not installed in this AI Bridge build." + } + } + + # A frame wait can run slower than the nominal frame rate. Budget one second + # per requested frame, plus the explicit duration and transport overhead. + var duration := maxf(0.0, float(params.get("seconds", 0.0))) + var frames := maxi(0, int(params.get("frames", params.get("holdFrames", 0)))) + if duration > 3600.0 or frames > 3600: + return {"error": {"code": -32602, "message": "Runtime waits are limited to 3600 seconds and 3600 frames."}} + var timeout := maxf(5.0, duration + float(frames) + 5.0) + var response = await debugger_plugin.send_runtime_request(method, params, timeout) + if response is Dictionary: + return response + + return { + "error": { + "code": -32603, + "message": "Runtime automation returned an unexpected response for %s." % method + } + } + + +func _handle_execute_gdscript(params: Dictionary) -> Dictionary: + var code: String = params.get("code", "") + if code.is_empty(): + return {"error": {"code": -32602, "message": "Missing code parameter"}} + + var log_checkpoint: int = _get_log_checkpoint_line() + var script_source := "extends RefCounted\nfunc __mcp_exec(editor_interface: Variant, message_handler: Variant) -> Variant:\n" + script_source += _indent_code(code) + + var temp_script := GDScript.new() + temp_script.source_code = script_source + var err := temp_script.reload() + if err != OK: + var script_identifier: String = str(temp_script.resource_path) + var diagnostics: Array[String] = _collect_recent_script_diagnostics( + script_identifier, + log_checkpoint, + 8 + ) + var error_message := "Failed to compile script: " + error_string(err) + if not diagnostics.is_empty(): + error_message += "\n" + "\n".join(diagnostics) + log_error({ + "type": "script_error", + "level": "error", + "source": "execute.gdscript", + "message": error_message, + "error": error_string(err), + "script_path": script_identifier + }) + return { + "error": { + "code": -32603, + "message": error_message + } + } + + var runner: Object = temp_script.new() + if runner == null: + var instantiate_error_message := "Failed to instantiate compiled script" + log_error({ + "type": "script_error", + "level": "error", + "source": "execute.gdscript", + "message": instantiate_error_message, + "error": instantiate_error_message + }) + return { + "error": { + "code": -32603, + "message": instantiate_error_message + } + } + + if not runner.has_method("__mcp_exec"): + var missing_method_message := "Compiled script is missing __mcp_exec entrypoint" + log_error({ + "type": "script_error", + "level": "error", + "source": "execute.gdscript", + "message": missing_method_message, + "error": missing_method_message + }) + return { + "error": { + "code": -32603, + "message": missing_method_message + } + } + + var result = runner.call("__mcp_exec", editor_interface, self) + + return {"result": {"executed": true, "result": result}} + + +func _get_log_checkpoint_line() -> int: + var scan_result = _scan_recent_log_entries(1, "all", "") + if scan_result.has("error"): + return 0 + return int(scan_result.get("next_since_line", 0)) + + +func _collect_recent_script_diagnostics( + script_identifier: String, + since_line: int = 0, + max_entries: int = 8 +) -> Array[String]: + var scan_result = _scan_recent_log_entries(300, "error", "", since_line) + if scan_result.has("error"): + var empty: Array[String] = [] + return empty + + var entries: Array = scan_result.get("entries", []) + var targeted: Array[String] = [] + var fallback: Array[String] = [] + + for item in entries: + if not item is Dictionary: + continue + var entry: Dictionary = item + var entry_text := str(entry.get("text", "")).strip_edges() + if entry_text.is_empty(): + continue + + var has_script_ref := entry_text.contains("gdscript://") + var has_parse_signal := ( + entry_text.contains("Parse Error") + or entry_text.contains("Parser Error") + or entry_text.contains("Warning treated as error") + or entry_text.contains("static type") + ) + if not has_script_ref and not has_parse_signal: + continue + + if not script_identifier.is_empty() and entry_text.contains(script_identifier): + targeted.append(entry_text) + elif has_script_ref and has_parse_signal: + fallback.append(entry_text) + + var selected: Array[String] = targeted if not targeted.is_empty() else fallback + var deduped: Array[String] = [] + var seen := {} + for line in selected: + if seen.has(line): + continue + seen[line] = true + deduped.append(line) + + return _take_last_string_entries(deduped, max_entries) + + +func _take_last_string_entries(entries: Array[String], max_count: int) -> Array[String]: + if max_count <= 0: + var empty: Array[String] = [] + return empty + var start_idx: int = max(0, entries.size() - max_count) + var recent: Array[String] = [] + for i in range(start_idx, entries.size()): + recent.append(entries[i]) + return recent + + +func _indent_code(code: String) -> String: + var lines := code.split("\n") + var indented: PackedStringArray = [] + for line in lines: + indented.append("\t" + line) + return "\n".join(indented) + "\n" + + +func _success_response(id, result) -> String: + return JSON.stringify({ + "jsonrpc": "2.0", + "id": id, + "result": result + }) + + +func _error_response(id, code: int, message: String) -> String: + return JSON.stringify({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message + } + }) + + +func _handle_get_errors(params: Dictionary) -> Dictionary: + _ingest_runtime_log() + var errors: Array = [] + var include_runtime: bool = params.get("include_runtime", true) + var include_script: bool = params.get("include_script", true) + var include_log_file: bool = params.get("include_log_file", true) + var severity: String = _normalize_log_level(str(params.get("severity", "all"))) + var query: String = str(params.get("query", "")).strip_edges() + var log_lines: int = int(params.get("log_lines", 200)) + var clear: bool = params.get("clear", false) + var source_counts := {"runtime": 0, "script": 0, "log_file": 0} + + # Add runtime errors from buffer + if include_runtime: + for runtime_error in error_buffer: + if _error_matches(runtime_error, severity, query): + errors.append(runtime_error) + source_counts["runtime"] = int(source_counts["runtime"]) + 1 + + # Get script editor to check for script errors + if include_script: + var script_editor = editor_interface.get_script_editor() + if script_editor: + var open_scripts = script_editor.get_open_scripts() + for script in open_scripts: + if script is GDScript: + # Try to reload and check for errors + var source = script.source_code + var test_script = GDScript.new() + test_script.source_code = source + var err = test_script.reload(false) + if err != OK: + var script_error := { + "path": script.resource_path, + "file": script.resource_path, + "error": error_string(err), + "message": error_string(err), + "type": "script_error", + "level": "error", + "source": "script_editor" + } + if _error_matches(script_error, severity, query): + errors.append(script_error) + source_counts["script"] = int(source_counts["script"]) + 1 + + if include_log_file: + var log_scan = _scan_recent_log_entries(log_lines, severity, query) + if not log_scan.has("error"): + var log_entries: Array = log_scan.get("entries", []) + for log_entry in log_entries: + if not log_entry is Dictionary: + continue + var level: String = str(log_entry.get("level", "info")) + if level != "error" and level != "warning": + continue + var mapped_error := { + "type": level, + "level": level, + "source": "log_file", + "log_file": log_scan.get("log_file", ""), + "line_number": log_entry.get("line_number", 0), + "timestamp": log_entry.get("timestamp", ""), + "message": log_entry.get("text", ""), + } + if _error_matches(mapped_error, severity, query): + errors.append(mapped_error) + source_counts["log_file"] = int(source_counts["log_file"]) + 1 + + errors = _dedupe_error_entries(errors) + + # Clear error buffer if requested + if clear: + error_buffer.clear() + + return { + "result": { + "errors": errors, + "capture": _runtime_capture_status(), + "count": errors.size(), + "runtime_count": error_buffer.size(), + "source_counts": source_counts, + "filters": { + "severity": severity, + "query": query, + "include_runtime": include_runtime, + "include_script": include_script, + "include_log_file": include_log_file + } + } + } + + +func _handle_get_output(params: Dictionary) -> Dictionary: + _ingest_runtime_log() + var lines: int = params.get("lines", 50) + var level: String = _normalize_log_level(str(params.get("level", "all"))) + var source: String = str(params.get("source", "all")).to_lower() + var query: String = str(params.get("query", "")).strip_edges() + var clear: bool = params.get("clear", false) + var include_metadata: bool = params.get("include_metadata", true) + var filtered_output: Array[Dictionary] = [] + + for entry in output_buffer: + if _output_entry_matches(entry, level, source, query): + filtered_output.append(entry) + + var recent_output: Array[Dictionary] = _take_last_entries(filtered_output, lines) + var output_lines: Array[String] = [] + for entry in recent_output: + output_lines.append(str(entry.get("line", entry.get("message", "")))) + + if clear: + output_buffer.clear() + + return { + "result": { + "output": output_lines, + "capture": _runtime_capture_status(), + "entries": recent_output if include_metadata else [], + "total_lines": output_buffer.size(), + "matched_lines": filtered_output.size(), + "returned_lines": output_lines.size(), + "filters": { + "level": level, + "source": source, + "query": query + } + } + } + + +func _handle_get_log_file(params: Dictionary) -> Dictionary: + var lines: int = params.get("lines", 100) + var filter: String = _normalize_log_level(str(params.get("filter", "all"))) + var query: String = str(params.get("query", "")).strip_edges() + var since_line: int = int(params.get("since_line", 0)) + var include_metadata: bool = params.get("include_metadata", true) + var scan_result = _scan_recent_log_entries(lines, filter, query, since_line) + + if scan_result.has("error"): + return {"error": {"code": -32603, "message": str(scan_result.get("error", "Unknown log read error"))}} + + var entries: Array = scan_result.get("entries", []) + var line_text: Array[String] = [] + for entry in entries: + if entry is Dictionary: + line_text.append(str(entry.get("text", ""))) + + return { + "result": { + "lines": line_text, + "entries": entries if include_metadata else [], + "log_file": scan_result.get("log_file", ""), + "total_lines": scan_result.get("total_lines", 0), + "matched_lines": scan_result.get("matched_lines", 0), + "returned_lines": line_text.size(), + "next_since_line": scan_result.get("next_since_line", 0), + "level_counts": scan_result.get("level_counts", {}), + "filters": { + "filter": filter, + "query": query, + "since_line": since_line + } + } + } + + +func _handle_select_node(params: Dictionary) -> Dictionary: + var path: String = params.get("path", "") + if path.is_empty(): + return {"error": {"code": -32602, "message": "Missing path parameter"}} + + var edited_scene = editor_interface.get_edited_scene_root() + if not edited_scene: + return {"error": {"code": -32603, "message": "No scene open"}} + + var node = _resolve_scene_node(path, edited_scene) + if not node: + return {"error": {"code": -32603, "message": "Node not found: " + path}} + + editor_interface.get_selection().clear() + editor_interface.get_selection().add_node(node) + + return {"result": {"selected": _scene_tree_path_for(node, edited_scene)}} + + +func _resolve_scene_node(path: String, edited_scene: Node) -> Node: + if not edited_scene: + return null + + var normalized_path := path.strip_edges() + if normalized_path.is_empty() or normalized_path == ".": + return edited_scene + + if normalized_path == edited_scene.name: + return edited_scene + + var direct = edited_scene.get_node_or_null(normalized_path) + if direct: + return direct + + var root_prefix := edited_scene.name + "/" + if normalized_path.begins_with(root_prefix): + var relative_path := normalized_path.substr(root_prefix.length()) + if relative_path.is_empty(): + return edited_scene + return edited_scene.get_node_or_null(relative_path) + + return null + + +func _scene_tree_path_for(node: Node, edited_scene: Node) -> String: + if not node or not edited_scene: + return "" + + if node == edited_scene: + return edited_scene.name + + var relative_path := str(edited_scene.get_path_to(node)) + if relative_path.is_empty() or relative_path == ".": + return edited_scene.name + + return edited_scene.name + "/" + relative_path + + +func begin_runtime_log() -> void: + _runtime_log_active = true + _runtime_log_cursor = 0 + + +func end_runtime_log() -> void: + _ingest_runtime_log() + _runtime_log_active = false + + +func _runtime_capture_status() -> Dictionary: + var logging_enabled := bool(ProjectSettings.get_setting_with_override("debug/file_logging/enable_logging")) + var log_info := _resolve_latest_log_file_path() + var available := logging_enabled and not log_info.has("error") + return { + "source": "log_file", "active": _runtime_log_active, + "available": available, + "message": "Runtime output is read from the configured log file; debugger stack capture is unavailable." if available else "Runtime log capture unavailable. Enable debug/file_logging/enable_logging and check debug/file_logging/log_path.", + "log_file": log_info.get("path", "") + } + + +func _ingest_runtime_log() -> void: + if not _runtime_log_active or not bool(_runtime_capture_status().get("available", false)): + return + var scan := _scan_recent_log_entries(MAX_OUTPUT_LINES, "all", "", _runtime_log_cursor) + if scan.has("error"): + return + _runtime_log_cursor = int(scan.get("next_since_line", 0)) + for entry in scan.get("entries", []): + var text := str(entry.get("text", "")) + var level := str(entry.get("level", "info")) + log_output(text, level, "log_file") + if level == "error" or level == "warning": + log_error({ + "message": text, "level": level, "type": level, + "source": "log_file", "log_file": scan.get("log_file", ""), + "line_number": entry.get("line_number", 0) + }) + + +func log_output(text: String, level: String = "info", source: String = "runtime") -> void: + var message: String = text.strip_edges() + if message.is_empty(): + return + + var timestamp = Time.get_datetime_string_from_system() + var normalized_level: String = _normalize_log_level(level) + if normalized_level == "all": + normalized_level = _classify_log_level(message) + var line = "[%s] %s" % [timestamp, message] + output_buffer.append({ + "timestamp": timestamp, + "level": normalized_level, + "source": source, + "message": message, + "line": line + }) + + # Keep buffer size limited + while output_buffer.size() > MAX_OUTPUT_LINES: + output_buffer.pop_front() + + +func log_error(error_data: Dictionary) -> void: + var entry := error_data.duplicate(true) + var timestamp = Time.get_datetime_string_from_system() + if not entry.has("timestamp"): + entry["timestamp"] = timestamp + var level: String = _normalize_log_level(str(entry.get("level", entry.get("type", "error")))) + if level == "all": + level = _classify_log_level(str(entry.get("message", ""))) + entry["level"] = level + if not entry.has("type"): + entry["type"] = level + if not entry.has("source"): + entry["source"] = "runtime" + error_buffer.append(entry) + + # Keep buffer size limited + while error_buffer.size() > MAX_ERROR_COUNT: + error_buffer.pop_front() + + +func clear_errors() -> void: + error_buffer.clear() + + +func clear_output() -> void: + output_buffer.clear() + + +func _normalize_log_level(level: String) -> String: + var normalized := level.to_lower().strip_edges() + if normalized.is_empty() or normalized == "all": + return "all" + if LOG_LEVELS.has(normalized): + return normalized + return "all" + + +func _classify_log_level(text: String) -> String: + var lower := text.to_lower() + if ( + lower.contains("error") + or lower.contains("exception") + or lower.contains("failed") + or lower.contains("fatal") + ): + return "error" + if lower.contains("warning") or lower.contains("warn"): + return "warning" + if lower.contains("debug") or lower.contains("[msg:") or lower.contains("[stack]"): + return "debug" + return "info" + + +func _line_matches_filter(line: String, line_level: String, filter_level: String, query: String) -> bool: + if filter_level != "all" and line_level != filter_level: + return false + if not query.is_empty() and not line.to_lower().contains(query.to_lower()): + return false + return true + + +func _extract_timestamp_from_line(line: String) -> String: + var regex := RegEx.new() + var compile_err = regex.compile("^\\[([^\\]]+)\\]") + if compile_err != OK: + return "" + var match = regex.search(line) + if match: + return match.get_string(1) + return "" + + +func _resolve_latest_log_file_path() -> Dictionary: + # Respect the project's configured active log, rather than choosing a stale + # rotated log by modification time. Standard engine output is not delivered + # to EditorDebuggerPlugin._capture; this is an explicit log-file fallback. + var log_path := str(ProjectSettings.get_setting_with_override("debug/file_logging/log_path")) + log_path = ProjectSettings.globalize_path(log_path) + if not FileAccess.file_exists(log_path): + return {"error": "Runtime log unavailable: " + log_path} + return {"path": log_path} + + +func _scan_recent_log_entries( + lines: int, + filter_level: String, + query: String, + since_line: int = 0 +) -> Dictionary: + var log_info = _resolve_latest_log_file_path() + if log_info.has("error"): + return {"error": log_info["error"]} + + var log_path: String = str(log_info.get("path", "")) + var file := FileAccess.open(log_path, FileAccess.READ) + if not file: + return {"error": "Cannot read log file: " + log_path} + + var content := file.get_as_text() + file.close() + + var all_lines := content.replace("\r", "").split("\n") + # split() includes the unfinished final line (empty after a trailing newline). + # Only advance the cursor past complete lines so later appends are not lost. + var complete_lines := maxi(0, all_lines.size() - 1) + if since_line > complete_lines: + since_line = 0 + var matched_entries: Array[Dictionary] = [] + var level_counts := {"debug": 0, "info": 0, "warning": 0, "error": 0} + var normalized_filter: String = _normalize_log_level(filter_level) + + for i in range(complete_lines): + var line_number: int = i + 1 + if since_line > 0 and line_number <= since_line: + continue + + var raw_line: String = all_lines[i].strip_edges() + if raw_line.is_empty(): + continue + + var level := _classify_log_level(raw_line) + if not _line_matches_filter(raw_line, level, normalized_filter, query): + continue + + level_counts[level] = int(level_counts.get(level, 0)) + 1 + matched_entries.append({ + "line_number": line_number, + "level": level, + "text": raw_line, + "timestamp": _extract_timestamp_from_line(raw_line), + }) + + var recent_entries: Array[Dictionary] = _take_last_entries(matched_entries, lines) + + return { + "log_file": log_path, + "entries": recent_entries, + "total_lines": complete_lines, + "matched_lines": matched_entries.size(), + "next_since_line": complete_lines, + "level_counts": level_counts, + } + + +func _take_last_entries(entries: Array[Dictionary], max_count: int) -> Array[Dictionary]: + if max_count <= 0: + var empty: Array[Dictionary] = [] + return empty + var start_idx: int = max(0, entries.size() - max_count) + var recent: Array[Dictionary] = [] + for i in range(start_idx, entries.size()): + recent.append(entries[i]) + return recent + + +func _output_entry_matches( + entry: Dictionary, + level: String, + source: String, + query: String +) -> bool: + var entry_level: String = _normalize_log_level(str(entry.get("level", "info"))) + if level != "all" and entry_level != level: + return false + + var entry_source: String = str(entry.get("source", "runtime")).to_lower() + if source != "all" and source != entry_source: + return false + + if not query.is_empty(): + var haystack := ( + str(entry.get("message", "")) + + " " + + str(entry.get("line", "")) + ).to_lower() + if not haystack.contains(query.to_lower()): + return false + + return true + + +func _error_matches(entry: Dictionary, severity: String, query: String) -> bool: + var level: String = _normalize_log_level(str(entry.get("level", entry.get("type", "error")))) + if level == "all": + level = _classify_log_level(str(entry.get("message", entry.get("error", "")))) + + if severity != "all": + if level != severity: + return false + + if not query.is_empty(): + var haystack := ( + str(entry.get("message", entry.get("error", ""))) + + " " + + str(entry.get("file", entry.get("path", ""))) + + " " + + str(entry.get("function", "")) + + " " + + str(entry.get("source", "")) + ).to_lower() + if not haystack.contains(query.to_lower()): + return false + + return true + + +func _dedupe_error_entries(errors: Array) -> Array: + var deduped: Array = [] + var seen := {} + + for item in errors: + if not item is Dictionary: + continue + var entry: Dictionary = item + var key := "%s|%s|%s|%s|%s|%s" % [ + str(entry.get("source", "")), + str(entry.get("file", entry.get("path", ""))), + str(entry.get("line", entry.get("line_number", ""))), + str(entry.get("function", "")), + str(entry.get("type", entry.get("level", ""))), + str(entry.get("message", entry.get("error", ""))), + ] + if seen.has(key): + continue + seen[key] = true + deduped.append(entry) + + return deduped diff --git a/weltenauge/godot_ai_bridge/plugin.cfg b/weltenauge/godot_ai_bridge/plugin.cfg new file mode 100644 index 0000000..80673ae --- /dev/null +++ b/weltenauge/godot_ai_bridge/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot AI Bridge" +description="WebSocket server enabling AI assistants (like Claude) to control the Godot editor" +author="genai-gametools" +version="0.1.0" +script="godot_ai_bridge.gd" diff --git a/weltenauge/godot_ai_bridge/runtime_bridge.gd b/weltenauge/godot_ai_bridge/runtime_bridge.gd new file mode 100644 index 0000000..e675b1f --- /dev/null +++ b/weltenauge/godot_ai_bridge/runtime_bridge.gd @@ -0,0 +1,344 @@ +extends Node +## Runtime automation harness for the Godot AI Bridge. + +const MESSAGE_PREFIX := "godot_runtime" + +var _capture_callable: Callable +var _mouse_position: Vector2 = Vector2.ZERO +var _mouse_button_mask: int = 0 + + +func _enter_tree() -> void: + if not EngineDebugger.is_active(): + return + + _capture_callable = Callable(self, "_capture_debugger_message") + EngineDebugger.register_message_capture(MESSAGE_PREFIX, _capture_callable) + call_deferred("_announce_ready") + + +func _exit_tree() -> void: + if _capture_callable.is_valid(): + EngineDebugger.unregister_message_capture(MESSAGE_PREFIX) + _capture_callable = Callable() + + +func _announce_ready() -> void: + if EngineDebugger.is_active(): + EngineDebugger.send_message("%s:ready" % MESSAGE_PREFIX, [_build_status_payload()]) + + +func _capture_debugger_message(message: String, data: Array) -> bool: + if message != "request": + return false + + if data.size() < 3: + _send_response(-1, {"success": false, "error": "Invalid runtime request payload."}) + return true + + var request_id := int(data[0]) + var method := str(data[1]) + var params: Dictionary = data[2] if data[2] is Dictionary else {} + + call_deferred("_process_request_async", request_id, method, params) + return true + + +func _process_request_async(request_id: int, method: String, params: Dictionary) -> void: + var response: Dictionary + + match method: + "status": + response = {"success": true, "result": _build_status_payload()} + "wait": + response = {"success": true, "result": await _handle_wait(params)} + "press_action": + response = {"success": true, "result": await _handle_press_action(params)} + "release_action": + response = {"success": true, "result": await _handle_release_action(params)} + "tap_action": + response = {"success": true, "result": await _handle_tap_action(params)} + "mouse_move": + response = {"success": true, "result": await _handle_mouse_move(params)} + "click": + response = {"success": true, "result": await _handle_click(params)} + "type_text": + response = {"success": true, "result": await _handle_type_text(params)} + "capture_screenshot": + response = await _handle_capture_screenshot(params) + _: + response = { + "success": false, + "error": "Unknown runtime automation method: " + method + } + + _send_response(request_id, response) + + +func _handle_wait(params: Dictionary) -> Dictionary: + var frames := max(0, int(params.get("frames", 0))) + var seconds := max(0.0, float(params.get("seconds", 0.0))) + + if frames == 0 and seconds <= 0.0: + frames = 1 + + for _i in range(frames): + await get_tree().process_frame + + if seconds > 0.0: + await get_tree().create_timer(seconds).timeout + + return { + "frames": frames, + "seconds": seconds + } + + +func _handle_press_action(params: Dictionary) -> Dictionary: + var action := _require_non_empty_string(params, "action") + var strength := float(params.get("strength", 1.0)) + _push_action_event(action, true, strength) + await get_tree().process_frame + return { + "action": action, + "pressed": true, + "strength": strength + } + + +func _handle_release_action(params: Dictionary) -> Dictionary: + var action := _require_non_empty_string(params, "action") + _push_action_event(action, false, 0.0) + await get_tree().process_frame + return { + "action": action, + "pressed": false + } + + +func _handle_tap_action(params: Dictionary) -> Dictionary: + var action := _require_non_empty_string(params, "action") + var frames := max(1, int(params.get("frames", 1))) + var strength := float(params.get("strength", 1.0)) + + _push_action_event(action, true, strength) + for _i in range(frames): + await get_tree().process_frame + _push_action_event(action, false, 0.0) + await get_tree().process_frame + + return { + "action": action, + "frames": frames, + "strength": strength + } + + +func _handle_mouse_move(params: Dictionary) -> Dictionary: + var target := _require_position(params) + await _push_mouse_motion(target) + return { + "position": _vector2_dict(_mouse_position) + } + + +func _handle_click(params: Dictionary) -> Dictionary: + var button := int(params.get("button", MOUSE_BUTTON_LEFT)) + var hold_frames := max(1, int(params.get("holdFrames", 1))) + + if params.has("x") or params.has("y"): + var target := _require_position(params) + await _push_mouse_motion(target) + + var click_position := _mouse_position + await _push_mouse_button(click_position, button, true) + for _i in range(hold_frames): + await get_tree().process_frame + await _push_mouse_button(click_position, button, false) + + return { + "button": button, + "hold_frames": hold_frames, + "position": _vector2_dict(click_position) + } + + +func _handle_type_text(params: Dictionary) -> Dictionary: + var text := _require_non_empty_string(params, "text") + var root := _get_root_viewport() + root.push_text_input(text) + await get_tree().process_frame + + return { + "text": text, + "focused_control": _focused_control_path() + } + + +func _handle_capture_screenshot(params: Dictionary) -> Dictionary: + var raw_path := _require_non_empty_string(params, "path") + var output_path := _resolve_output_path(raw_path) + var output_dir := output_path.get_base_dir() + + if not output_dir.is_empty(): + var dir_err := DirAccess.make_dir_recursive_absolute(output_dir) + if dir_err != OK and dir_err != ERR_ALREADY_EXISTS: + return { + "success": false, + "error": "Failed to create screenshot directory: " + output_dir + } + + await RenderingServer.frame_post_draw + + var root := _get_root_viewport() + var image := root.get_texture().get_image() + if image == null: + return { + "success": false, + "error": "Failed to capture screenshot image from the root viewport." + } + + var save_err := image.save_png(output_path) + if save_err != OK: + return { + "success": false, + "error": "Failed to save screenshot: " + error_string(save_err) + } + + return { + "success": true, + "result": { + "path": output_path, + "width": image.get_width(), + "height": image.get_height() + } + } + + +func _push_action_event(action: String, pressed: bool, strength: float) -> void: + var event := InputEventAction.new() + event.action = action + event.pressed = pressed + event.strength = strength if pressed else 0.0 + Input.parse_input_event(event) + + +func _push_mouse_motion(target: Vector2) -> void: + var root := _get_root_viewport() + var event := InputEventMouseMotion.new() + event.position = target + event.global_position = target + event.relative = target - _mouse_position + event.velocity = Vector2.ZERO + event.button_mask = _mouse_button_mask + root.push_input(event, true) + _mouse_position = target + await get_tree().process_frame + + +func _push_mouse_button(target: Vector2, button: int, pressed: bool) -> void: + var root := _get_root_viewport() + var mask := _button_mask_for(button) + var next_mask := _mouse_button_mask + if pressed: + next_mask |= mask + else: + next_mask &= ~mask + + var event := InputEventMouseButton.new() + event.position = target + event.global_position = target + event.button_index = button + event.pressed = pressed + event.button_mask = next_mask + root.push_input(event, true) + + _mouse_button_mask = next_mask + _mouse_position = target + await get_tree().process_frame + + +func _button_mask_for(button: int) -> int: + match button: + MOUSE_BUTTON_LEFT: + return MOUSE_BUTTON_MASK_LEFT + MOUSE_BUTTON_RIGHT: + return MOUSE_BUTTON_MASK_RIGHT + MOUSE_BUTTON_MIDDLE: + return MOUSE_BUTTON_MASK_MIDDLE + MOUSE_BUTTON_XBUTTON1: + return MOUSE_BUTTON_MASK_MB_XBUTTON1 + MOUSE_BUTTON_XBUTTON2: + return MOUSE_BUTTON_MASK_MB_XBUTTON2 + _: + return 0 + + +func _get_root_viewport() -> Viewport: + var tree := get_tree() + if tree == null or tree.root == null: + push_error("[AI Bridge] Runtime automation requires an active scene tree root viewport.") + return null + return tree.root + + +func _build_status_payload() -> Dictionary: + var root := _get_root_viewport() + var current_scene := get_tree().current_scene + var focused_control := _focused_control_path() + + return { + "available": true, + "scene_root": current_scene.name if current_scene else "", + "scene_path": current_scene.scene_file_path if current_scene else "", + "viewport_size": _vector2_dict(root.get_visible_rect().size) if root else _vector2_dict(Vector2.ZERO), + "mouse_position": _vector2_dict(_mouse_position), + "focused_control": focused_control + } + + +func _focused_control_path() -> String: + var root := _get_root_viewport() + if root == null: + return "" + var focused = root.gui_get_focus_owner() + if focused: + return str(focused.get_path()) + return "" + + +func _require_non_empty_string(params: Dictionary, key: String) -> String: + var value := str(params.get(key, "")).strip_edges() + if value.is_empty(): + push_error("[AI Bridge] Missing required runtime parameter: " + key) + return value + + +func _require_position(params: Dictionary) -> Vector2: + var has_x := params.has("x") + var has_y := params.has("y") + if not has_x or not has_y: + push_error("[AI Bridge] Runtime position requires both x and y values.") + return _mouse_position + return Vector2(float(params.get("x", _mouse_position.x)), float(params.get("y", _mouse_position.y))) + + +func _resolve_output_path(raw_path: String) -> String: + var path := raw_path.strip_edges() + if path.begins_with("res://") or path.begins_with("user://"): + return ProjectSettings.globalize_path(path) + if path.begins_with("/") or path.contains(":/"): + return path + return ProjectSettings.globalize_path("res://" + path) + + +func _vector2_dict(value: Vector2) -> Dictionary: + return { + "x": value.x, + "y": value.y + } + + +func _send_response(request_id: int, payload: Dictionary) -> void: + if EngineDebugger.is_active(): + EngineDebugger.send_message("%s:response" % MESSAGE_PREFIX, [request_id, payload]) diff --git a/weltenauge/godot_ai_bridge/ws_server.gd b/weltenauge/godot_ai_bridge/ws_server.gd new file mode 100644 index 0000000..c4eaca4 --- /dev/null +++ b/weltenauge/godot_ai_bridge/ws_server.gd @@ -0,0 +1,112 @@ +@tool +extends Node +## WebSocket server for AI Bridge + +signal message_received(peer_id: int, message: String) +signal client_connected(peer_id: int) +signal client_disconnected(peer_id: int) + +var port: int = 6550 +var pairing_token: String = "" +const PROTOCOL_PREFIX := "godot-ai-bridge." + +var _tcp_server: TCPServer +var _ws_peers: Dictionary = {} # peer_id -> {peer: WebSocketPeer, stream: StreamPeerTCP} +var _timer: Timer + + +func _ready() -> void: + pass + + +func start() -> Error: + if pairing_token.length() != 64: + return ERR_UNAUTHORIZED + _tcp_server = TCPServer.new() + var err = _tcp_server.listen(port, "127.0.0.1") + if err != OK: + return err + + _timer = Timer.new() + _timer.wait_time = 0.016 + _timer.timeout.connect(_poll) + add_child(_timer) + _timer.start() + + print("[AI Bridge] Timer started for polling") + return OK + + +func stop() -> void: + if _timer: + _timer.stop() + _timer.queue_free() + _timer = null + + for peer_id in _ws_peers.keys(): + _ws_peers[peer_id]["peer"].close() + _ws_peers.clear() + + if _tcp_server: + _tcp_server.stop() + _tcp_server = null + + +func _poll() -> void: + # Accept new TCP connections and immediately create WebSocketPeer + while _tcp_server and _tcp_server.is_connection_available(): + var stream = _tcp_server.take_connection() + if stream: + print("[AI Bridge] New TCP connection!") + var ws_peer = WebSocketPeer.new() + ws_peer.supported_protocols = PackedStringArray([PROTOCOL_PREFIX + pairing_token]) + # Let WebSocketPeer handle the handshake itself + var err = ws_peer.accept_stream(stream) + if err == OK: + var peer_id = stream.get_instance_id() + _ws_peers[peer_id] = {"peer": ws_peer, "stream": stream, "connected": false} + print("[AI Bridge] WebSocket peer created, waiting for handshake...") + else: + print("[AI Bridge] Failed to accept stream: ", err) + + # Process WebSocket peers + var to_remove: Array = [] + for peer_id in _ws_peers.keys(): + var data = _ws_peers[peer_id] + var peer: WebSocketPeer = data["peer"] + + peer.poll() + var state = peer.get_ready_state() + + if state == WebSocketPeer.STATE_OPEN: + if peer.get_selected_protocol() != PROTOCOL_PREFIX + pairing_token: + peer.close(1008, "Pairing required") + continue + if not data["connected"]: + data["connected"] = true + print("[AI Bridge] Client connected: ", peer_id) + client_connected.emit(peer_id) + + while peer.get_available_packet_count() > 0: + var packet = peer.get_packet() + var msg = packet.get_string_from_utf8() + message_received.emit(peer_id, msg) + + elif state == WebSocketPeer.STATE_CLOSED: + print("[AI Bridge] Client disconnected: ", peer_id) + to_remove.append(peer_id) + client_disconnected.emit(peer_id) + + for peer_id in to_remove: + _ws_peers.erase(peer_id) + + +func send_message(peer_id: int, message: String) -> Error: + if not _ws_peers.has(peer_id): + return ERR_DOES_NOT_EXIST + + var peer: WebSocketPeer = _ws_peers[peer_id]["peer"] + if peer.get_ready_state() != WebSocketPeer.STATE_OPEN: + return ERR_CONNECTION_ERROR + + return peer.send_text(message)