commit cbce1590f5972353181f96aecf01bc5a6c09db34 Author: hz Date: Mon May 18 13:00:55 2026 -0500 first commit diff --git a/.gitea/workflows/deploy-auth.yml b/.gitea/workflows/deploy-auth.yml new file mode 100644 index 0000000..ddad582 --- /dev/null +++ b/.gitea/workflows/deploy-auth.yml @@ -0,0 +1,85 @@ +name: Deploy Territory Autobattler Auth API + +on: + push: + branches: + - main + workflow_dispatch: {} + +jobs: + deploy: + runs-on: self-hosted + + env: + IMAGE_NAME: territory-autobattler-auth:latest + IMAGE_TAR: /tmp/territory-autobattler-auth.tar + NAMESPACE: territory-autobattler + DEPLOYMENT_NAME: territory-autobattler-auth + NODES: "192.168.86.72 192.168.86.73 192.168.86.74" + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Build Docker image + run: | + cd microservices/AuthApi + docker build -t "${IMAGE_NAME}" . + + - name: Save Docker image to TAR + run: | + docker save "${IMAGE_NAME}" -o "${IMAGE_TAR}" + + - name: Copy TAR to nodes + run: | + for node in ${NODES}; do + echo "Copying image tar to $node ..." + scp -o StrictHostKeyChecking=no "${IMAGE_TAR}" hz@"$node":"${IMAGE_TAR}" + done + + - name: Import image on nodes + run: | + for node in ${NODES}; do + echo "Importing image on $node ..." + ssh -o StrictHostKeyChecking=no hz@"$node" "sudo ctr -n k8s.io images import ${IMAGE_TAR}" + done + + - name: Clean TAR from nodes + run: | + for node in ${NODES}; do + echo "Removing image tar on $node ..." + ssh -o StrictHostKeyChecking=no hz@"$node" "rm -f ${IMAGE_TAR}" + done + + - name: Clean TAR on runner + run: | + rm -f "${IMAGE_TAR}" + + - name: Write kubeconfig from secret + env: + KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }} + run: | + mkdir -p /tmp/kube + printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config + + - name: Create namespace if missing + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl create namespace "${NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f - + + - name: Apply Auth manifests + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl apply -f microservices/AuthApi/k8s/configmap.yaml + kubectl apply -f microservices/AuthApi/k8s/secret.yaml + kubectl apply -f microservices/AuthApi/k8s/deployment.yaml + kubectl apply -f microservices/AuthApi/k8s/service.yaml + + - name: Restart Auth deployment + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl rollout restart deployment/"${DEPLOYMENT_NAME}" -n "${NAMESPACE}" + kubectl rollout status deployment/"${DEPLOYMENT_NAME}" -n "${NAMESPACE}" diff --git a/.gitea/workflows/k8s-smoke-test.yml b/.gitea/workflows/k8s-smoke-test.yml new file mode 100644 index 0000000..feaf051 --- /dev/null +++ b/.gitea/workflows/k8s-smoke-test.yml @@ -0,0 +1,28 @@ +name: k8s smoke test + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + test: + runs-on: self-hosted + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Write kubeconfig from secret + env: + KUBECONFIG_CONTENT: ${{ secrets.KUBECONFIG }} + run: | + mkdir -p /tmp/kube + printf '%s\n' "$KUBECONFIG_CONTENT" > /tmp/kube/config + + - name: Test kubectl connectivity + env: + KUBECONFIG: /tmp/kube/config + run: | + kubectl get nodes --kubeconfig "${KUBECONFIG}" diff --git a/game/territory-autobattler/.editorconfig b/game/territory-autobattler/.editorconfig new file mode 100644 index 0000000..f28239b --- /dev/null +++ b/game/territory-autobattler/.editorconfig @@ -0,0 +1,4 @@ +root = true + +[*] +charset = utf-8 diff --git a/game/territory-autobattler/.gitattributes b/game/territory-autobattler/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/game/territory-autobattler/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/game/territory-autobattler/.gitignore b/game/territory-autobattler/.gitignore new file mode 100644 index 0000000..0af181c --- /dev/null +++ b/game/territory-autobattler/.gitignore @@ -0,0 +1,3 @@ +# Godot 4+ specific ignores +.godot/ +/android/ diff --git a/game/territory-autobattler/addons/godot_mcp/.DS_Store b/game/territory-autobattler/addons/godot_mcp/.DS_Store new file mode 100644 index 0000000..ea0ce1d Binary files /dev/null and b/game/territory-autobattler/addons/godot_mcp/.DS_Store differ diff --git a/game/territory-autobattler/addons/godot_mcp/command_handler.gd b/game/territory-autobattler/addons/godot_mcp/command_handler.gd new file mode 100644 index 0000000..c6c1a7e --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/command_handler.gd @@ -0,0 +1,2582 @@ +# Structure for addons/godot_mcp/command_handler.gd +@tool +extends RefCounted + +var editor_plugin: EditorPlugin = null + +# Initialize with reference to the editor plugin +func set_editor_plugin(plugin): + editor_plugin = plugin + +# Main command handling function +func handle_command(command_type, params): + match command_type: + "GET_SCENE_INFO": + return handle_get_scene_info() + "OPEN_SCENE": + return handle_open_scene(params) + "SAVE_SCENE": + return handle_save_scene() + "CREATE_CHILD_OBJECT": + return handle_create_child_object(params) + "NEW_SCENE": + return handle_new_scene(params) + "CREATE_OBJECT": + return handle_create_object(params) + "DELETE_OBJECT": + return handle_delete_object(params) + "FIND_OBJECTS_BY_NAME": + return handle_find_objects_by_name(params) + "GET_OBJECT_PROPERTIES": + return handle_get_object_properties(params) + "SET_PROPERTY": + return handle_set_property(params) + "SET_COLLISION_SHAPE": + return handle_set_collision_shape(params) + "SET_OBJECT_TRANSFORM": + return handle_set_object_transform(params) + "CREATE_CHILD_OBJECT": + return handle_create_child_object(params) + "GET_ASSET_LIST": + return handle_get_asset_list(params) + "VIEW_SCRIPT": + return handle_view_script(params) + "SET_NESTED_PROPERTY": + return handle_set_nested_property(params) + "SET_PARENT": + return handle_set_parent(params) + "CREATE_SCRIPT": + return handle_create_script(params) + "UPDATE_SCRIPT": + return handle_update_script(params) + "LIST_SCRIPTS": + return handle_list_scripts(params) + "DELETE_SCRIPT": + return handle_delete_script(params) + "DELETE_FILE": + return handle_delete_file(params) + "EDITOR_CONTROL": + return handle_editor_control(params) + "SET_MATERIAL": + return handle_set_material(params) + "IMPORT_ASSET": + return handle_import_asset(params) + "SET_MESH": + return handle_set_mesh(params) + "CREATE_PREFAB": + return handle_create_packed_scene(params) + "INSTANTIATE_PREFAB": + return handle_instantiate_prefab(params) + "SHOW_MESSAGE": + return handle_show_message(params) + "REIMPORT_ASSET": + return handle_reimport_asset(params) + "IMPORT_GLB_SCENE": + return handle_import_glb_scene(params) + _: + return {"error": "Unknown command type: " + command_type} + +# Scene commands +func handle_get_scene_info(): + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + var scene_info = { + "name": current_scene.name, + "path": current_scene.scene_file_path, + "hierarchy": _get_hierarchy_recursive(current_scene), + "root_objects": [] + } + + # Get root-level nodes + for child in current_scene.get_children(): + scene_info.root_objects.append({ + "name": child.name, + "type": child.get_class() + }) + + return scene_info + +# Helper function to recursively build hierarchy +func _get_hierarchy_recursive(node): + var hierarchy = { + "name": node.name, + "type": node.get_class(), + "children": [] + } + + # Add transform info for spatial nodes + if node is Node3D: + hierarchy["transform"] = { + "position": [node.position.x, node.position.y, node.position.z], + "rotation": [node.rotation_degrees.x, node.rotation_degrees.y, node.rotation_degrees.z], + "scale": [node.scale.x, node.scale.y, node.scale.z] + } + elif node is Node2D: + hierarchy["transform"] = { + "position": [node.position.x, node.position.y], + "rotation": node.rotation_degrees, + "scale": [node.scale.x, node.scale.y] + } + + # Add script info if available + if node.get_script(): + hierarchy["script"] = node.get_script().resource_path + + # Add all child nodes recursively + for child in node.get_children(): + hierarchy["children"].append(_get_hierarchy_recursive(child)) + + return hierarchy + +func handle_open_scene(params): + if not params.has("scene_path"): + return {"error": "Missing required parameter: scene_path"} + + var scene_path = params.scene_path + var editor_interface = editor_plugin.get_editor_interface() + + # Check if file exists + if not FileAccess.file_exists(scene_path): + return {"error": "Scene file does not exist: " + scene_path} + + # Save current scene if needed + if params.get("save_current", false): + editor_interface.save_scene() + + # Open the scene + editor_interface.open_scene_from_path(scene_path) + return {"message": "Scene opened successfully: " + scene_path} + +func handle_save_scene(): + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + editor_interface.save_scene() + return {"message": "Scene saved successfully: " + current_scene.scene_file_path} + +func handle_new_scene(params): + if not params.has("scene_path"): + return {"error": "Missing required parameter: scene_path"} + + var scene_path = params.scene_path + var editor_interface = editor_plugin.get_editor_interface() + + # Check if file exists and handle overwrite + if FileAccess.file_exists(scene_path) and not params.get("overwrite", false): + return {"error": "Scene file already exists. Use overwrite=true to replace it."} + + # Create directory if needed + var directory = scene_path.get_base_dir() + if not DirAccess.dir_exists_absolute(directory): + var error = DirAccess.make_dir_recursive_absolute(directory) + if error != OK: + return {"error": "Failed to create directory: " + directory} + + # Create a new scene with a Node as root + var root_node = Node.new() + root_node.name = scene_path.get_file().get_basename() + + # Create a packed scene and save it + var packed_scene = PackedScene.new() + var result = packed_scene.pack(root_node) + if result != OK: + return {"error": "Failed to pack scene: " + str(result)} + + var error = ResourceSaver.save(packed_scene, scene_path) + if error != OK: + return {"error": "Failed to save new scene: " + str(error)} + + # Open the newly created scene + editor_interface.open_scene_from_path(scene_path) + + return {"message": "New scene created successfully: " + scene_path} + +# Object commands +func handle_create_object(params): + var type = params.get("type", "EMPTY") + var name = params.get("name", "") + var location = params.get("location", [0, 0, 0]) + var rotation = params.get("rotation", [0, 0, 0]) + var scale = params.get("scale", [1, 1, 1]) + var replace_if_exists = params.get("replace_if_exists", false) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Check if name already exists and handle replacement + if name != "": + var existing_node = current_scene.find_child(name, true, false) + if existing_node and not replace_if_exists: + return {"error": "Node with name '" + name + "' already exists. Use replace_if_exists=true to replace it."} + elif existing_node and replace_if_exists: + existing_node.queue_free() + + # Create the node based on type (case-insensitive) + var node + var upper_type = type.to_upper() + + # Handle common 3D node types + match upper_type: + "NODE", "EMPTY": + node = Node3D.new() + "NODE3D": + node = Node3D.new() + "SPATIAL": # For compatibility with Godot 3.x terminology + node = Node3D.new() + "MESH", "MESHINSTANCE3D": + node = MeshInstance3D.new() + "CUBE", "BOX": + node = MeshInstance3D.new() + node.mesh = BoxMesh.new() + "SPHERE": + node = MeshInstance3D.new() + node.mesh = SphereMesh.new() + "CYLINDER": + node = MeshInstance3D.new() + node.mesh = CylinderMesh.new() + "PLANE": + node = MeshInstance3D.new() + node.mesh = PlaneMesh.new() + "CAMERA", "CAMERA3D": + node = Camera3D.new() + "LIGHT", "DIRECTIONALLIGHT", "DIRECTIONALLIGHT3D": + node = DirectionalLight3D.new() + "SPOTLIGHT", "SPOTLIGHT3D": + node = SpotLight3D.new() + "OMNILIGHT", "OMNILIGHT3D": + node = OmniLight3D.new() + "RIGIDBODY", "RIGIDBODY3D": + node = RigidBody3D.new() + "STATICBODY", "STATICBODY3D": + node = StaticBody3D.new() + "CHARACTERBODY", "CHARACTERBODY3D": + node = CharacterBody3D.new() + "AREA", "AREA3D": + node = Area3D.new() + "COLLISION", "COLLISIONSHAPE3D": + node = CollisionShape3D.new() + # Add a default sphere shape + node.shape = SphereShape3D.new() + + # Handle common 2D node types + "NODE2D": + node = Node2D.new() + "SPRITE", "SPRITE2D": + node = Sprite2D.new() + "CAMERA2D": + node = Camera2D.new() + "AREA2D": + node = Area2D.new() + "COLLISION2D", "COLLISIONSHAPE2D": + node = CollisionShape2D.new() + # Add a default circle shape + node.shape = CircleShape2D.new() + "RIGIDBODY2D": + node = RigidBody2D.new() + "STATICBODY2D": + node = StaticBody2D.new() + "CHARACTERBODY2D": + node = CharacterBody2D.new() + + # Handle UI node types + "CONTROL": + node = Control.new() + "PANEL": + node = Panel.new() + "BUTTON": + node = Button.new() + "LABEL": + node = Label.new() + "LINEEDIT": + node = LineEdit.new() + "TEXTEDIT": + node = TextEdit.new() + "CONTAINER": + node = Container.new() + "VBOX", "VBOXCONTAINER": + node = VBoxContainer.new() + "HBOX", "HBOXCONTAINER": + node = HBoxContainer.new() + _: + # Try to create the node directly by class name using ClassDB + if ClassDB.class_exists(type) and ClassDB.can_instantiate(type): + node = ClassDB.instantiate(type) + else: + return {"error": "Unsupported object type: " + type} + + # Set node name if provided + if name != "": + node.name = name + + # Add to scene + current_scene.add_child(node) + node.owner = current_scene + + # Set transform for Node3D or Node2D objects + if node is Node3D: + node.position = Vector3(location[0], location[1], location[2]) + node.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2]) + node.scale = Vector3(scale[0], scale[1], scale[2]) + elif node is Node2D: + node.position = Vector2(location[0], location[1]) + node.rotation_degrees = rotation[0] + node.scale = Vector2(scale[0], scale[1]) + + return { + "name": node.name, + "type": node.get_class(), + "path": current_scene.get_path_to(node) + } + + +func handle_delete_object(params): + if not params.has("name"): + return {"error": "Missing required parameter: name"} + + var name_or_path = params.name + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node by name or path + var node = null + + # Check if it's a path (contains /) + if "/" in name_or_path: + # Try direct path lookup first + node = current_scene.get_node_or_null(name_or_path) + + # If that fails, try with leading slash + if not node and not name_or_path.begins_with("/"): + node = current_scene.get_node_or_null("/" + name_or_path) + + # If still not found, try parsing the path components + if not node: + var parts = name_or_path.split("/") + var current = current_scene + + for part in parts: + if part == "": + continue + + var found = false + for child in current.get_children(): + if child.name == part: + current = child + found = true + break + + if not found: + # Try searching recursively for this part + var found_node = current.find_child(part, true, false) + if found_node: + current = found_node + else: + # Part not found, path is invalid + return {"error": "Could not find part '" + part + "' in path: " + name_or_path} + + node = current + else: + # Simple name lookup for non-path names + node = current_scene.find_child(name_or_path, true, false) + + if not node: + return {"error": "Node not found: " + name_or_path} + + # Store the node's path for the response before deleting + var node_path = current_scene.get_path_to(node) + var node_type = node.get_class() + + # Delete the node + node.queue_free() + + return { + "message": "Node deleted: " + name_or_path, + "path": node_path, + "type": node_type + } +#func handle_delete_object(params): + #if not params.has("name"): + #return {"error": "Missing required parameter: name"} + # + #var name = params.name + #var editor_interface = editor_plugin.get_editor_interface() + #var current_scene = editor_interface.get_edited_scene_root() + # + #if current_scene == null: + #return {"error": "No scene is currently open"} + # + ## Find node by name + #var node = current_scene.find_child(name, true, false) + #if not node: + #return {"error": "Node not found: " + name} + # + ## Delete the node + #node.queue_free() + #return {"message": "Node deleted: " + name} + +func handle_find_objects_by_name(params): + if not params.has("name"): + return {"error": "Missing required parameter: name"} + + var search_name = params.name + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + var objects = [] + var nodes = _find_nodes_by_name(current_scene, search_name) + + for node in nodes: + objects.append({ + "name": node.name, + "path": _get_node_path(node), + "type": node.get_class() + }) + + return {"objects": objects} + +# Helper function to find nodes recursively +func _find_nodes_by_name(root, search_name): + var result = [] + + if search_name in root.name: + result.append(root) + + for child in root.get_children(): + result.append_array(_find_nodes_by_name(child, search_name)) + + return result + +# Helper function to get a node's path relative to the scene root +func _get_node_path(node): + var current_scene = editor_plugin.get_editor_interface().get_edited_scene_root() + if current_scene: + return current_scene.get_path_to(node) + return node.get_path() + +func handle_get_object_properties(params): + if not params.has("name"): + return {"error": "Missing required parameter: name"} + + var name_or_path = params.name + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + var node + + # Check if we're dealing with a path or just a name + if "/" in name_or_path: + # It's a path, try to get the node using get_node + node = current_scene.get_node_or_null(name_or_path) + + # If that fails, try with NodePath + if not node: + var node_path = NodePath(name_or_path) + node = current_scene.get_node_or_null(node_path) + + # Try finding from root with a leading slash + if not node and not name_or_path.begins_with("/"): + node = current_scene.get_node_or_null("/" + name_or_path) + else: + # It's just a name, use find_child (which searches recursively) + node = current_scene.find_child(name_or_path, true, false) + + if not node: + return {"error": "Node not found: " + name_or_path} + + # Get properties + var properties = { + "name": node.name, + "type": node.get_class(), + "path": current_scene.get_path_to(node), + "visible": node.visible if "visible" in node else true, + "components": [] + } + + # Handle transform properties for 3D nodes + if node is Node3D: + properties["transform"] = { + "position": [node.position.x, node.position.y, node.position.z], + "rotation": [node.rotation_degrees.x, node.rotation_degrees.y, node.rotation_degrees.z], + "scale": [node.scale.x, node.scale.y, node.scale.z] + } + elif node is Node2D: + properties["transform"] = { + "position": [node.position.x, node.position.y], + "rotation": node.rotation_degrees, + "scale": [node.scale.x, node.scale.y] + } + + # Get node properties and components + if node.get_script(): + properties["components"].append({ + "type": "Script", + "path": node.get_script().resource_path + }) + + # Get children information + properties["children"] = [] + for child in node.get_children(): + properties["children"].append({ + "name": child.name, + "type": child.get_class(), + "path": current_scene.get_path_to(child) + }) + + # Get parent information + var parent = node.get_parent() + if parent and parent != current_scene: + properties["parent"] = { + "name": parent.name, + "type": parent.get_class(), + "path": current_scene.get_path_to(parent) + } + + return properties + +func handle_set_object_transform(params): + if not params.has("name"): + return {"error": "Missing required parameter: name"} + + var name = params.name + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node by name + var node = current_scene.find_child(name, true, false) + if not node: + return {"error": "Node not found: " + name} + + # Check if node is 3D + if not node is Node3D: + return {"error": "Node is not a 3D node: " + name} + + # Set transform properties + if params.has("location"): + var loc = params.location + node.position = Vector3(loc[0], loc[1], loc[2]) + + if params.has("rotation"): + var rot = params.rotation + node.rotation_degrees = Vector3(rot[0], rot[1], rot[2]) + + if params.has("scale"): + var scale = params.scale + node.scale = Vector3(scale[0], scale[1], scale[2]) + + return {"message": "Transform updated for node: " + name} + +# Asset commands +func handle_get_asset_list(params): + var type = params.get("type", "") + var search_pattern = params.get("search_pattern", "*") + var folder = params.get("folder", "res://") + + var assets = [] + var dir = DirAccess.open(folder) + + if dir == null: + return {"error": "Unable to access directory: " + folder} + + dir.list_dir_begin() + var file_name = dir.get_next() + + while file_name != "": + var full_path = folder.path_join(file_name) + + if dir.current_is_dir(): + # Handle directories if needed + pass + else: + # Check if it matches the search pattern + if search_pattern == "*" or search_pattern in file_name: + # Check type if specified + var add_file = true + if type != "": + # Determine file type based on extension + var extension = file_name.get_extension().to_lower() + match type.to_lower(): + "scene": + add_file = extension == "tscn" or extension == "scn" + "script": + add_file = extension == "gd" or extension == "cs" + "texture": + add_file = extension in ["png", "jpg", "jpeg", "webp"] + "material": + add_file = extension == "material" or extension == "tres" + "prefab", "packedscene": + add_file = extension == "tscn" or extension == "scn" + + if add_file: + assets.append({ + "name": file_name, + "path": full_path, + "type": _get_resource_type(full_path) + }) + + file_name = dir.get_next() + + dir.list_dir_end() + return {"assets": assets} + +# Helper function to determine resource type +func _get_resource_type(path): + var extension = path.get_extension().to_lower() + match extension: + "tscn", "scn": + return "PackedScene" + "gd": + return "GDScript" + "cs": + return "CSharpScript" + "png", "jpg", "jpeg", "webp": + return "Texture" + "material", "tres": + return "Material" + "wav", "mp3", "ogg": + return "AudioStream" + _: + return "Resource" + +# Script commands +func handle_view_script(params): + if not params.has("script_path"): + return {"error": "Missing required parameter: script_path"} + + var script_path = params.script_path + var require_exists = params.get("require_exists", true) + + if not FileAccess.file_exists(script_path): + if require_exists: + return {"error": "Script file does not exist: " + script_path} + else: + return {"exists": false, "message": "Script file does not exist: " + script_path} + + var file = FileAccess.open(script_path, FileAccess.READ) + if file == null: + return {"error": "Failed to open script file: " + script_path} + + var content = file.get_as_text() + return {"exists": true, "content": content} + +func handle_create_script(params): + if not params.has("script_name"): + return {"error": "Missing required parameter: script_name"} + + var script_name = params.script_name + var script_type = params.get("script_type", "Node") + var nam = params.get("namespace", "") + var script_folder = params.get("script_folder", "res://scripts") + var overwrite = params.get("overwrite", false) + var content = params.get("content", "") + + # Ensure script has .gd extension + if not script_name.ends_with(".gd"): + script_name += ".gd" + + # Create full script path + var script_path = script_folder.path_join(script_name) + + # Check if directory exists, create if needed + if not DirAccess.dir_exists_absolute(script_folder): + var error = DirAccess.make_dir_recursive_absolute(script_folder) + if error != OK: + return {"error": "Failed to create script directory: " + script_folder} + + # Check if script already exists + if FileAccess.file_exists(script_path) and not overwrite: + return {"error": "Script already exists. Use overwrite=true to replace it."} + + # Create script content if not provided + if content == "": + content = _generate_script_template(script_type, nam) + + # Write script file + var file = FileAccess.open(script_path, FileAccess.WRITE) + if file == null: + return {"error": "Failed to create script file: " + script_path} + + file.store_string(content) + + return {"message": "Script created successfully: " + script_path} + +# Helper function to generate script template +func _generate_script_template(node_type, nam): + var template = "" + + # Add class_name if namespace is provided + if nam != "": + template += "class_name " + nam + "\n\n" + + # Add extends + template += "extends " + node_type + "\n\n" + + # Add basic structure + template += "# Properties\n\n" + template += "# Called when the node enters the scene tree\n" + template += "func _ready():\n" + template += "\tpass\n\n" + template += "# Called every frame\n" + template += "func _process(delta):\n" + template += "\tpass\n" + + return template + +func handle_update_script(params): + if not params.has("script_path") or not params.has("content"): + return {"error": "Missing required parameters: script_path and content"} + + var script_path = params.script_path + var content = params.content + var create_if_missing = params.get("create_if_missing", false) + var create_folder_if_missing = params.get("create_folder_if_missing", false) + + # Check if script exists + if not FileAccess.file_exists(script_path): + if not create_if_missing: + return {"error": "Script file does not exist: " + script_path} + + # Create directory if needed + var directory = script_path.get_base_dir() + if create_folder_if_missing and not DirAccess.dir_exists_absolute(directory): + var dir_error = DirAccess.make_dir_recursive_absolute(directory) + if dir_error != OK: + return {"error": "Failed to create directory: " + directory} + + # Write script content + var file = FileAccess.open(script_path, FileAccess.WRITE) + if file == null: + return {"error": "Failed to open script file for writing: " + script_path} + + file.store_string(content) + + return {"message": "Script updated successfully: " + script_path} + +func handle_list_scripts(params): + var folder_path = params.get("folder_path", "res://") + + var scripts = [] + var dir = DirAccess.open(folder_path) + + if dir == null: + return {"error": "Unable to access directory: " + folder_path} + + dir.list_dir_begin() + var file_name = dir.get_next() + + while file_name != "": + if not dir.current_is_dir(): + var extension = file_name.get_extension().to_lower() + if extension == "gd" or extension == "cs": + scripts.append(folder_path.path_join(file_name)) + + file_name = dir.get_next() + + dir.list_dir_end() + return {"scripts": scripts} + +# Editor control commands +func handle_editor_control(params): + if not params.has("command"): + return {"error": "Missing required parameter: command"} + + var command = params.command + var editor_interface = editor_plugin.get_editor_interface() + + match command: + "PLAY": + editor_interface.play_main_scene() + return {"message": "Started playing the main scene"} + "STOP": + editor_interface.stop_playing_scene() + return {"message": "Stopped playing the scene"} + "SAVE": + editor_interface.save_scene() + return {"message": "Scene saved"} + "READ_CONSOLE": + # Godot doesn't have a direct API for reading console output + return {"message": "Console reading not implemented in Godot"} + _: + return {"error": "Unknown editor command: " + command} + +# Material commands +func handle_set_material(params): + if not params.has("object_name"): + return {"error": "Missing required parameter: object_name"} + + var object_name = params.object_name + var material_name = params.get("material_name", "") + var color = params.get("color", [1.0, 1.0, 1.0, 1.0]) + var create_if_missing = params.get("create_if_missing", true) + + print("handle_set_material called for node: ", object_name, " color: ", color) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node using the improved node lookup + var node = _find_node(current_scene, object_name) + if not node: + return {"error": "Node not found: " + object_name} + + print("Node found: ", node.name, " type: ", node.get_class()) + + # Check if node can have materials + if not (node is MeshInstance3D or node is CSGShape3D): + return {"error": "Node does not support materials: " + object_name} + + var material + + # Create or load material + if material_name != "": + var material_path = "res://materials/" + material_name + ".material" + + if FileAccess.file_exists(material_path): + # Load existing material + material = load(material_path) + elif create_if_missing: + # Create directory if needed + if not DirAccess.dir_exists_absolute("res://materials"): + DirAccess.make_dir_recursive_absolute("res://materials") + + # Create new material + material = StandardMaterial3D.new() + + # Set color + if color.size() >= 3: + var albedo_color = Color(color[0], color[1], color[2]) + if color.size() >= 4: + albedo_color.a = color[3] + material.albedo_color = albedo_color + + # Save material + var error = ResourceSaver.save(material, material_path) + if error != OK: + return {"error": "Failed to save material: " + str(error)} + else: + return {"error": "Material not found and create_if_missing is false"} + else: + # Create instance material + material = StandardMaterial3D.new() + + # Set color + if color.size() >= 3: + var albedo_color = Color(color[0], color[1], color[2]) + if color.size() >= 4: + albedo_color.a = color[3] + material.albedo_color = albedo_color + + # Apply material + if node is MeshInstance3D: + node.material_override = material + elif node is CSGShape3D: + node.material = material + + if material_name != "": + return { + "material_name": material_name, + "path": "res://materials/" + material_name + ".material", + "message": "Applied shared material to " + object_name + } + else: + return { + "material_name": "instance_material", + "message": "Applied instance material to " + object_name + } + +# Asset import +func handle_import_asset(params): + if not params.has("source_path") or not params.has("target_path"): + return {"error": "Missing required parameters: source_path and target_path"} + + var source_path = params.source_path + var target_path = params.target_path + var overwrite = params.get("overwrite", false) + + # Ensure target_path starts with res:// + if not target_path.begins_with("res://"): + target_path = "res://" + target_path + + # Check if source file exists + if not FileAccess.file_exists(source_path): + return {"error": "Source file does not exist: " + source_path} + + # Check if target file exists + if FileAccess.file_exists(target_path) and not overwrite: + return {"error": "Target file already exists. Use overwrite=true to replace it."} + + # Create target directory if needed + var target_dir = target_path.get_base_dir() + if not DirAccess.dir_exists_absolute(target_dir): + var dir_error = DirAccess.make_dir_recursive_absolute(target_dir) + if dir_error != OK: + return {"error": "Failed to create target directory: " + target_dir} + + # Copy the file + var source_file = FileAccess.open(source_path, FileAccess.READ) + if source_file == null: + return {"error": "Failed to open source file: " + source_path} + + var target_file = FileAccess.open(target_path, FileAccess.WRITE) + if target_file == null: + return {"error": "Failed to create target file: " + target_path} + + target_file.store_buffer(source_file.get_buffer(source_file.get_length())) + + return { + "success": true, + "message": "Asset imported successfully: " + target_path + } + +func handle_create_packed_scene(params): + """Create a packed scene (prefab) from an existing node.""" + if not params.has("object_name") or not params.has("prefab_path"): + return {"error": "Missing required parameters: object_name and prefab_path"} + + var object_name = params.object_name + var prefab_path = params.prefab_path + var overwrite = params.get("overwrite", false) + + # Ensure prefab_path starts with res:// + if not prefab_path.begins_with("res://"): + prefab_path = "res://" + prefab_path + + # Ensure it has .tscn extension + if not prefab_path.ends_with(".tscn"): + prefab_path += ".tscn" + + # Check if file exists and handle overwrite + if FileAccess.file_exists(prefab_path) and not overwrite: + return {"error": "Packed scene file already exists. Use overwrite=true to replace it."} + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node by name + var node = current_scene.find_child(object_name, true, false) + if not node: + return {"error": "Node not found: " + object_name} + + # Create directory if needed + var directory = prefab_path.get_base_dir() + if not DirAccess.dir_exists_absolute(directory): + var dir_error = DirAccess.make_dir_recursive_absolute(directory) + if dir_error != OK: + return {"error": "Failed to create directory: " + directory} + + # Create packed scene + var packed_scene = PackedScene.new() + var result = packed_scene.pack(node) + if result != OK: + return {"error": "Failed to pack scene: " + str(result)} + + # Save packed scene + result = ResourceSaver.save(packed_scene, prefab_path) + if result != OK: + return {"error": "Failed to save packed scene: " + str(result)} + + return { + "success": true, + "path": prefab_path, + "message": "Packed scene created successfully from " + object_name + } + +func handle_instantiate_prefab(params): + """Instantiate a packed scene (prefab) into the current scene.""" + if not params.has("prefab_path"): + return {"error": "Missing required parameter: prefab_path"} + + var prefab_path = params.prefab_path + var position_x = params.get("position_x", 0.0) + var position_y = params.get("position_y", 0.0) + var position_z = params.get("position_z", 0.0) + var rotation_x = params.get("rotation_x", 0.0) + var rotation_y = params.get("rotation_y", 0.0) + var rotation_z = params.get("rotation_z", 0.0) + + # Ensure prefab_path starts with res:// + if not prefab_path.begins_with("res://"): + prefab_path = "res://" + prefab_path + + # Check if file exists + if not FileAccess.file_exists(prefab_path): + return {"error": "Packed scene file does not exist: " + prefab_path} + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Load the packed scene + var scene_resource = load(prefab_path) + if not scene_resource is PackedScene: + return {"error": "Failed to load packed scene: " + prefab_path} + + # Instantiate the scene + var instance = scene_resource.instantiate() + if not instance: + return {"error": "Failed to instantiate packed scene"} + + # Add to the current scene + current_scene.add_child(instance) + instance.owner = current_scene + + # Set transform if it's a spatial node + if instance is Node3D: + instance.position = Vector3(position_x, position_y, position_z) + instance.rotation_degrees = Vector3(rotation_x, rotation_y, rotation_z) + + return { + "success": true, + "instance_name": instance.name, + "message": "Packed scene instantiated successfully" + } +func handle_set_property(params): + if not params.has("node_name") or not params.has("property_name") or not params.has("value"): + return {"error": "Missing required parameters: node_name, property_name, and value"} + + var node_name = params.node_name + var property_name = params.property_name + var value = params.value + var force_type = params.get("force_type", "") + + print("handle_set_property called with node_name: ", node_name, " property_name: ", property_name, " value: ", value) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node by name or path + var node = null + + # Check if it's a path (contains /) + if "/" in node_name: + # Try direct path lookup first + node = current_scene.get_node_or_null(node_name) + if not node: + # Try as relative path + if not node_name.begins_with("/"): + node = current_scene.get_node_or_null("/" + node_name) + else: + # Simple name lookup + node = current_scene.find_child(node_name, true, false) + + if node: + print("Node found: ", node.name, " of type: ", node.get_class()) + else: + print("Node not found: ", node_name) + return {"error": "Node not found: " + node_name} + + # Handle special properties + if property_name == "script": + # Check if script file exists + if not FileAccess.file_exists(value): + return {"error": "Script file not found: " + value} + + # Try to load the script + var script_resource = load(value) + if not script_resource: + return {"error": "Failed to load script: " + value} + + if not script_resource is GDScript: + return {"error": "Resource is not a GDScript: " + value} + + # Set the script + node.set_script(script_resource) + return {"message": "Script set on node '" + node_name + "': " + value} + + # Handle other special properties + if property_name == "visible": + node.visible = bool(value) + return {"message": "Property set: visible = " + str(bool(value))} + + if property_name == "name": + # Check if name is already taken + var existing = current_scene.find_child(str(value), true, false) + if existing != null and existing != node: + return {"error": "Name already in use by another node"} + node.name = str(value) + return {"message": "Node renamed to: " + str(value)} + + # Handle property paths (e.g., "position:x") + var parts = property_name.split(":") + if parts.size() == 2: + var base_property = parts[0] + var sub_property = parts[1] + + if base_property in node: + var base_value = node.get(base_property) + + # Handle different vector types + if base_value is Vector2 or base_value is Vector3 or base_value is Color: + if sub_property in ["x", "y", "z", "r", "g", "b", "a"]: + # Safety check for Vector2 which doesn't have z + if base_value is Vector2 and sub_property == "z": + return {"error": "Vector2 doesn't have a z component"} + + # Safety check for components that don't exist + if sub_property == "z" and not (base_value is Vector3): + return {"error": "Property doesn't have a z component"} + + if sub_property in ["r", "g", "b", "a"] and not (base_value is Color): + return {"error": "Only Color has r, g, b, a components"} + + # Convert value to float for vector components + var float_value + if typeof(value) == TYPE_STRING: + float_value = float(value) + else: + float_value = float(value) + + # Set the appropriate component + match sub_property: + "x": + base_value.x = float_value + "y": + base_value.y = float_value + "z": + base_value.z = float_value + "r": + base_value.r = float_value + "g": + base_value.g = float_value + "b": + base_value.b = float_value + "a": + base_value.a = float_value + + # Set the modified vector back to the node + node.set(base_property, base_value) + return {"message": "Property set: " + property_name + " = " + str(float_value)} + + return {"error": "Unable to set sub-property: " + property_name} + + # Handle regular properties + if not property_name in node: + return {"error": "Property not found on node: " + property_name} + + # Get the current property value to determine its type + var current_value = node.get(property_name) + print("Current value type: ", typeof(current_value)) + + var converted_value = value + + # If force_type is specified, use that for conversion + if force_type != "": + match force_type: + "bool": + if typeof(value) == TYPE_STRING: + converted_value = (value.to_lower() == "true") + else: + converted_value = bool(value) + "int": + converted_value = int(value) + "float": + converted_value = float(value) + "string": + converted_value = str(value) + "Vector2": + if typeof(value) == TYPE_ARRAY and value.size() >= 2: + converted_value = Vector2(float(value[0]), float(value[1])) + "Vector3": + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + converted_value = Vector3(float(value[0]), float(value[1]), float(value[2])) + "Color": + if typeof(value) == TYPE_ARRAY: + if value.size() >= 4: + converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3])) + elif value.size() >= 3: + converted_value = Color(float(value[0]), float(value[1]), float(value[2])) + else: + # Try to convert the value to match the current property type + match typeof(current_value): + TYPE_BOOL: + if typeof(value) == TYPE_STRING: + converted_value = (value.to_lower() == "true") + else: + converted_value = bool(value) + TYPE_INT: + converted_value = int(value) + TYPE_FLOAT: + converted_value = float(value) + TYPE_STRING: + converted_value = str(value) + TYPE_VECTOR2: + if typeof(value) == TYPE_ARRAY and value.size() >= 2: + converted_value = Vector2(float(value[0]), float(value[1])) + TYPE_VECTOR3: + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + converted_value = Vector3(float(value[0]), float(value[1]), float(value[2])) + TYPE_COLOR: + if typeof(value) == TYPE_ARRAY: + if value.size() >= 4: + converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3])) + elif value.size() >= 3: + converted_value = Color(float(value[0]), float(value[1]), float(value[2])) + + print("Converted value: ", converted_value, " of type: ", typeof(converted_value)) + + # Try to set the property + var previous_value = node.get(property_name) + + # Use set to safely call the setter + var error = null + node.set(property_name, converted_value) + + # Check if the property actually changed + var new_value = node.get(property_name) + # Handle type-safe comparison - don't compare different types directly + var values_different = false + if typeof(new_value) == typeof(converted_value): + values_different = (new_value != converted_value) + else: + # Different types means they're different values + values_different = true + + if new_value == previous_value and values_different: + return {"warning": "Property might be read-only or conversion failed: " + property_name, "previous_value": previous_value, "attempted_value": converted_value} + + return {"message": "Property set: " + property_name + " = " + str(converted_value)} + + +#func handle_set_property(params): + #if not params.has("node_name") or not params.has("property_name") or not params.has("value"): + #return {"error": "Missing required parameters: node_name, property_name, and value"} + # + #var node_name = params.node_name + #var property_name = params.property_name + #var value = params.value + #var force_type = params.get("force_type", "") + # + #var editor_interface = editor_plugin.get_editor_interface() + #var current_scene = editor_interface.get_edited_scene_root() + # + #if current_scene == null: + #return {"error": "No scene is currently open"} + # + ## Find node by name + #var node = current_scene.find_child(node_name, true, false) + #if not node: + #return {"error": "Node not found: " + node_name} + # + ## Handle special properties + #if property_name == "script": + ## Check if script file exists + #if not FileAccess.file_exists(value): + #return {"error": "Script file not found: " + value} + # + ## Try to load the script + #var script_resource = load(value) + #if not script_resource: + #return {"error": "Failed to load script: " + value} + # + #if not script_resource is GDScript: + #return {"error": "Resource is not a GDScript: " + value} + # + ## Set the script + #node.set_script(script_resource) + #return {"message": "Script set on node '" + node_name + "': " + value} + # + ## Handle other special properties + #if property_name == "visible": + #node.visible = bool(value) + #return {"message": "Property set: visible = " + str(bool(value))} + # + #if property_name == "name": + ## Check if name is already taken + #var existing = current_scene.find_child(str(value), true, false) + #if existing != null and existing != node: + #return {"error": "Name already in use by another node"} + #node.name = str(value) + #return {"message": "Node renamed to: " + str(value)} + # + ## Handle property paths (e.g., "position:x") + #var parts = property_name.split(":") + #if parts.size() == 2: + #var base_property = parts[0] + #var sub_property = parts[1] + # + #if base_property in node: + #var base_value = node.get(base_property) + # + ## Handle different vector types + #if base_value is Vector2 or base_value is Vector3 or base_value is Color: + #if sub_property in ["x", "y", "z", "r", "g", "b", "a"]: + ## Safety check for Vector2 which doesn't have z + #if base_value is Vector2 and sub_property == "z": + #return {"error": "Vector2 doesn't have a z component"} + # + ## Safety check for components that don't exist + #if sub_property == "z" and not (base_value is Vector3): + #return {"error": "Property doesn't have a z component"} + # + #if sub_property in ["r", "g", "b", "a"] and not (base_value is Color): + #return {"error": "Only Color has r, g, b, a components"} + # + ## Convert value to float for vector components + #var float_value + #if typeof(value) == TYPE_STRING: + #float_value = float(value) + #else: + #float_value = float(value) + # + ## Set the appropriate component + #match sub_property: + #"x": base_value.x = float_value + #"y": base_value.y = float_value + #"z": base_value.z = float_value + #"r": base_value.r = float_value + #"g": base_value.g = float_value + #"b": base_value.b = float_value + #"a": base_value.a = float_value + # + ## Set the modified vector back to the node + #node.set(base_property, base_value) + #return {"message": "Property set: " + property_name + " = " + str(float_value)} + # + #return {"error": "Unable to set sub-property: " + property_name} + # + ## Handle regular properties + #if not property_name in node: + #return {"error": "Property not found on node: " + property_name} + # + ## Get the current property value to determine its type + #var current_value = node.get(property_name) + #var converted_value = value + # + ## If force_type is specified, use that for conversion + #if force_type != "": + #match force_type: + #"bool": + #if typeof(value) == TYPE_STRING: + #converted_value = (value.to_lower() == "true") + #else: + #converted_value = bool(value) + #"int": + #converted_value = int(value) + #"float": + #converted_value = float(value) + #"string": + #converted_value = str(value) + #"Vector2": + #if typeof(value) == TYPE_ARRAY and value.size() >= 2: + #converted_value = Vector2(float(value[0]), float(value[1])) + #"Vector3": + #if typeof(value) == TYPE_ARRAY and value.size() >= 3: + #converted_value = Vector3(float(value[0]), float(value[1]), float(value[2])) + #"Color": + #if typeof(value) == TYPE_ARRAY: + #if value.size() >= 4: + #converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3])) + #elif value.size() >= 3: + #converted_value = Color(float(value[0]), float(value[1]), float(value[2])) + #else: + ## Try to convert the value to match the current property type + #match typeof(current_value): + #TYPE_BOOL: + #if typeof(value) == TYPE_STRING: + #converted_value = (value.to_lower() == "true") + #else: + #converted_value = bool(value) + #TYPE_INT: + #converted_value = int(value) + #TYPE_FLOAT: + #converted_value = float(value) + #TYPE_STRING: + #converted_value = str(value) + #TYPE_VECTOR2: + #if typeof(value) == TYPE_ARRAY and value.size() >= 2: + #converted_value = Vector2(float(value[0]), float(value[1])) + #TYPE_VECTOR3: + #if typeof(value) == TYPE_ARRAY and value.size() >= 3: + #converted_value = Vector3(float(value[0]), float(value[1]), float(value[2])) + #TYPE_COLOR: + #if typeof(value) == TYPE_ARRAY: + #if value.size() >= 4: + #converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3])) + #elif value.size() >= 3: + #converted_value = Color(float(value[0]), float(value[1]), float(value[2])) + # + ## Try to set the property + #var previous_value = node.get(property_name) + # + ## Use callv to safely call the setter + #node.set(property_name, converted_value) + # + ## Check if the property actually changed + #var new_value = node.get(property_name) + #if new_value == previous_value and new_value != converted_value: + #return {"warning": "Property might be read-only or conversion failed: " + property_name, "previous_value": previous_value, "attempted_value": converted_value} + # + #return {"message": "Property set: " + property_name + " = " + str(converted_value)} + +func handle_set_parent(params): + if not params.has("child_name") or not params.has("parent_name"): + return {"error": "Missing required parameters: child_name and parent_name"} + + var child_name = params.child_name + var parent_name = params.parent_name + var keep_global_transform = params.get("keep_global_transform", true) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find child node (search the entire scene) + var child_node = current_scene.find_child(child_name, true, false) + if not child_node: + return {"error": "Child node not found: " + child_name} + + # Find parent node (search the entire scene) + var parent_node + if parent_name == "root": + parent_node = current_scene + else: + parent_node = current_scene.find_child(parent_name, true, false) + + if not parent_node: + return {"error": "Parent node not found: " + parent_name} + + # Store the global transform if needed + var global_transform = null + if child_node is Node3D and keep_global_transform: + global_transform = child_node.global_transform + elif child_node is Node2D and keep_global_transform: + global_transform = child_node.global_transform + + # Get the original parent + var original_parent = child_node.get_parent() + if original_parent: + # Remove from original parent + original_parent.remove_child(child_node) + + # Add to new parent + parent_node.add_child(child_node) + + # Ensure ownership is set correctly + child_node.owner = current_scene + + # Set all children's owner recursively + _set_owner_recursive(child_node, current_scene) + + # Restore global transform if needed + if child_node is Node3D and global_transform and keep_global_transform: + child_node.global_transform = global_transform + elif child_node is Node2D and global_transform and keep_global_transform: + child_node.global_transform = global_transform + + return {"message": "Set parent of '" + child_name + "' to '" + parent_name + "'"} + +# Helper function to set owner recursively +func _set_owner_recursive(node, owner): + for child in node.get_children(): + child.owner = owner + _set_owner_recursive(child, owner) + + + + +func handle_create_child_object(params): + """Create a new object as a child of an existing node.""" + if not params.has("parent_name"): + return {"error": "Missing required parameter: parent_name"} + + var parent_name = params.parent_name + var type = params.get("type", "EMPTY") + var name = params.get("name", "") + var location = params.get("location", [0, 0, 0]) + var rotation = params.get("rotation", [0, 0, 0]) + var scale = params.get("scale", [1, 1, 1]) + var replace_if_exists = params.get("replace_if_exists", false) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find parent node with proper path handling + var parent_node = null + + if parent_name == "root": + parent_node = current_scene + else: + # Check if it's a path (contains /) + if "/" in parent_name: + # Try direct path lookup first + parent_node = current_scene.get_node_or_null(parent_name) + + # If that fails, try with leading slash + if not parent_node and not parent_name.begins_with("/"): + parent_node = current_scene.get_node_or_null("/" + parent_name) + + # If still not found, try parsing the path components + if not parent_node: + var parts = parent_name.split("/") + var current = current_scene + + for part in parts: + if part == "": + continue + + var found = false + for child in current.get_children(): + if child.name == part: + current = child + found = true + break + + if not found: + # Try searching recursively for this part + var found_node = current.find_child(part, true, false) + if found_node: + current = found_node + else: + # Part not found, path is invalid + return {"error": "Could not find part '" + part + "' in path: " + parent_name} + + parent_node = current + else: + # Simple name lookup for non-path names + parent_node = current_scene.find_child(parent_name, true, false) + + if not parent_node: + return {"error": "Parent node not found: " + parent_name} + + # Check if name already exists and handle replacement + if name != "": + var existing_node = current_scene.find_child(name, true, false) + if existing_node and not replace_if_exists: + return {"error": "Node with name '" + name + "' already exists. Use replace_if_exists=true to replace it."} + elif existing_node and replace_if_exists: + existing_node.queue_free() + + # Create the node based on type + var node + var upper_type = type.to_upper() + + # Handle common 3D node types + match upper_type: + "NODE", "EMPTY": + node = Node3D.new() + "NODE3D": + node = Node3D.new() + "SPATIAL": # For compatibility with Godot 3.x terminology + node = Node3D.new() + "MESH", "MESHINSTANCE3D": + node = MeshInstance3D.new() + "CUBE", "BOX": + node = MeshInstance3D.new() + node.mesh = BoxMesh.new() + "SPHERE": + node = MeshInstance3D.new() + node.mesh = SphereMesh.new() + "CYLINDER": + node = MeshInstance3D.new() + node.mesh = CylinderMesh.new() + "PLANE": + node = MeshInstance3D.new() + node.mesh = PlaneMesh.new() + "CAMERA", "CAMERA3D": + node = Camera3D.new() + "LIGHT", "DIRECTIONALLIGHT", "DIRECTIONALLIGHT3D": + node = DirectionalLight3D.new() + "SPOTLIGHT", "SPOTLIGHT3D": + node = SpotLight3D.new() + "OMNILIGHT", "OMNILIGHT3D": + node = OmniLight3D.new() + "RIGIDBODY", "RIGIDBODY3D": + node = RigidBody3D.new() + "STATICBODY", "STATICBODY3D": + node = StaticBody3D.new() + "CHARACTERBODY", "CHARACTERBODY3D": + node = CharacterBody3D.new() + "AREA", "AREA3D": + node = Area3D.new() + "COLLISION", "COLLISIONSHAPE3D": + node = CollisionShape3D.new() + # Add a default sphere shape + node.shape = SphereShape3D.new() + + # Handle common 2D node types + "NODE2D": + node = Node2D.new() + "SPRITE", "SPRITE2D": + node = Sprite2D.new() + "CAMERA2D": + node = Camera2D.new() + "AREA2D": + node = Area2D.new() + "COLLISION2D", "COLLISIONSHAPE2D": + node = CollisionShape2D.new() + # Add a default circle shape + node.shape = CircleShape2D.new() + "RIGIDBODY2D": + node = RigidBody2D.new() + "STATICBODY2D": + node = StaticBody2D.new() + "CHARACTERBODY2D": + node = CharacterBody2D.new() + + # Handle UI node types + "CONTROL": + node = Control.new() + "PANEL": + node = Panel.new() + "BUTTON": + node = Button.new() + "LABEL": + node = Label.new() + "LINEEDIT": + node = LineEdit.new() + "TEXTEDIT": + node = TextEdit.new() + "CONTAINER": + node = Container.new() + "VBOX", "VBOXCONTAINER": + node = VBoxContainer.new() + "HBOX", "HBOXCONTAINER": + node = HBoxContainer.new() + _: + # Try to create the node directly by class name using ClassDB + if ClassDB.class_exists(type) and ClassDB.can_instantiate(type): + node = ClassDB.instantiate(type) + else: + return {"error": "Unsupported object type: " + type} + + # Set node name if provided + if name != "": + node.name = name + + # Add to parent node directly + parent_node.add_child(node) + node.owner = current_scene + + # Recursively set ownership for all children + _set_owner_recursive(node, current_scene) + + # Set transform for Node3D or Node2D objects + if node is Node3D: + node.position = Vector3(location[0], location[1], location[2]) + node.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2]) + node.scale = Vector3(scale[0], scale[1], scale[2]) + elif node is Node2D: + node.position = Vector2(location[0], location[1]) + node.rotation_degrees = rotation[0] + node.scale = Vector2(scale[0], scale[1]) + + # In the JSON output, include the full path to the parent to help with debugging + var parent_path = current_scene.get_path_to(parent_node) + + return { + "name": node.name, + "type": node.get_class(), + "path": current_scene.get_path_to(node), + "parent": parent_node.name, + "parent_path": parent_path + } + + + + + + + + + + +#func handle_create_child_object(params): + #"""Create a new object as a child of an existing node.""" + #if not params.has("parent_name"): + #return {"error": "Missing required parameter: parent_name"} + # + #var parent_name = params.parent_name + #var type = params.get("type", "EMPTY") + #var name = params.get("name", "") + #var location = params.get("location", [0, 0, 0]) + #var rotation = params.get("rotation", [0, 0, 0]) + #var scale = params.get("scale", [1, 1, 1]) + #var replace_if_exists = params.get("replace_if_exists", false) + # + #var editor_interface = editor_plugin.get_editor_interface() + #var current_scene = editor_interface.get_edited_scene_root() + # + #if current_scene == null: + #return {"error": "No scene is currently open"} + # + ## Find parent node + #var parent_node + #if parent_name == "root": + #parent_node = current_scene + #else: + #parent_node = current_scene.find_child(parent_name, true, false) + # + #if not parent_node: + #return {"error": "Parent node not found: " + parent_name} + # + ## Check if name already exists and handle replacement + #if name != "": + #var existing_node = current_scene.find_child(name, true, false) + #if existing_node and not replace_if_exists: + #return {"error": "Node with name '" + name + "' already exists. Use replace_if_exists=true to replace it."} + #elif existing_node and replace_if_exists: + #existing_node.queue_free() + # + ## Create the node based on type (same logic as handle_create_object) + #var node + #var upper_type = type.to_upper() + # + ## Handle common 3D node types + #match upper_type: + #"NODE", "EMPTY": + #node = Node3D.new() + ## (rest of the type matching code from handle_create_object) + ## ... + #_: + ## Try to create the node directly by class name using ClassDB + #if ClassDB.class_exists(type) and ClassDB.can_instantiate(type): + #node = ClassDB.instantiate(type) + #else: + #return {"error": "Unsupported object type: " + type} + # + ## Set node name if provided + #if name != "": + #node.name = name + # + ## Add to parent node directly + #parent_node.add_child(node) + #node.owner = current_scene + # + ## Set transform for Node3D or Node2D objects + #if node is Node3D: + #node.position = Vector3(location[0], location[1], location[2]) + #node.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2]) + #node.scale = Vector3(scale[0], scale[1], scale[2]) + #elif node is Node2D: + #node.position = Vector2(location[0], location[1]) + #node.rotation_degrees = rotation[0] + #node.scale = Vector2(scale[0], scale[1]) + # + #return { + #"name": node.name, + #"type": node.get_class(), + #"path": current_scene.get_path_to(node), + #"parent": parent_name + #} + +func handle_set_mesh(params): + """Create and set a mesh on a MeshInstance3D node.""" + if not params.has("node_name") or not params.has("mesh_type"): + return {"error": "Missing required parameters: node_name and mesh_type"} + + var node_name = params.node_name + var mesh_type = params.mesh_type + + print("handle_set_mesh called for node: ", node_name, " mesh_type: ", mesh_type) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node using the improved node lookup + var node = _find_node(current_scene, node_name) + if not node: + return {"error": "Node not found: " + node_name} + + print("Node found: ", node.name, " type: ", node.get_class()) + + # Check if the node can have a mesh property + if not node is MeshInstance3D: + return {"error": "Node is not a MeshInstance3D: " + node_name} + + # Create mesh based on type + var mesh + match mesh_type.to_upper(): + "CAPSULEMESH": + mesh = CapsuleMesh.new() + if params.has("radius"): + mesh.radius = float(params.radius) + if params.has("height"): + mesh.height = float(params.height) + "BOXMESH": + mesh = BoxMesh.new() + if params.has("size"): + var size = params.size + mesh.size = Vector3(float(size[0]), float(size[1]), float(size[2])) + "SPHEREMESH": + mesh = SphereMesh.new() + if params.has("radius"): + mesh.radius = float(params.radius) + "CYLINDERMESH": + mesh = CylinderMesh.new() + if params.has("radius"): + mesh.radius = float(params.radius) + if params.has("height"): + mesh.height = float(params.height) + "PLANEMESH": + mesh = PlaneMesh.new() + if params.has("size"): + var size = params.size + mesh.size = Vector2(float(size[0]), float(size[1])) + _: + return {"error": "Unsupported mesh type: " + mesh_type} + + # Set the mesh + node.mesh = mesh + + return {"message": "Set " + mesh_type + " on " + node_name} + + +func _find_node(root, name_or_path): + # Check if it's a path (contains /) + if "/" in name_or_path: + # Try direct path lookup first + var node = root.get_node_or_null(name_or_path) + if node: + return node + + # Try as relative path + if not name_or_path.begins_with("/"): + node = root.get_node_or_null("/" + name_or_path) + if node: + return node + + # Try all combinations of path separators + var parts = name_or_path.split("/") + var current = root + + for part in parts: + # Look for the next part in current node's children + var found = false + for child in current.get_children(): + if child.name == part: + current = child + found = true + break + + if not found: + # Try searching recursively + var found_node = current.find_child(part, true, false) + if found_node: + current = found_node + else: + return null + + return current + else: + # Simple name lookup + return root.find_child(name_or_path, true, false) + + +func handle_set_collision_shape(params): + """Create and set a collision shape on a CollisionShape3D or CollisionShape2D node.""" + if not params.has("node_name") or not params.has("shape_type"): + return {"error": "Missing required parameters: node_name and shape_type"} + + var node_name = params.node_name + var shape_type = params.shape_type + var shape_params = params.get("shape_params", {}) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node by name or path + var node = _find_node(current_scene, node_name) + if not node: + return {"error": "Node not found: " + node_name} + + # Check if the node can have a shape property + if not (node is CollisionShape3D or node is CollisionShape2D): + return {"error": "Node is not a CollisionShape: " + node_name} + + # Create shape based on type + var shape + match shape_type.to_upper(): + "CAPSULESHAPE3D": + shape = CapsuleShape3D.new() + if shape_params.has("radius"): + shape.radius = float(shape_params.radius) + if shape_params.has("height"): + shape.height = float(shape_params.height) + "BOXSHAPE3D": + shape = BoxShape3D.new() + if shape_params.has("size"): + var size = shape_params.size + shape.size = Vector3(float(size[0]), float(size[1]), float(size[2])) + "SPHERESHAPE3D": + shape = SphereShape3D.new() + if shape_params.has("radius"): + shape.radius = float(shape_params.radius) + "CYLINDERSHAPE3D": + shape = CylinderShape3D.new() + if shape_params.has("radius"): + shape.radius = float(shape_params.radius) + if shape_params.has("height"): + shape.height = float(shape_params.height) + "WORLDBOUNDARYSHAPE3D": + shape = WorldBoundaryShape3D.new() + # 2D Shapes + "CIRCLESHAPE2D": + shape = CircleShape2D.new() + if shape_params.has("radius"): + shape.radius = float(shape_params.radius) + "RECTANGLESHAPE2D": + shape = RectangleShape2D.new() + if shape_params.has("size"): + var size = shape_params.size + shape.size = Vector2(float(size[0]), float(size[1])) + "CAPSULESHAPE2D": + shape = CapsuleShape2D.new() + if shape_params.has("radius"): + shape.radius = float(shape_params.radius) + if shape_params.has("height"): + shape.height = float(shape_params.height) + _: + return {"error": "Unsupported shape type: " + shape_type} + + # Set the shape + node.shape = shape + + return {"message": "Set " + shape_type + " on " + node_name} + + +#func handle_set_nested_property(params): + #"""Set a nested property like environment/sky/sky_material on a node.""" + #if not params.has("node_name") or not params.has("property_name") or not params.has("value"): + #return {"error": "Missing required parameters: node_name, property_name, and value"} + # + #var node_name = params.node_name + #var property_path = params.property_name + #var value = params.value + #var value_type = params.get("value_type", "") + # + #var editor_interface = editor_plugin.get_editor_interface() + #var current_scene = editor_interface.get_edited_scene_root() + # + #if current_scene == null: + #return {"error": "No scene is currently open"} + # + ## Find node by name or path + #var node = null + # + ## Check if it's a path (contains /) + #if "/" in node_name: + ## Try direct path lookup first + #node = current_scene.get_node_or_null(node_name) + # + ## If that fails, try with leading slash + #if not node and not node_name.begins_with("/"): + #node = current_scene.get_node_or_null("/" + node_name) + # + ## Try other lookup methods if needed... + #else: + ## Simple name lookup for non-path names + #node = current_scene.find_child(node_name, true, false) + # + #if not node: + #return {"error": "Node not found: " + node_name} + # + ## Split the property path + #var property_parts = property_path.split("/") + # + ## Handle special cases for common node types + #if node is WorldEnvironment: + ## Make sure the environment exists + #if not node.environment: + #node.environment = Environment.new() + # + ## Handle environment properties + #if property_parts.size() >= 1 and property_parts[0] == "environment": + #var env = node.environment + # + ## Handle special case for sky material + #if property_parts.size() >= 3 and property_parts[1] == "sky" and property_parts[2] == "sky_material": + ## Make sure sky exists + #if not env.sky: + #env.sky = Sky.new() + # + #if property_parts.size() == 3: + ## Create the material based on type + #var material = null + #if value == "ProceduralSkyMaterial": + #material = ProceduralSkyMaterial.new() + #elif value == "PanoramaSkyMaterial": + #material = PanoramaSkyMaterial.new() + #elif value == "PhysicalSkyMaterial": + #material = PhysicalSkyMaterial.new() + #else: + #return {"error": "Unknown sky material type: " + str(value)} + # + ## Set the sky material + #env.sky.sky_material = material + #return {"message": "Set sky material to " + str(value)} + # + ## Handle sky material properties (e.g., environment/sky/sky_material/sky_top_color) + #elif property_parts.size() >= 4 and env.sky.sky_material: + #var material = env.sky.sky_material + #var mat_prop = property_parts[3] + # + ## Verify property exists on the material + #if not mat_prop in material: + #return {"error": "Property not found on sky material: " + mat_prop} + # + ## Handle color properties + #if typeof(material.get(mat_prop)) == TYPE_COLOR: + #if typeof(value) == TYPE_ARRAY and value.size() >= 3: + #if value.size() >= 4: + #material.set(mat_prop, Color(value[0], value[1], value[2], value[3])) + #else: + #material.set(mat_prop, Color(value[0], value[1], value[2])) + #return {"message": "Set sky material property " + mat_prop + " to " + str(value)} + #else: + ## Set other property types + #material.set(mat_prop, value) + #return {"message": "Set sky material property " + mat_prop + " to " + str(value)} + # + ## Handle direct environment properties + #elif property_parts.size() == 2: + #var prop = property_parts[1] + # + ## Map common property names to actual Godot property names + #match prop: + #"background_color": + #prop = "background_color" + #"ambient_light_color": + #prop = "ambient_light_color" + #"fog_color": + #prop = "fog_color" + #"background_mode": + #prop = "background_mode" + ## Add more mappings as needed + # + ## Verify property exists + #if not prop in env: + #var available_props = [] + #for p in ["background_color", "ambient_light_color", "fog_color", "background_mode", + #"fog_enabled", "fog_density", "glow_enabled", "glow_intensity", + #"adjustment_enabled", "tonemap_mode"]: + #if p in env: + #available_props.append(p) + #return {"error": "Property not found on environment: " + prop + + #". Available properties include: " + str(available_props)} + # + ## Convert value based on property type + #var converted_value = value + #var current_value = env.get(prop) + # + ## Handle different types + #if typeof(current_value) == TYPE_COLOR: + #if typeof(value) == TYPE_ARRAY and value.size() >= 3: + #if value.size() >= 4: + #converted_value = Color(value[0], value[1], value[2], value[3]) + #else: + #converted_value = Color(value[0], value[1], value[2]) + #elif typeof(current_value) == TYPE_BOOL: + #if typeof(value) == TYPE_STRING: + #converted_value = (value.to_lower() == "true") + #else: + #converted_value = bool(value) + #elif typeof(current_value) == TYPE_INT: + #converted_value = int(value) + #elif typeof(current_value) == TYPE_FLOAT: + #converted_value = float(value) + # + ## Set the property + #env.set(prop, converted_value) + #return {"message": "Set environment property " + prop + " to " + str(converted_value)} + # + ## Handle environment sub-objects (e.g., environment/fog/enabled) + #elif property_parts.size() == 3: + #var sub_obj = property_parts[1] + #var sub_prop = property_parts[2] + # + ## Map common property paths + #if sub_obj == "fog" and sub_prop == "enabled": + #env.fog_enabled = bool(value) + #return {"message": "Set fog_enabled to " + str(bool(value))} + #elif sub_obj == "glow" and sub_prop == "enabled": + #env.glow_enabled = bool(value) + #return {"message": "Set glow_enabled to " + str(bool(value))} + #elif sub_obj == "ambient_light" and sub_prop == "color": + #if typeof(value) == TYPE_ARRAY and value.size() >= 3: + #env.ambient_light_color = Color(value[0], value[1], value[2]) + #return {"message": "Set ambient_light_color to " + str(value)} + # + ## Generic property mapping attempt + #var full_prop = sub_obj + "_" + sub_prop + #if full_prop in env: + #env.set(full_prop, value) + #return {"message": "Set " + full_prop + " to " + str(value)} + # + #return {"error": "Unsupported environment property path: " + property_path} + # + ## Handle other node types here + ## ... + # + #return {"error": "Unsupported nested property path: " + property_path} + + +func handle_set_nested_property(params): + """Set a nested property like environment/sky/sky_material on a node.""" + if not params.has("node_name") or not params.has("property_name") or not params.has("value"): + return {"error": "Missing required parameters: node_name, property_name, and value"} + + var node_name = params.node_name + var property_path = params.property_name + var value = params.value + var value_type = params.get("value_type", "") + + print("Setting nested property: ", property_path, " to ", value, " on ", node_name) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Find node by name or path + var node = null + + # Check if it's a path (contains /) + if "/" in node_name: + # Try direct path lookup first + node = current_scene.get_node_or_null(node_name) + + # If that fails, try with leading slash + if not node and not node_name.begins_with("/"): + node = current_scene.get_node_or_null("/" + node_name) + + # Try parsing the path manually if needed + if not node: + # Try other path resolution methods... + pass + else: + # Simple name lookup for non-path names + node = current_scene.find_child(node_name, true, false) + + if not node: + return {"error": "Node not found: " + node_name} + + # Split the property path + var property_parts = property_path.split("/") + + # Handle WorldEnvironment properties + if node is WorldEnvironment: + return _handle_worldenvironment_properties(node, property_parts, value) + + # Add other node type handlers here + + # Default case for simple properties + return {"error": "Unsupported node type for nested properties: " + node.get_class()} + +func _handle_worldenvironment_properties(node, property_parts, value): + """Handle nested properties for WorldEnvironment nodes.""" + # Make sure the environment exists + if not node.environment: + node.environment = Environment.new() + + # Get environment resource for easier access + var env = node.environment + + # First level should be "environment" + if property_parts.size() < 1 or property_parts[0] != "environment": + return {"error": "WorldEnvironment properties must start with 'environment/'"} + + # Handle sky material properties (4-part paths) + if property_parts.size() == 4 and property_parts[1] == "sky" and property_parts[2] == "sky_material": + # Make sure sky exists + if not env.sky: + env.sky = Sky.new() + + # Make sure sky material exists + if not env.sky.sky_material: + env.sky.sky_material = ProceduralSkyMaterial.new() + + var material = env.sky.sky_material + var property_name = property_parts[3] + + # Debug info + print("Trying to set sky material property: ", property_name) + print("Material class: ", material.get_class()) + + # Direct property handling for cloud properties + if property_name == "use_clouds" or property_name == "clouds_enabled": + # Enable/disable clouds directly + material.use_clouds = bool(value) + return {"message": "Set use_clouds to " + str(bool(value))} + + elif property_name == "cloud_color": + # Set cloud color directly + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + material.cloud_color = Color(float(value[0]), float(value[1]), float(value[2])) + return {"message": "Set cloud_color to " + str(material.cloud_color)} + + elif property_name == "cloud_coverage": + # Set cloud coverage directly + material.cloud_coverage = float(value) + return {"message": "Set cloud_coverage to " + str(float(value))} + + elif property_name == "cloud_size": + # Set cloud size directly + material.cloud_size = float(value) + return {"message": "Set cloud_size to " + str(float(value))} + + # Handle other properties with the property map + var property_map = { + # Sky colors + "sky_top_color": "sky_top_color", + "sky_horizon_color": "sky_horizon_color", + "sky_curve": "sky_curve", + "ground_horizon_color": "ground_horizon_color", + "ground_bottom_color": "ground_bottom_color", + "ground_curve": "ground_curve", + + # Sun properties + "sun_angle_max": "sun_angle_max", + "sun_curve": "sun_curve" + } + + # Check if property name needs to be mapped + if property_name in property_map: + property_name = property_map[property_name] + + # Try to set the property with property-specific type conversion + var current_value = material.get(property_name) + var converted_value = value + + # Handle different property types + match typeof(current_value): + TYPE_BOOL: + converted_value = bool(value) + TYPE_INT: + converted_value = int(value) + TYPE_FLOAT: + converted_value = float(value) + TYPE_COLOR: + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + if value.size() >= 4: + converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3])) + else: + converted_value = Color(float(value[0]), float(value[1]), float(value[2])) + + # Set the property + material.set(property_name, converted_value) + return {"message": "Set sky material property " + property_name + " to " + str(converted_value)} + + return {"error": "Unknown sky material property: " + property_name + ". Available cloud properties: use_clouds, cloud_color, cloud_coverage, cloud_size"} + + # Handle sky material type (3-part paths) + elif property_parts.size() == 3 and property_parts[1] == "sky" and property_parts[2] == "sky_material": + # Make sure sky exists + if not env.sky: + env.sky = Sky.new() + + # Create the material based on type + var material = null + + if value == "ProceduralSkyMaterial": + material = ProceduralSkyMaterial.new() + elif value == "PanoramaSkyMaterial": + material = PanoramaSkyMaterial.new() + elif value == "PhysicalSkyMaterial": + material = PhysicalSkyMaterial.new() + else: + return {"error": "Unknown sky material type: " + str(value)} + + # Set the sky material + env.sky.sky_material = material + return {"message": "Set sky material to " + str(value)} + + # Handle direct environment properties (2-part paths) + elif property_parts.size() == 2: + var property_name = property_parts[1] + + # Map common property name variations to actual property names + var property_map = { + "background": "background_mode", + "ambient_light_color": "ambient_light_color", + "background_color": "background_color", + "fog_enabled": "fog_enabled", + "glow_enabled": "glow_enabled", + "glow_intensity": "glow_intensity", + "fog_density": "fog_density", + "fog_color": "fog_color", + "volumetric_fog_enabled": "volumetric_fog_enabled", + "volumetric_fog_density": "volumetric_fog_density", + "volumetric_fog_albedo": "volumetric_fog_albedo" + } + + # Check if we need to map the property name + if property_name in property_map: + property_name = property_map[property_name] + + # Verify property exists + if not property_name in env: + return {"error": "Property not found on environment: " + property_name} + + # Convert value based on property type + var converted_value = value + var current_value = env.get(property_name) + + # Handle different property types + match typeof(current_value): + TYPE_BOOL: + converted_value = bool(value) + TYPE_INT: + converted_value = int(value) + TYPE_FLOAT: + converted_value = float(value) + TYPE_COLOR: + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + if value.size() >= 4: + converted_value = Color(float(value[0]), float(value[1]), float(value[2]), float(value[3])) + else: + converted_value = Color(float(value[0]), float(value[1]), float(value[2])) + + # Set the property + env.set(property_name, converted_value) + return {"message": "Set environment property " + property_name + " to " + str(converted_value)} + + # Handle fog properties (3-part paths) + elif property_parts.size() == 3 and property_parts[1] == "fog": + var prop = property_parts[2] + + if prop == "enabled": + env.fog_enabled = bool(value) + return {"message": "Set fog_enabled to " + str(bool(value))} + elif prop == "density": + env.fog_density = float(value) + return {"message": "Set fog_density to " + str(float(value))} + elif prop == "color": + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + env.fog_color = Color(float(value[0]), float(value[1]), float(value[2])) + return {"message": "Set fog_color to " + str(env.fog_color)} + else: + # Try a direct combined property name + var combined_prop = "fog_" + prop + if combined_prop in env: + env.set(combined_prop, value) + return {"message": "Set " + combined_prop + " to " + str(value)} + return {"error": "Unknown fog property: " + prop} + + # Handle volumetric fog properties (3-part paths) + elif property_parts.size() == 3 and property_parts[1] == "volumetric_fog": + var prop = property_parts[2] + + if prop == "enabled": + env.volumetric_fog_enabled = bool(value) + return {"message": "Set volumetric_fog_enabled to " + str(bool(value))} + elif prop == "density": + env.volumetric_fog_density = float(value) + return {"message": "Set volumetric_fog_density to " + str(float(value))} + elif prop == "albedo": + if typeof(value) == TYPE_ARRAY and value.size() >= 3: + env.volumetric_fog_albedo = Color(float(value[0]), float(value[1]), float(value[2])) + return {"message": "Set volumetric_fog_albedo to " + str(env.volumetric_fog_albedo)} + else: + # Try a direct combined property name + var combined_prop = "volumetric_fog_" + prop + if combined_prop in env: + env.set(combined_prop, value) + return {"message": "Set " + combined_prop + " to " + str(value)} + return {"error": "Unknown volumetric fog property: " + prop} + + # Other environment properties can be added here + + return {"error": "Unsupported property path: " + property_parts.join("/")} + +# Add the following function: +func handle_delete_script(params): + if not params.has("script_path"): + return {"error": "Missing required parameter: script_path"} + + var script_path = params.script_path + + # Check if file exists + if not FileAccess.file_exists(script_path): + return {"error": "Script file does not exist: " + script_path} + + # Delete the file + var error = DirAccess.remove_absolute(script_path) + if error != OK: + return {"error": "Failed to delete script file: " + script_path + " (Error code: " + str(error) + ")"} + + return { + "message": "Script deleted successfully: " + script_path + } + +# Add the following function near the handle_delete_script function: +func handle_delete_file(params): + if not params.has("file_path"): + return {"error": "Missing required parameter: file_path"} + + var file_path = params.file_path + + # Check if file exists + if not FileAccess.file_exists(file_path): + return {"error": "File does not exist: " + file_path} + + # Delete the file + var error = DirAccess.remove_absolute(file_path) + if error != OK: + return {"error": "Failed to delete file: " + file_path + " (Error code: " + str(error) + ")"} + + return { + "message": "File deleted successfully: " + file_path + } + +func handle_show_message(params): + if not params.has("message"): + return {"error": "Missing required parameter: message"} + + var message = params.message + + # Show a message dialog + editor_plugin.get_editor_interface().show_message_notification(message) + + return {"message": "Message shown successfully"} + +func handle_reimport_asset(params): + if not params.has("asset_path"): + return {"error": "Missing required parameter: asset_path"} + + var asset_path = params.asset_path + + # Force a filesystem scan to detect the new file + var editor_filesystem = editor_plugin.get_editor_interface().get_resource_filesystem() + if editor_filesystem: + editor_filesystem.scan() + # Also scan the specific directory + var dir_path = asset_path.get_base_dir() + editor_filesystem.scan_sources() + + return { + "message": "Triggered filesystem scan for: " + asset_path + } + else: + return {"error": "Could not access editor filesystem"} + +func handle_import_glb_scene(params): + if not params.has("glb_path"): + return {"error": "Missing required parameter: glb_path"} + + var glb_path = params.glb_path + var name = params.get("name", "") + var position = params.get("position", [0, 0, 0]) + var rotation = params.get("rotation", [0, 0, 0]) + var scale = params.get("scale", [1, 1, 1]) + + var editor_interface = editor_plugin.get_editor_interface() + var current_scene = editor_interface.get_edited_scene_root() + + if current_scene == null: + return {"error": "No scene is currently open"} + + # Check if the GLB file exists + if not FileAccess.file_exists(glb_path): + return {"error": "GLB file not found: " + glb_path} + + # Try to load the GLB as a PackedScene + var glb_scene = load(glb_path) + if not glb_scene: + return {"error": "Failed to load GLB file: " + glb_path} + + # Check if it's a PackedScene + if glb_scene is PackedScene: + # Instantiate the packed scene + var instance = glb_scene.instantiate() + if not instance: + return {"error": "Failed to instantiate GLB scene"} + + # Set the name + if name != "": + instance.name = name + else: + # Use filename without extension + var filename = glb_path.get_file().get_basename() + instance.name = filename + + # Add to current scene + current_scene.add_child(instance) + instance.owner = current_scene + + # Set ownership recursively for all children + _set_owner_recursive(instance, current_scene) + + # Set transform if it's a 3D node + if instance is Node3D: + instance.position = Vector3(position[0], position[1], position[2]) + instance.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2]) + instance.scale = Vector3(scale[0], scale[1], scale[2]) + + return { + "success": true, + "instance_name": instance.name, + "message": "GLB scene imported successfully as: " + instance.name + } + else: + # If it's not a PackedScene, try to create a MeshInstance3D + var node_name = name if name != "" else glb_path.get_file().get_basename() + + # Create a MeshInstance3D + var mesh_instance = MeshInstance3D.new() + mesh_instance.name = node_name + + # Try to set the mesh + if glb_scene is Mesh: + mesh_instance.mesh = glb_scene + else: + # Try loading it differently + return {"error": "GLB file is not a PackedScene or Mesh resource"} + + # Add to scene + current_scene.add_child(mesh_instance) + mesh_instance.owner = current_scene + + # Set transform + mesh_instance.position = Vector3(position[0], position[1], position[2]) + mesh_instance.rotation_degrees = Vector3(rotation[0], rotation[1], rotation[2]) + mesh_instance.scale = Vector3(scale[0], scale[1], scale[2]) + + return { + "success": true, + "instance_name": mesh_instance.name, + "message": "GLB imported as MeshInstance3D: " + mesh_instance.name + } diff --git a/game/territory-autobattler/addons/godot_mcp/command_handler.gd.uid b/game/territory-autobattler/addons/godot_mcp/command_handler.gd.uid new file mode 100644 index 0000000..17838dd --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/command_handler.gd.uid @@ -0,0 +1 @@ +uid://mcipi7y664aq diff --git a/game/territory-autobattler/addons/godot_mcp/plugin.cfg b/game/territory-autobattler/addons/godot_mcp/plugin.cfg new file mode 100644 index 0000000..a382373 --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Godot MCP" +description="A plugin to enable communication between Godot Editor and Model Context Protocol (MCP) clients." +author="Your Name" +version="0.1.0" +script="plugin.gd" diff --git a/game/territory-autobattler/addons/godot_mcp/plugin.gd b/game/territory-autobattler/addons/godot_mcp/plugin.gd new file mode 100644 index 0000000..ba238eb --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/plugin.gd @@ -0,0 +1,140 @@ +# Structure for addons/godot_mcp/plugin.gd +@tool +extends EditorPlugin + +const SERVER_PORT = 6400 +var server: TCPServer = null +var active_connections = [] +var command_handler + +func _enter_tree(): + # Initialize the plugin + print("Godot MCP Plugin activated") + + # Create command handler + command_handler = preload("res://addons/godot_mcp/command_handler.gd").new() + command_handler.set_editor_plugin(self) + + # Start the TCP server + server = TCPServer.new() + var error = server.listen(SERVER_PORT) + if error != OK: + push_error("Failed to start Godot MCP Server on port %d: %s" % [SERVER_PORT, error]) + return + + print("Godot MCP Server listening on port %d" % SERVER_PORT) + + # Add UI + add_control_to_bottom_panel( + preload("res://addons/godot_mcp/ui/mcp_panel.tscn").instantiate(), + "MCP" + ) + +func _exit_tree(): + # Clean up the plugin when disabled + if server: + server.stop() + server = null + + for connection in active_connections: + if connection.get_status() == StreamPeerTCP.STATUS_CONNECTED: + connection.disconnect_from_host() + + active_connections.clear() + + # Remove UI + remove_control_from_bottom_panel(get_editor_interface().get_base_control().get_node("MCPPanel")) + print("Godot MCP Plugin deactivated") + +func _process(delta): + # Check for new connections + if server and server.is_connection_available(): + var connection = server.take_connection() + if connection: + active_connections.append(connection) + print("New MCP connection established") + + # Process existing connections + var i = 0 + while i < active_connections.size(): + var connection = active_connections[i] + + # Check connection status + if connection.get_status() != StreamPeerTCP.STATUS_CONNECTED: + active_connections.remove_at(i) + print("MCP connection closed") + continue + + # Check for incoming messages + if connection.get_available_bytes() > 0: + var data = _read_message(connection) + if data.size() > 0: + # Process the command + var response = _process_command(data) + + # Send the response + _send_message(connection, response) + + i += 1 + +func _read_message(connection): + # Read data from the connection + var data = PackedByteArray() + var bytes_available = connection.get_available_bytes() + + if bytes_available > 0: + data = connection.get_data(bytes_available)[1] + + # Attempt to parse as JSON + var json_string = data.get_string_from_utf8() + var json = JSON.new() + var error = json.parse(json_string) + + if error == OK: + return json.get_data() + else: + print("Failed to parse JSON: ", json.get_error_message()) + + return {} + +func _send_message(connection, data): + # Convert to JSON and send + var json_string = JSON.stringify(data) + connection.put_data(json_string.to_utf8_buffer()) + + + +func _process_command(data): + # Process the command and return a response + if data == null or typeof(data) != TYPE_DICTIONARY: + return { + "status": "error", + "error": "Invalid command format. Expected a dictionary." + } + + if not data.has("type") or not data.has("params"): + return { + "status": "error", + "error": "Invalid command format. Expected 'type' and 'params' fields." + } + + var command_type = data["type"] + var params = data["params"] + + if command_type == "ping": + return {"status": "success", "result": {"message": "pong"}} + + # Forward to command handler + var result = command_handler.handle_command(command_type, params) + + # Check if result is valid + if result == null: + return { + "status": "error", + "error": "Command handler returned null result" + } + + if result.has("error"): + return {"status": "error", "error": result.error} + else: + return {"status": "success", "result": result} diff --git a/game/territory-autobattler/addons/godot_mcp/plugin.gd.uid b/game/territory-autobattler/addons/godot_mcp/plugin.gd.uid new file mode 100644 index 0000000..9df341b --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/plugin.gd.uid @@ -0,0 +1 @@ +uid://ddfhkiyge45tn diff --git a/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.gd b/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.gd new file mode 100644 index 0000000..c9a17d9 --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.gd @@ -0,0 +1,77 @@ +# addons/godot_mcp/ui/mcp_panel.gd +@tool +extends Control + +var status_label: Label +var port_field: SpinBox +var start_button: Button +var stop_button: Button +var log_display: TextEdit + +func _ready(): + # Set up references to UI elements + status_label = $VBoxContainer/StatusPanel/StatusLabel + port_field = $VBoxContainer/ConfigPanel/PortField + start_button = $VBoxContainer/ButtonPanel/StartButton + stop_button = $VBoxContainer/ButtonPanel/StopButton + log_display = $VBoxContainer/LogPanel/LogDisplay + + # Initialize UI + port_field.value = 6400 # Default port + start_button.disabled = false + stop_button.disabled = true + + # Connect signals + start_button.pressed.connect(_on_start_button_pressed) + stop_button.pressed.connect(_on_stop_button_pressed) + + # Set initial status + update_status("Not running") + add_log_message("Godot MCP Plugin initialized") + +func update_status(status_text: String, is_error: bool = false): + status_label.text = "Status: " + status_text + if is_error: + status_label.add_theme_color_override("font_color", Color(1, 0.3, 0.3)) + else: + status_label.remove_theme_color_override("font_color") + +func add_log_message(message: String): + var timestamp = Time.get_datetime_string_from_system() + log_display.text += "[" + timestamp + "] " + message + "\n" + log_display.scroll_vertical = log_display.get_line_count() + +func _on_start_button_pressed(): + # This function will be called from the plugin.gd script + # when the server is actually started + update_status("Running on port " + str(port_field.value)) + start_button.disabled = true + stop_button.disabled = false + add_log_message("MCP Server started on port " + str(port_field.value)) + +func _on_stop_button_pressed(): + # This function will be called from the plugin.gd script + # when the server is actually stopped + update_status("Stopped") + start_button.disabled = false + stop_button.disabled = true + add_log_message("MCP Server stopped") + +# Function to be called from plugin.gd when a client connects +func on_client_connected(): + add_log_message("Client connected") + update_status("Client connected") + +# Function to be called from plugin.gd when a client disconnects +func on_client_disconnected(): + add_log_message("Client disconnected") + update_status("Running (no clients)") + +# Function to be called from plugin.gd when a command is received +func on_command_received(command_type, params): + add_log_message("Command received: " + command_type) + +# Function to be called from plugin.gd when a response is sent +func on_response_sent(command_type, success): + var status = "Success" if success else "Failed" + add_log_message("Response sent for " + command_type + ": " + status) \ No newline at end of file diff --git a/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.gd.uid b/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.gd.uid new file mode 100644 index 0000000..e80c22f --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.gd.uid @@ -0,0 +1 @@ +uid://bqabyd7ce60u0 diff --git a/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.tscn b/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.tscn new file mode 100644 index 0000000..38c38f8 --- /dev/null +++ b/game/territory-autobattler/addons/godot_mcp/ui/mcp_panel.tscn @@ -0,0 +1,69 @@ +[gd_scene load_steps=2 format=3 uid="uid://dxvt86ck6b2a4"] + +[ext_resource type="Script" path="res://addons/godot_mcp/ui/mcp_panel.gd" id="1_4g23r"] + +[node name="MCPPanel" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_4g23r") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="StatusPanel" type="PanelContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="StatusLabel" type="Label" parent="VBoxContainer/StatusPanel"] +layout_mode = 2 +text = "Status: Not running" + +[node name="ConfigPanel" type="PanelContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/ConfigPanel"] +layout_mode = 2 + +[node name="PortLabel" type="Label" parent="VBoxContainer/ConfigPanel/HBoxContainer"] +layout_mode = 2 +text = "Port:" + +[node name="PortField" type="SpinBox" parent="VBoxContainer/ConfigPanel"] +layout_mode = 2 +min_value = 1024.0 +max_value = 65535.0 +value = 6400.0 +alignment = 1 + +[node name="ButtonPanel" type="PanelContainer" parent="VBoxContainer"] +layout_mode = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="VBoxContainer/ButtonPanel"] +layout_mode = 2 +alignment = 1 + +[node name="StartButton" type="Button" parent="VBoxContainer/ButtonPanel"] +layout_mode = 2 +text = "Start Server" + +[node name="StopButton" type="Button" parent="VBoxContainer/ButtonPanel"] +layout_mode = 2 +disabled = true +text = "Stop Server" + +[node name="LogPanel" type="PanelContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="LogDisplay" type="TextEdit" parent="VBoxContainer/LogPanel"] +layout_mode = 2 +editable = false +wrap_mode = 1 \ No newline at end of file diff --git a/game/territory-autobattler/icon.svg b/game/territory-autobattler/icon.svg new file mode 100644 index 0000000..c6bbb7d --- /dev/null +++ b/game/territory-autobattler/icon.svg @@ -0,0 +1 @@ + diff --git a/game/territory-autobattler/icon.svg.import b/game/territory-autobattler/icon.svg.import new file mode 100644 index 0000000..9634663 --- /dev/null +++ b/game/territory-autobattler/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drj7e8fs1pl5e" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/game/territory-autobattler/project.godot b/game/territory-autobattler/project.godot new file mode 100644 index 0000000..a52dc4a --- /dev/null +++ b/game/territory-autobattler/project.godot @@ -0,0 +1,27 @@ +; Engine configuration file. +; It's best edited using the editor UI and not directly, +; since the parameters that go here are not all obvious. +; +; Format: +; [section] ; section goes between [] +; param=value ; assign values to parameters + +config_version=5 + +[application] + +config/name="Territory-Autobattler" +config/features=PackedStringArray("4.6", "Forward Plus") +config/icon="res://icon.svg" + +[editor_plugins] + +enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg") + +[physics] + +3d/physics_engine="Jolt Physics" + +[rendering] + +rendering_device/driver.windows="d3d12" diff --git a/microservices/AuthApi/AuthApi.csproj b/microservices/AuthApi/AuthApi.csproj new file mode 100644 index 0000000..66c00d8 --- /dev/null +++ b/microservices/AuthApi/AuthApi.csproj @@ -0,0 +1,17 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + diff --git a/microservices/AuthApi/AuthApi.http b/microservices/AuthApi/AuthApi.http new file mode 100644 index 0000000..c6e6d30 --- /dev/null +++ b/microservices/AuthApi/AuthApi.http @@ -0,0 +1,6 @@ +@AuthApi_HostAddress = http://localhost:5279 + +GET {{AuthApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/microservices/AuthApi/Controllers/AuthController.cs b/microservices/AuthApi/Controllers/AuthController.cs new file mode 100644 index 0000000..d91fd07 --- /dev/null +++ b/microservices/AuthApi/Controllers/AuthController.cs @@ -0,0 +1,113 @@ +using AuthApi.Models; +using AuthApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace AuthApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly UserService _users; + private readonly IConfiguration _cfg; + private readonly BlacklistService _blacklist; + + public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist) + { + _users = users; _cfg = cfg; _blacklist = blacklist; + } + + [HttpPost("register")] + public async Task Register([FromBody] RegisterRequest req) + { + if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password)) + return BadRequest("Username and password required"); + + if (await _users.GetByUsernameAsync(req.Username) != null) + return BadRequest("User already exists"); + + var hash = BCrypt.Net.BCrypt.HashPassword(req.Password); + var user = new User { Username = req.Username, PasswordHash = hash, Role = "USER", Email = req.Email }; + await _users.CreateAsync(user); + return Ok("User created"); + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest req) + { + var user = await _users.GetByUsernameAsync(req.Username); + if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + return Unauthorized(); + + var (accessToken, jti, expUtc) = GenerateJwtToken(user); + user.RefreshToken = Guid.NewGuid().ToString("N"); + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await _users.UpdateAsync(user); + + return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc }); + } + + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshRequest req) + { + var user = await _users.GetByUsernameAsync(req.Username); + if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow) + return Unauthorized("Invalid or expired refresh token"); + + var (accessToken, _, expUtc) = GenerateJwtToken(user); + return Ok(new { accessToken, exp = expUtc }); + } + + [HttpPost("logout")] + [Authorize(Roles = "USER,SUPER")] + public async Task Logout() + { + var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", ""); + if (string.IsNullOrWhiteSpace(token)) return BadRequest("Token missing"); + var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); + await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo); + return Ok("Logged out."); + } + + [HttpPost("role")] + [Authorize(Roles = "SUPER")] + public async Task ChangeUserRole([FromBody] ChangeRoleRequest req) + { + if (req.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'"); + var user = await _users.GetByUsernameAsync(req.Username); + if (user is null) return NotFound("User not found"); + user.Role = req.NewRole; + await _users.UpdateAsync(user); + return Ok($"{req.Username}'s role updated to {req.NewRole}"); + } + + [HttpGet("users")] + [Authorize(Roles = "SUPER")] + public async Task GetAllUsers() => Ok(await _users.GetAllAsync()); + + private (string token, string jti, DateTime expUtc) GenerateJwtToken(User user) + { + var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!); + var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi"; + var audience = _cfg["Jwt:Audience"] ?? issuer; + + var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); + var jti = Guid.NewGuid().ToString("N"); + var claims = new[] + { + new Claim(ClaimTypes.Name, user.Username), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(ClaimTypes.Role, user.Role), + new Claim(JwtRegisteredClaimNames.Jti, jti) + }; + + var exp = DateTime.UtcNow.AddMinutes(15); + var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds); + return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp); + } +} diff --git a/microservices/AuthApi/DOCUMENTS.md b/microservices/AuthApi/DOCUMENTS.md new file mode 100644 index 0000000..7aa9a40 --- /dev/null +++ b/microservices/AuthApi/DOCUMENTS.md @@ -0,0 +1,49 @@ +# AuthApi document shapes + +This service expects JSON request bodies for its auth endpoints and stores user +documents in MongoDB. + +Inbound JSON documents +- RegisterRequest (`POST /api/auth/register`) + ```json + { + "username": "string", + "password": "string", + "email": "string (optional)" + } + ``` +- LoginRequest (`POST /api/auth/login`) + ```json + { + "username": "string", + "password": "string" + } + ``` +- RefreshRequest (`POST /api/auth/refresh`) + ```json + { + "username": "string", + "refreshToken": "string" + } + ``` +- ChangeRoleRequest (`POST /api/auth/role`) + ```json + { + "username": "string", + "newRole": "USER | SUPER" + } + ``` + +Stored documents (MongoDB) +- User + ```json + { + "id": "string (ObjectId)", + "username": "string", + "passwordHash": "string", + "role": "USER | SUPER", + "email": "string (optional)", + "refreshToken": "string (optional)", + "refreshTokenExpiry": "string (optional, ISO-8601 datetime)" + } + ``` diff --git a/microservices/AuthApi/Dockerfile b/microservices/AuthApi/Dockerfile new file mode 100644 index 0000000..128c610 --- /dev/null +++ b/microservices/AuthApi/Dockerfile @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src + +# Copy project file first to take advantage of Docker layer caching +COPY ["AuthApi.csproj", "./"] +RUN dotnet restore "AuthApi.csproj" + +# Copy the remaining source and publish +COPY . . +RUN dotnet publish "AuthApi.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final +WORKDIR /app +COPY --from=build /app/publish . + +ENV ASPNETCORE_URLS=http://+:8080 \ + ASPNETCORE_ENVIRONMENT=Production + +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "AuthApi.dll"] diff --git a/microservices/AuthApi/Models/Dto.cs b/microservices/AuthApi/Models/Dto.cs new file mode 100644 index 0000000..cd6897c --- /dev/null +++ b/microservices/AuthApi/Models/Dto.cs @@ -0,0 +1,6 @@ +namespace AuthApi.Models; + +public class RegisterRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; public string? Email { get; set; } } +public class LoginRequest { public string Username { get; set; } = ""; public string Password { get; set; } = ""; } +public class ChangeRoleRequest { public string Username { get; set; } = ""; public string NewRole { get; set; } = ""; } +public class RefreshRequest { public string Username { get; set; } = ""; public string RefreshToken { get; set; } = ""; } diff --git a/microservices/AuthApi/Models/User.cs b/microservices/AuthApi/Models/User.cs new file mode 100644 index 0000000..3ff9d76 --- /dev/null +++ b/microservices/AuthApi/Models/User.cs @@ -0,0 +1,16 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace AuthApi.Models; + +public class User +{ + [BsonId] [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = default!; + public string Username { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public string Role { get; set; } = "USER"; + public string? Email { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiry { get; set; } +} diff --git a/microservices/AuthApi/Program.cs b/microservices/AuthApi/Program.cs new file mode 100644 index 0000000..617173b --- /dev/null +++ b/microservices/AuthApi/Program.cs @@ -0,0 +1,86 @@ +using AuthApi.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Security.Claims; +using System.Text; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddControllers(); + +// DI +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Swagger + JWT auth in Swagger +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(c => +{ + c.SwaggerDoc("v1", new OpenApiInfo { Title = "Auth API", Version = "v1" }); + c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme + { + Type = SecuritySchemeType.Http, + Scheme = "bearer", + BearerFormat = "JWT", + Description = "Paste your access token here (no 'Bearer ' prefix needed)." + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } + }, + Array.Empty() + } + }); +}); + +// AuthN/JWT +var cfg = builder.Configuration; +var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); +var issuer = cfg["Jwt:Issuer"] ?? "GameAuthApi"; +var aud = cfg["Jwt:Audience"] ?? issuer; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, ValidIssuer = issuer, + ValidateAudience = true, ValidAudience = aud, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), + ValidateLifetime = true, + ClockSkew = TimeSpan.FromSeconds(30) + }; + o.Events = new JwtBearerEvents + { + OnTokenValidated = async ctx => + { + var jti = ctx.Principal?.FindFirstValue(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Jti); + if (!string.IsNullOrEmpty(jti)) + { + var bl = ctx.HttpContext.RequestServices.GetRequiredService(); + if (await bl.IsBlacklistedAsync(jti)) ctx.Fail("Token revoked"); + } + } + }; + }); + +builder.Services.AddAuthorization(); + +var app = builder.Build(); + +app.MapGet("/healthz", () => Results.Ok("ok")); +app.UseSwagger(); +app.UseSwaggerUI(o => +{ + o.SwaggerEndpoint("/swagger/v1/swagger.json", "Auth API v1"); + o.RoutePrefix = "swagger"; +}); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); +app.Run(); diff --git a/microservices/AuthApi/Properties/launchSettings.json b/microservices/AuthApi/Properties/launchSettings.json new file mode 100644 index 0000000..dcb9958 --- /dev/null +++ b/microservices/AuthApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5279", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7295;http://localhost:5279", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/microservices/AuthApi/README.md b/microservices/AuthApi/README.md new file mode 100644 index 0000000..68cb79b --- /dev/null +++ b/microservices/AuthApi/README.md @@ -0,0 +1,12 @@ +# AuthApi + +## Document shapes +See `DOCUMENTS.md` for request payloads and stored document shapes. + +## Endpoints +- `POST /api/auth/register` Register a new user. +- `POST /api/auth/login` Issue access and refresh tokens. +- `POST /api/auth/refresh` Refresh an access token. +- `POST /api/auth/logout` Revoke the current access token. +- `POST /api/auth/role` Update a user's role (SUPER only). +- `GET /api/auth/users` List users (SUPER only). diff --git a/microservices/AuthApi/Services/BlacklistService.cs b/microservices/AuthApi/Services/BlacklistService.cs new file mode 100644 index 0000000..958a558 --- /dev/null +++ b/microservices/AuthApi/Services/BlacklistService.cs @@ -0,0 +1,36 @@ +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Driver; + +namespace AuthApi.Services; + +public class BlacklistedToken +{ + [BsonId] public string Jti { get; set; } = default!; + public DateTime ExpiresAt { get; set; } +} + +public class BlacklistService +{ + private readonly IMongoCollection _col; + + public BlacklistService(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("BlacklistedTokens"); + + // TTL index so revocations expire automatically + var keys = Builders.IndexKeys.Ascending(x => x.ExpiresAt); + _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); + } + + public Task AddToBlacklistAsync(string jti, DateTime expiresAt) => + _col.ReplaceOneAsync(x => x.Jti == jti, + new BlacklistedToken { Jti = jti, ExpiresAt = expiresAt }, + new ReplaceOptions { IsUpsert = true }); + + public Task IsBlacklistedAsync(string jti) => + _col.Find(x => x.Jti == jti).AnyAsync(); +} diff --git a/microservices/AuthApi/Services/UserService.cs b/microservices/AuthApi/Services/UserService.cs new file mode 100644 index 0000000..9bda631 --- /dev/null +++ b/microservices/AuthApi/Services/UserService.cs @@ -0,0 +1,32 @@ +using AuthApi.Models; +using MongoDB.Driver; + +namespace AuthApi.Services; + +public class UserService +{ + private readonly IMongoCollection _col; + + public UserService(IConfiguration cfg) + { + var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; + var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; + var client = new MongoClient(cs); + var db = client.GetDatabase(dbName); + _col = db.GetCollection("Users"); + + var keys = Builders.IndexKeys.Ascending(u => u.Username); + _col.Indexes.CreateOne(new CreateIndexModel(keys, new CreateIndexOptions { Unique = true })); + } + + public Task GetByUsernameAsync(string username) => + _col.Find(u => u.Username == username).FirstOrDefaultAsync(); + + public Task CreateAsync(User user) => _col.InsertOneAsync(user); + + public Task UpdateAsync(User user) => + _col.ReplaceOneAsync(u => u.Id == user.Id, user); + + public Task> GetAllAsync() => + _col.Find(FilterDefinition.Empty).ToListAsync(); +} diff --git a/microservices/AuthApi/appsettings.Development.json b/microservices/AuthApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/microservices/AuthApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/microservices/AuthApi/appsettings.json b/microservices/AuthApi/appsettings.json new file mode 100644 index 0000000..6758547 --- /dev/null +++ b/microservices/AuthApi/appsettings.json @@ -0,0 +1,7 @@ +{ + "Kestrel": { "Endpoints": { "Http": { "Url": "http://0.0.0.0:8080" } } }, + "MongoDB": { "ConnectionString": "mongodb://192.168.86.50:27017", "DatabaseName": "territory-autobattler" }, + "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "territory-autobattler", "Audience": "territory-autobattler-auth-api" }, + "Logging": { "LogLevel": { "Default": "Information" } }, + "AllowedHosts": "*" +} diff --git a/microservices/AuthApi/k8s/configmap.yaml b/microservices/AuthApi/k8s/configmap.yaml new file mode 100644 index 0000000..7977975 --- /dev/null +++ b/microservices/AuthApi/k8s/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: territory-autobattler-auth-config + namespace: territory-autobattler +data: + MongoDB__ConnectionString: mongodb://192.168.86.50:27017 + MongoDB__DatabaseName: territory-autobattler + Jwt__Issuer: territory-autobattler + Jwt__Audience: territory-autobattler-auth-api diff --git a/microservices/AuthApi/k8s/deployment.yaml b/microservices/AuthApi/k8s/deployment.yaml new file mode 100644 index 0000000..cdee2be --- /dev/null +++ b/microservices/AuthApi/k8s/deployment.yaml @@ -0,0 +1,34 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: territory-autobattler-auth + namespace: territory-autobattler + labels: + app: territory-autobattler-auth +spec: + replicas: 2 + selector: + matchLabels: + app: territory-autobattler-auth + template: + metadata: + labels: + app: territory-autobattler-auth + spec: + containers: + - name: territory-autobattler-auth + image: territory-autobattler-auth:latest + imagePullPolicy: IfNotPresent + ports: + - containerPort: 8080 + envFrom: + - configMapRef: + name: territory-autobattler-auth-config + - secretRef: + name: territory-autobattler-auth-secrets + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/microservices/AuthApi/k8s/namespace.yaml b/microservices/AuthApi/k8s/namespace.yaml new file mode 100644 index 0000000..e8a7f18 --- /dev/null +++ b/microservices/AuthApi/k8s/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: territory-autobattler diff --git a/microservices/AuthApi/k8s/secret.yaml b/microservices/AuthApi/k8s/secret.yaml new file mode 100644 index 0000000..c5b7d54 --- /dev/null +++ b/microservices/AuthApi/k8s/secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: territory-autobattler-auth-secrets + namespace: territory-autobattler +type: Opaque +stringData: + Jwt__Key: SuperUltraSecureJwtKeyWithAtLeast32Chars!! diff --git a/microservices/AuthApi/k8s/service.yaml b/microservices/AuthApi/k8s/service.yaml new file mode 100644 index 0000000..7d746c4 --- /dev/null +++ b/microservices/AuthApi/k8s/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: territory-autobattler-auth + namespace: territory-autobattler + labels: + app: territory-autobattler-auth +spec: + selector: + app: territory-autobattler-auth + type: NodePort + ports: + - name: http + port: 80 + targetPort: 8080 + nodePort: 30081