Files
tekton/addons/godot_ai/utils/scene_path.gd
T

147 lines
6.9 KiB
GDScript

@tool
class_name McpScenePath
extends RefCounted
const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Utility for converting between Godot internal node paths and clean
## scene-relative paths like /Main/Camera3D.
## Return a clean path relative to the scene root (e.g. /Main/Camera3D).
## Returns "" when `node` is not the scene root or a descendant of it —
## without the ancestry guard, get_path_to() returns an empty NodePath that
## concatenates into a plausible-looking but invalid "/Main/".
static func from_node(node: Node, scene_root: Node) -> String:
if scene_root == null or node == null:
return ""
if node == scene_root:
return "/" + scene_root.name
if not scene_root.is_ancestor_of(node):
return ""
var relative := scene_root.get_path_to(node)
return "/" + scene_root.name + "/" + str(relative)
## Resolve a clean scene path like "/Main/Camera3D" to the actual node.
##
## Accepts forms relative to the edited scene root:
## "/Main" — explicit root prefix (canonical)
## "/Main/Camera3D" — descendant path
## "Camera3D" — bare relative to scene_root
## "World/Ground" — nested bare relative to scene_root
##
## Also accepts SceneTree-style "/root/<scene_root_name>[/...]" as an alias for
## the edited scene root. Agents reach for /root/Foo right after creating a
## scene because that's where scenes live at runtime; we honor it so the call
## doesn't fail with a confusing "not found" error. The alias only kicks in
## when the segment after /root matches the scene root's name — paths like
## "/root/@EditorNode@.../Main/..." (returned by Node.get_path() in the editor)
## fall through to the absolute-path fallback unchanged.
static func resolve(scene_path: String, scene_root: Node) -> Node:
if scene_root == null:
return null
## /root/<scene_root_name>[/...] alias: strip the /root prefix and recurse.
## Match the scene root by name explicitly so we don't capture editor-
## internal paths that legitimately live under /root.
var alias_prefix := "/root/" + scene_root.name
if scene_path == alias_prefix or scene_path.begins_with(alias_prefix + "/"):
return resolve(scene_path.substr(5), scene_root) # keep leading slash
var root_prefix := "/" + scene_root.name
if scene_path == root_prefix:
return scene_root
if scene_path.begins_with(root_prefix + "/"):
var relative := scene_path.substr(root_prefix.length() + 1)
return scene_root.get_node_or_null(relative)
# Try as-is (relative path, or absolute SceneTree path).
return scene_root.get_node_or_null(scene_path)
## Return the edited scene root, or an error dict if the editor has no open
## scene or the open scene doesn't match `expected_scene_file`.
##
## `expected_scene_file` is the caller's `scene_file` parameter — an empty
## string means "target whatever is currently edited" (current behaviour,
## no guard). A non-empty value must match `scene_file_path` on the current
## edited scene root exactly, or we return EDITED_SCENE_MISMATCH so the
## caller can re-open the right scene.
##
## Shape on success: {"node": <scene_root>}. Shape on error matches
## `ErrorCodes.make()` so callers can propagate the result directly.
static func require_edited_scene(expected_scene_file: String) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if root == null:
# Mirrors the structured payload that the Python-side require_writable
# gate attaches for `playing` / `importing`. Together these cover the
# three recoverable editor *states* (playing / importing / no_scene)
# — the EDITOR_NOT_READY paths an AI caller can act on. Other
# EDITOR_NOT_READY callsites describing internal-state failures
# ("EditorFileSystem not available" etc.) intentionally don't carry
# this payload because there's no useful caller hint to give.
var err := ErrorCodes.make(ErrorCodes.EDITOR_NOT_READY, "No scene open")
err["error"]["data"] = {
"editor_state": "no_scene",
"retryable": false,
"hint": (
"No scene is open. Call scene_open with a scene path "
+ "(e.g. \"res://main.tscn\") before issuing scene-mutating tools."
),
}
return err
if not expected_scene_file.is_empty() and root.scene_file_path != expected_scene_file:
var actual := root.scene_file_path if not root.scene_file_path.is_empty() else "<unsaved>"
return ErrorCodes.make(
ErrorCodes.EDITED_SCENE_MISMATCH,
(
"Expected edited scene \"%s\" but \"%s\" is active. "
+ "Call scene_open(\"%s\") first, or omit scene_file to target the active scene."
) % [expected_scene_file, actual, expected_scene_file],
)
return {"node": root}
## Format a "parent not found" error that names the path convention.
## Agents routinely try /root/Foo or absolute SceneTree paths; the bare
## "Parent not found: X" gave them no hint that paths are scene-relative.
## Wording is generic ("Paths are relative...") so the helper works for any
## param name (parent_path, new_parent, …).
static func format_parent_error(path: String, scene_root: Node) -> String:
if scene_root == null:
return "Parent not found: %s. No edited scene is open." % path
var root_name := str(scene_root.name)
return "Parent not found: %s. Paths are relative to the edited scene root (e.g. \"/%s\" or \"\"), not the SceneTree. Scene root is \"/%s\"." % [path, root_name, root_name]
## Format a "node not found" error that names the path convention and, when
## possible, suggests a corrected path. Agents routinely pass /root/Foo
## (runtime SceneTree) or unprefixed names; the bare "Node not found: X"
## gives no hint that paths are edited-scene-relative.
##
## Suggestion logic (highest-confidence first):
## 1. /root/<X>[/...] where <X> is not the scene root → suggest /<sceneRoot>/<X>[/...]
## 2. path doesn't start with "/" → suggest "/<sceneRoot>/<path>"
## 3. otherwise no concrete "did you mean", just the convention reminder.
static func format_node_error(path: String, scene_root: Node) -> String:
if scene_root == null:
return "Node not found: %s. No edited scene is open." % path
var root_name := str(scene_root.name)
var suggestion := ""
if path.begins_with("/root/"):
var after_root := path.substr(6) # "/root/" is 6 chars
# Only suggest if the segment after /root/ isn't already the scene root
# (resolve() handles /root/<sceneRoot>/... as an alias, so a failure
# with that prefix means a deeper segment is wrong — no clean rewrite).
var first_seg := after_root.split("/")[0]
if first_seg != root_name and not first_seg.is_empty():
suggestion = "/" + root_name + "/" + after_root
elif not path.begins_with("/") and not path.is_empty():
suggestion = "/" + root_name + "/" + path
if suggestion.is_empty():
return "Node not found: %s. Paths are relative to the edited scene root (e.g. \"/%s/Child\"), not runtime /root/... paths. Scene root is \"/%s\"." % [path, root_name, root_name]
return "Node not found: %s. Did you mean \"%s\"? Paths are relative to the edited scene root, not runtime /root/... paths. Scene root is \"/%s\"." % [path, suggestion, root_name]