feat: bullrush branch - mekton bulls arena, HUD, NPC managers, godot_ai updates

This commit is contained in:
2026-07-01 10:39:21 +08:00
parent cc584c3251
commit b6b37b5aac
48 changed files with 2548 additions and 397 deletions
+54 -14
View File
@@ -28,6 +28,7 @@ func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_
func get_editor_state(_params: Dictionary) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var game_status := _current_game_status()
var data := {
"godot_version": Engine.get_version_info().get("string", "unknown"),
"project_name": ProjectSettings.get_setting("application/config/name", ""),
@@ -38,6 +39,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
## false between Play→Stop cycles. Lets capture-source=game callers
## poll for a real ready signal instead of guessing with sleep().
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
"game_status": game_status,
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
}
## Half-installed addon tree from a failed self-update rollback. When
## non-empty, the agent / dock paint the operator-facing recovery copy
@@ -72,6 +76,7 @@ func get_logs(params: Dictionary) -> Dictionary:
var include_details: bool = bool(params.get("include_details", false))
var has_since_cursor := params.has("since_cursor") and params.get("since_cursor") != null
var since_cursor: int = maxi(0, int(params.get("since_cursor", 0)))
var since_run_id := "" if params.get("since_run_id", null) == null else str(params.get("since_run_id", ""))
if not source in VALID_LOG_SOURCES:
return ErrorCodes.make(
ErrorCodes.VALUE_OUT_OF_RANGE,
@@ -82,7 +87,7 @@ func get_logs(params: Dictionary) -> Dictionary:
"plugin":
return _get_plugin_logs(count, offset)
"game":
return _get_game_logs(count, offset, include_details)
return _get_game_logs(count, offset, include_details, since_run_id)
"editor":
return _get_editor_logs(count, offset, include_details, has_since_cursor, since_cursor)
"all":
@@ -90,6 +95,17 @@ func get_logs(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Unreachable")
func _current_game_status() -> Dictionary:
if _debugger_plugin == null:
return McpDebuggerPlugin.with_liveness_flags({
"status": "stopped",
"active": false,
"ready": false,
"helper_expected": true,
})
return _debugger_plugin.get_game_status()
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
var page: Array[Dictionary] = []
@@ -107,7 +123,10 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
}
func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionary:
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
var game_status := _current_game_status()
var helper_live := bool(game_status.get("helper_live", false))
var session_active := bool(game_status.get("session_active", false))
if _game_log_buffer == null:
return {
"data": {
@@ -117,21 +136,35 @@ func _get_game_logs(count: int, offset: int, include_details: bool) -> Dictionar
"returned_count": 0,
"offset": offset,
"run_id": "",
"is_running": false,
"current_run_id": "",
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": 0,
"stale_run_id": false,
}
}
var page := _entries_for_response(_game_log_buffer.get_range(offset, count), include_details)
var current_run_id := _game_log_buffer.run_id()
var target_run_id := since_run_id if not since_run_id.is_empty() else current_run_id
var stale_run_id := not since_run_id.is_empty() and since_run_id != current_run_id
var run_page := _game_log_buffer.get_run_page(target_run_id, offset, count)
var page := _entries_for_response(run_page.get("entries", []), include_details)
return {
"data": {
"source": "game",
"lines": page,
"total_count": _game_log_buffer.total_count(),
"total_count": int(run_page.get("total_count", 0)),
"returned_count": page.size(),
"offset": offset,
"run_id": _game_log_buffer.run_id(),
"is_running": EditorInterface.is_playing_scene(),
"run_id": target_run_id,
"current_run_id": current_run_id,
"is_running": session_active,
"helper_live": helper_live,
"session_active": session_active,
"game_status": game_status,
"dropped_count": _game_log_buffer.dropped_count(),
"stale_run_id": stale_run_id,
}
}
@@ -211,21 +244,24 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
combined.append({"source": "plugin", "level": "info", "text": line})
for entry in _collect_editor_log_entries():
combined.append(entry)
var run_id := ""
var current_run_id := ""
var dropped := 0
if _game_log_buffer != null:
for entry in _game_log_buffer.get_range(0, _game_log_buffer.total_count()):
run_id = _game_log_buffer.run_id()
current_run_id = run_id
dropped = _game_log_buffer.dropped_count()
var run_page := _game_log_buffer.get_run_page(run_id, 0, McpGameLogBuffer.MAX_LINES)
for entry in run_page.get("entries", []):
combined.append(entry)
var stop := mini(combined.size(), offset + count)
var page: Array[Dictionary] = []
for i in range(mini(offset, combined.size()), stop):
page.append(combined[i])
page = _entries_for_response(page, include_details)
var run_id := ""
var dropped := 0
if _game_log_buffer != null:
run_id = _game_log_buffer.run_id()
dropped = _game_log_buffer.dropped_count()
if _editor_log_buffer != null:
dropped += _editor_log_buffer.dropped_count()
var game_status := _current_game_status()
return {
"data": {
"source": "all",
@@ -234,7 +270,11 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
"returned_count": page.size(),
"offset": offset,
"run_id": run_id,
"is_running": EditorInterface.is_playing_scene(),
"current_run_id": current_run_id,
"is_running": bool(game_status.get("session_active", false)),
"helper_live": bool(game_status.get("helper_live", false)),
"session_active": bool(game_status.get("session_active", false)),
"game_status": game_status,
"dropped_count": dropped,
}
}
+128 -11
View File
@@ -239,16 +239,10 @@ func set_property(params: Dictionary) -> Dictionary:
# properties. Mirrors resource_create's inline-assign path but
# avoids a separate tool call for the common case.
var type_str: String = value.get("__class__", "")
var class_err := ResourceHandler._validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null or not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource" % type_str
)
var res: Resource = instance
var made := ResourceHandler._instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
var remaining: Dictionary = (value as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -528,6 +522,7 @@ func _set_owner_recursive(node: Node, owner: Node) -> void:
## is optional — the coercer defaults it to 1.0 when absent.
const VECTOR2_KEYS: Array[String] = ["x", "y"]
const VECTOR3_KEYS: Array[String] = ["x", "y", "z"]
const VECTOR4_KEYS: Array[String] = ["x", "y", "z", "w"]
const COLOR_KEYS: Array[String] = ["r", "g", "b"]
@@ -556,6 +551,8 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedVector2Array
TYPE_PACKED_VECTOR3_ARRAY:
ok = value is PackedVector3Array
TYPE_PACKED_VECTOR4_ARRAY:
ok = value is PackedVector4Array
TYPE_PACKED_COLOR_ARRAY:
ok = value is PackedColorArray
TYPE_PACKED_INT32_ARRAY:
@@ -568,8 +565,31 @@ static func _check_coerced(value: Variant, target_type: int, prefix: String = ""
ok = value is PackedFloat64Array
TYPE_PACKED_STRING_ARRAY:
ok = value is PackedStringArray
TYPE_VECTOR2I: ok = value is Vector2i
TYPE_VECTOR3I: ok = value is Vector3i
TYPE_VECTOR4: ok = value is Vector4
TYPE_VECTOR4I: ok = value is Vector4i
TYPE_QUATERNION: ok = value is Quaternion
TYPE_RECT2: ok = value is Rect2
TYPE_RECT2I: ok = value is Rect2i
TYPE_AABB: ok = value is AABB
TYPE_PLANE: ok = value is Plane
TYPE_BASIS: ok = value is Basis
TYPE_TRANSFORM2D: ok = value is Transform2D
TYPE_TRANSFORM3D: ok = value is Transform3D
TYPE_PROJECTION: ok = value is Projection
_:
return null
# null / untyped-TYPE_NIL / already-correct-type are handled by
# Godot's setter; anything else would silently no-op, so error.
if value == null or target_type == TYPE_NIL or typeof(value) == target_type:
return null
var unsupported := ErrorCodes.make(
ErrorCodes.WRONG_TYPE,
"Cannot write %s to a %s property; godot-ai has no coercion for that type" % [
type_string(typeof(value)), type_string(target_type),
],
)
return ErrorCodes.prefix_message(unsupported, prefix)
if ok:
return null
var dict_err := _check_dict_coerce_failed(value, target_type)
@@ -597,6 +617,8 @@ static func _shape_hint(target_type: int) -> String:
return "[{\"x\":0,\"y\":0}, ...]"
TYPE_PACKED_VECTOR3_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0}, ...]"
TYPE_PACKED_VECTOR4_ARRAY:
return "[{\"x\":0,\"y\":0,\"z\":0,\"w\":0}, ...]"
TYPE_PACKED_COLOR_ARRAY:
return "[{\"r\":0,\"g\":0,\"b\":0,\"a\":1}, ...]"
TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY:
@@ -605,6 +627,24 @@ static func _shape_hint(target_type: int) -> String:
return "[float, ...]"
TYPE_PACKED_STRING_ARRAY:
return "[\"...\", ...]"
TYPE_VECTOR2I:
return "{\"x\":0,\"y\":0}"
TYPE_VECTOR3I:
return "{\"x\":0,\"y\":0,\"z\":0}"
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
return "{\"x\":0,\"y\":0,\"z\":0,\"w\":0}"
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
return "{\"position\":{...},\"size\":{...}}"
TYPE_PLANE:
return "{\"normal\":{...},\"d\":0}"
TYPE_BASIS:
return "{\"x\":{...},\"y\":{...},\"z\":{...}}"
TYPE_TRANSFORM2D:
return "{\"x\":{...},\"y\":{...},\"origin\":{...}}"
TYPE_TRANSFORM3D:
return "{\"basis\":{...},\"origin\":{...}}"
TYPE_PROJECTION:
return "{\"x\":{...},\"y\":{...},\"z\":{...},\"w\":{...}}"
var keys: Array[String] = []
match target_type:
TYPE_VECTOR2: keys = VECTOR2_KEYS
@@ -717,6 +757,17 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_PACKED_VECTOR4_ARRAY:
if value is Array:
var out := PackedVector4Array()
for item in value:
if item is Vector4:
out.append(item)
elif item is Dictionary and item.has_all(VECTOR4_KEYS):
out.append(Vector4(item["x"], item["y"], item["z"], item["w"]))
else:
return value
return out
TYPE_PACKED_COLOR_ARRAY:
if value is Array:
var out := PackedColorArray()
@@ -757,6 +808,72 @@ static func _coerce_value(value: Variant, target_type: int) -> Variant:
else:
return value
return out
TYPE_VECTOR2I:
if value is Dictionary and value.has_all(VECTOR2_KEYS):
return Vector2i(int(value["x"]), int(value["y"]))
TYPE_VECTOR3I:
if value is Dictionary and value.has_all(VECTOR3_KEYS):
return Vector3i(int(value["x"]), int(value["y"]), int(value["z"]))
TYPE_VECTOR4:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4(value["x"], value["y"], value["z"], value["w"])
TYPE_VECTOR4I:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Vector4i(int(value["x"]), int(value["y"]), int(value["z"]), int(value["w"]))
TYPE_QUATERNION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
return Quaternion(value["x"], value["y"], value["z"], value["w"])
TYPE_RECT2:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2)
if p is Vector2 and s is Vector2:
return Rect2(p, s)
TYPE_RECT2I:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR2I)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR2I)
if p is Vector2i and s is Vector2i:
return Rect2i(p, s)
TYPE_AABB:
if value is Dictionary and value.has("position") and value.has("size"):
var p: Variant = _coerce_value(value["position"], TYPE_VECTOR3)
var s: Variant = _coerce_value(value["size"], TYPE_VECTOR3)
if p is Vector3 and s is Vector3:
return AABB(p, s)
TYPE_PLANE:
if value is Dictionary and value.has("normal") and value.has("d"):
var n: Variant = _coerce_value(value["normal"], TYPE_VECTOR3)
if n is Vector3:
return Plane(n, float(value["d"]))
TYPE_BASIS:
if value is Dictionary and value.has_all(["x", "y", "z"]):
var bx: Variant = _coerce_value(value["x"], TYPE_VECTOR3)
var by: Variant = _coerce_value(value["y"], TYPE_VECTOR3)
var bz: Variant = _coerce_value(value["z"], TYPE_VECTOR3)
if bx is Vector3 and by is Vector3 and bz is Vector3:
return Basis(bx, by, bz)
TYPE_TRANSFORM2D:
if value is Dictionary and value.has_all(["x", "y", "origin"]):
var tx: Variant = _coerce_value(value["x"], TYPE_VECTOR2)
var ty: Variant = _coerce_value(value["y"], TYPE_VECTOR2)
var to_: Variant = _coerce_value(value["origin"], TYPE_VECTOR2)
if tx is Vector2 and ty is Vector2 and to_ is Vector2:
return Transform2D(tx, ty, to_)
TYPE_TRANSFORM3D:
if value is Dictionary and value.has("basis") and value.has("origin"):
var b: Variant = _coerce_value(value["basis"], TYPE_BASIS)
var o: Variant = _coerce_value(value["origin"], TYPE_VECTOR3)
if b is Basis and o is Vector3:
return Transform3D(b, o)
TYPE_PROJECTION:
if value is Dictionary and value.has_all(VECTOR4_KEYS):
var px: Variant = _coerce_value(value["x"], TYPE_VECTOR4)
var py: Variant = _coerce_value(value["y"], TYPE_VECTOR4)
var pz: Variant = _coerce_value(value["z"], TYPE_VECTOR4)
var pw: Variant = _coerce_value(value["w"], TYPE_VECTOR4)
if px is Vector4 and py is Vector4 and pz is Vector4 and pw is Vector4:
return Projection(px, py, pz, pw)
# PackedByteArray intentionally unhandled — needs design decision
# (base64 string vs. raw int list); JSON has no native byte type.
return value
+177 -20
View File
@@ -6,14 +6,17 @@ const ErrorCodes := preload("res://addons/godot_ai/utils/error_codes.gd")
## Handles project settings and filesystem search commands.
const NodeHandler := preload("res://addons/godot_ai/handlers/node_handler.gd")
const RUN_READY_WAIT_SEC := 3.0
var _connection: McpConnection
var _debugger_plugin
var _editor_log_buffer
func _init(connection: McpConnection = null, debugger_plugin = null) -> void:
func _init(connection: McpConnection = null, debugger_plugin = null, editor_log_buffer = null) -> void:
_connection = connection
_debugger_plugin = debugger_plugin
_editor_log_buffer = editor_log_buffer
func get_project_setting(params: Dictionary) -> Dictionary:
@@ -81,16 +84,15 @@ func run_project(params: Dictionary) -> Dictionary:
# stop-not-running case in telemetry). Surface state via was_already_running
# so a caller wanting a *different* scene can detect and stop+restart.
if EditorInterface.is_playing_scene():
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": true,
"undoable": false,
"reason": "Project was already running; no action taken",
}
}
return _run_project_current_liveness_response(
_run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
true,
"Project was already running; no action taken"
)
)
var validation_error: Variant = null
if mode == "custom":
@@ -125,7 +127,7 @@ func run_project(params: Dictionary) -> Dictionary:
restore_setting = true
if _debugger_plugin != null:
_debugger_plugin.begin_game_run()
_debugger_plugin.begin_game_run(_editor_log_cursor(), _game_helper_autoload_expected())
match mode:
"main":
@@ -142,18 +144,173 @@ func run_project(params: Dictionary) -> Dictionary:
if _connection:
_connection.pause_processing = false
var base_data := _run_project_base_data(
mode,
str(params.get("scene", "")),
autosave,
false,
"Play/stop is a runtime action"
)
var request_id: String = params.get("_request_id", "")
if _connection != null and _debugger_plugin != null and not request_id.is_empty():
_finish_run_project_deferred(request_id, base_data)
return McpDispatcher.DEFERRED_RESPONSE
return _run_project_current_liveness_response(base_data)
func _editor_log_cursor() -> int:
return _editor_log_buffer.appended_total() if _editor_log_buffer != null else 0
func _game_helper_autoload_expected() -> bool:
return ProjectSettings.has_setting("autoload/_mcp_game_helper")
func _run_project_base_data(
mode: String,
scene: String,
autosave: bool,
was_already_running: bool,
reason: String
) -> Dictionary:
return {
"data": {
"mode": mode,
"scene": params.get("scene", ""),
"autosave": autosave,
"was_already_running": false,
"undoable": false,
"reason": "Play/stop is a runtime action",
}
"mode": mode,
"scene": scene,
"autosave": autosave,
"was_already_running": was_already_running,
"undoable": false,
"reason": reason,
}
func _run_project_current_liveness_response(base_data: Dictionary) -> Dictionary:
if _debugger_plugin == null:
return {"data": base_data}
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
return _run_project_response(base_data, _run_project_liveness_decision(status, errors_info))
func _finish_run_project_deferred(request_id: String, base_data: Dictionary) -> void:
var tree := _connection.get_tree()
while true:
await tree.process_frame
if not is_instance_valid(_connection):
return
var pre_status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
if (
not EditorInterface.is_playing_scene()
and int(pre_status.get("elapsed_msec", 0)) > 100
and str(pre_status.get("status", "stopped")) == "launching"
):
_debugger_plugin.end_game_run()
var status: Dictionary = _debugger_plugin.get_game_status(-1, RUN_READY_WAIT_SEC)
var errors_info: Dictionary = _debugger_plugin.recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
var decision := _run_project_liveness_decision(status, errors_info)
if not bool(decision.get("resolve", false)):
continue
_connection.send_deferred_response(request_id, _run_project_response(base_data, decision))
return
func _run_project_response(base_data: Dictionary, decision: Dictionary) -> Dictionary:
var data := base_data.duplicate(true)
var game_status: Dictionary = decision.get("game_status", {})
data["game_status"] = game_status
data["helper_live"] = bool(game_status.get("helper_live", false))
data["session_active"] = bool(game_status.get("session_active", false))
if bool(data.get("was_already_running", false)):
data["reason"] = _run_project_already_running_message(decision)
else:
data["reason"] = decision.get("message", data.get("reason", "Play/stop is a runtime action"))
data["recent_errors"] = decision.get("recent_errors", [])
data["recent_errors_scope"] = decision.get("recent_errors_scope", "none")
data["recent_errors_may_predate_run"] = decision.get("recent_errors_may_predate_run", false)
data["recent_errors_truncated"] = decision.get("recent_errors_truncated", false)
return {"data": data}
func _run_project_already_running_message(decision: Dictionary) -> String:
var state := str(decision.get("liveness_status", "unknown"))
match state:
"live":
return "Project was already running; the Godot AI game helper is live."
"not_live":
var errors: Array = decision.get("recent_errors", [])
var scope := str(decision.get("recent_errors_scope", "none"))
if not errors.is_empty() and scope == "run":
return "Project was already running but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
if not errors.is_empty():
return "Project was already running but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(errors[0])
return "Project was already running but did not become live before the helper-ready window elapsed. Check logs_read(source='editor', include_details=true) and poll editor_state."
"no_helper":
return "Project was already running, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness."
"launching":
return "Project was already running and is still waiting for the Godot AI game helper to register. Poll editor_state shortly."
"stopped":
return "Project was already marked playing by the editor, but no active game liveness run exists."
_:
return "Project was already running; current liveness status is %s." % state
func _run_project_liveness_decision(status: Dictionary, errors_info: Dictionary = {}) -> Dictionary:
var enriched_status := McpDebuggerPlugin.with_liveness_flags(status)
var state := str(status.get("status", "stopped"))
var recent_errors: Array = errors_info.get("errors", [])
var errors_scope := str(errors_info.get("scope", "none"))
var truncated := bool(errors_info.get("truncated", false))
var correlated_error := not recent_errors.is_empty() and errors_scope == "run"
var elapsed_msec := int(status.get("elapsed_msec", 0))
var ready_wait_msec := int(status.get("ready_wait_msec", int(RUN_READY_WAIT_SEC * 1000.0)))
var decision := {
"resolve": false,
"game_status": enriched_status,
"liveness_status": state,
"recent_errors": recent_errors,
"recent_errors_scope": errors_scope,
"recent_errors_may_predate_run": errors_scope == "retained_recent",
"recent_errors_truncated": truncated,
"message": "",
}
if state == "live":
decision["resolve"] = true
decision["message"] = "Game launched and the Godot AI game helper is live."
elif correlated_error:
decision["resolve"] = true
decision["liveness_status"] = "not_live"
decision["message"] = "Game launched but failed to load before the Godot AI game helper registered: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
if truncated:
decision["message"] += " Editor logs since this run may be truncated; showing retained errors."
elif state == "not_live":
decision["resolve"] = true
if not recent_errors.is_empty():
decision["message"] = "Game launched but is not responding. A recent editor error may be related, but may predate this run: %s. Check logs_read(source='editor', include_details=true)." % _format_editor_error_summary(recent_errors[0])
else:
decision["message"] = "Game launched but did not become live before the helper-ready window elapsed. It may still be booting or may have failed silently; check logs_read(source='editor', include_details=true) and poll editor_state."
elif state == "no_helper":
decision["resolve"] = true
decision["message"] = "Game launched, but no _mcp_game_helper autoload is expected. Headless or custom-main-loop projects cannot confirm helper liveness; use editor_state and viewport/editor tools where applicable."
elif state == "stopped":
decision["resolve"] = true
decision["message"] = "The play session stopped, or no active game liveness run exists, before the Godot AI game helper became live."
elif state == "launching" and elapsed_msec >= ready_wait_msec:
decision["resolve"] = true
decision["message"] = "Game launched but is not yet live after %.1fs; it may still be booting. Poll editor_state and check logs_read(source='editor', include_details=true)." % (float(elapsed_msec) / 1000.0)
return decision
func _format_editor_error_summary(entry: Dictionary) -> String:
var text := str(entry.get("text", "editor error"))
var path := str(entry.get("path", ""))
var line := int(entry.get("line", 0))
if not path.is_empty() and line > 0:
return "%s (%s:%d)" % [text, path, line]
if not path.is_empty():
return "%s (%s)" % [text, path]
return text
func stop_project(params: Dictionary) -> Dictionary:
# Idempotent: a project that's already stopped satisfies the caller's intent.
# Returning INVALID_PARAMS here was the largest single source of fleet-wide
+157 -24
View File
@@ -178,19 +178,10 @@ func create_resource(params: Dictionary) -> Dictionary:
return home_err
var has_file_target := not resource_path.is_empty()
var class_err := _validate_resource_class(type_str)
if class_err != null:
return class_err
var instance := ClassDB.instantiate(type_str)
if instance == null:
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s" % type_str)
if not (instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Instantiated %s but result is not a Resource (got %s)" % [type_str, instance.get_class()]
)
var res: Resource = instance
var made := _instantiate_resource(type_str)
if made is Dictionary:
return made
var res: Resource = made
if not properties.is_empty():
var apply_err := _apply_resource_properties(res, properties)
@@ -226,6 +217,56 @@ static func _validate_resource_class(type_str: String) -> Variant:
return null
## Resolve a resource type name to a fresh instance. Handles engine built-ins
## (ClassDB) and project `class_name` Resources (the global script-class
## registry). Returns a Resource on success, or an error dict on failure.
static func _instantiate_resource(type_str: String) -> Variant:
if ClassDB.class_exists(type_str):
var class_err: Variant = _validate_resource_class(type_str)
if class_err != null:
return class_err
var built_in := ClassDB.instantiate(type_str)
if built_in == null or not (built_in is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return built_in
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") == type_str:
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
# Reject non-Resource script classes BEFORE constructing them:
# scr.new() runs _init(), and an @tool class_name extending a
# non-RefCounted type (e.g. Node) would otherwise build — and leak —
# an orphan instance this path never frees. get_instance_base_type()
# resolves to the native base, so multi-level custom Resource
# hierarchies (B extends A extends Resource) still pass.
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
if not scr.can_instantiate():
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated in the editor (abstract, or a non-@tool script — add @tool to instantiate it here)" % type_str)
# Reject scripts whose _init() requires arguments BEFORE scr.new():
# scr.new() passes no args, so a required-arg _init raises and aborts
# this handler mid-call, null-cascading into a generic "malformed
# result" error instead of a clean rejection. get_script_method_list()
# reports the effective (incl. inherited) _init; required args =
# args - default_args. Statically detectable only — a _init that runs
# but throws still falls through to scr.new() and the dispatcher catch.
for method in scr.get_script_method_list():
if method.get("name", "") == "_init":
var required_args: int = (method.get("args", []) as Array).size() - (method.get("default_args", []) as Array).size()
if required_args > 0:
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s cannot be instantiated: its _init() requires arguments" % type_str)
break
var made: Variant = scr.new()
if made == null or not (made is Resource):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to instantiate %s as a Resource" % type_str)
return made
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
## Apply a dict of property values to a freshly-instantiated Resource,
## reusing NodeHandler's coercion so Vector3/Color/etc. dicts land typed.
## Returns null on success or an error dict on failure.
@@ -240,9 +281,17 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
if prop.get("usage", 0) & PROPERTY_USAGE_EDITOR:
valid.append(prop.name)
valid.sort()
# Name the script's class_name (e.g. MyTestResource) rather than the
# native base (Resource) so the hint names the type the agent created,
# and point at the real MCP verb — resource_manage(op="get_info") now
# answers for project class_name Resources too.
var type_label := res.get_class()
var res_script: Variant = res.get_script()
if res_script is Script and not String(res_script.get_global_name()).is_empty():
type_label = String(res_script.get_global_name())
var err := ErrorCodes.make(
ErrorCodes.PROPERTY_NOT_ON_CLASS,
"Property '%s' not found on %s. Call resource_get_info('%s') to list available properties." % [key, res.get_class(), res.get_class()]
"Property '%s' not found on %s. Call resource_manage(op=\"get_info\", params={\"type\": \"%s\"}) to list available properties." % [key, type_label, type_label]
)
err["error"]["data"] = {"valid_properties": valid}
return err
@@ -270,16 +319,15 @@ static func _apply_resource_properties(res: Resource, properties: Dictionary) ->
# resource_create/environment_create callers can populate
# sub-resource slots (ShaderMaterial.shader, etc.) in one shot.
var sub_type: String = v.get("__class__", "")
var class_err := _validate_resource_class(sub_type)
if class_err != null:
return class_err
var sub_instance := ClassDB.instantiate(sub_type)
if sub_instance == null or not (sub_instance is Resource):
return ErrorCodes.make(
ErrorCodes.INTERNAL_ERROR,
"Failed to instantiate %s as a Resource for property '%s'" % [sub_type, key]
)
var sub_res: Resource = sub_instance
# Resolve via the shared helper so the nested shortcut accepts both
# engine built-ins (ClassDB) and project `class_name` Resources,
# exactly like the top-level resource_create path.
var sub_made := _instantiate_resource(sub_type)
if sub_made is Dictionary:
# Preserve the property-slot context the inline path used to add.
sub_made["error"]["message"] = "%s (for property '%s')" % [sub_made["error"]["message"], key]
return sub_made
var sub_res: Resource = sub_made
var remaining: Dictionary = (v as Dictionary).duplicate()
remaining.erase("__class__")
if not remaining.is_empty():
@@ -361,6 +409,12 @@ func get_resource_info(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.MISSING_REQUIRED_PARAM, "Missing required param: type")
if not ClassDB.class_exists(type_str):
# Project class_name Resources aren't in ClassDB; resolve them through the
# global script-class registry so get_info answers for the same custom
# types resource_create can make. Read-only — never instantiates.
var custom_info: Variant = _custom_resource_info(type_str)
if custom_info != null:
return custom_info
return ErrorCodes.make(ErrorCodes.VALUE_OUT_OF_RANGE, "Unknown resource type: %s" % type_str)
if ClassDB.is_parent_class(type_str, "Node"):
return ErrorCodes.make(
@@ -396,3 +450,82 @@ func get_resource_info(params: Dictionary) -> Dictionary:
data["concrete_subclasses"] = class_info.concrete_inheritors
return {"data": data}
## Resolve a loaded global-class script to its native base type, or an error if
## the script failed to load (not a Script) or to compile (empty base type).
## Shared by the create and get_info custom-Resource paths so both report a
## compile failure rather than a misleading "is not a Resource type (extends )".
static func _script_base_type_or_error(scr: Variant, type_str: String, script_path: String) -> Variant:
if not (scr is Script):
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "Failed to load script class %s from %s" % [type_str, script_path])
var base_type: StringName = scr.get_instance_base_type()
if String(base_type).is_empty():
return ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, "%s failed to compile or parse (script %s)" % [type_str, script_path])
return base_type
## get_info for a project `class_name` Resource (not in ClassDB). Returns an info
## dict, an error dict (for a class_name whose native base is not a Resource), or
## null if `type_str` is not a registered global class. Read-only: resolves
## properties from the script + its native base WITHOUT instantiating (no _init()).
static func _custom_resource_info(type_str: String) -> Variant:
for entry in ProjectSettings.get_global_class_list():
if entry.get("class", "") != type_str:
continue
var script_path: String = entry.get("path", "")
var scr: Variant = load(script_path)
var base_or_err: Variant = _script_base_type_or_error(scr, type_str, script_path)
if base_or_err is Dictionary:
return base_or_err
var base_type: StringName = base_or_err
if not ClassDB.is_parent_class(base_type, "Resource"):
return ErrorCodes.make(ErrorCodes.WRONG_TYPE, "%s is not a Resource type (extends %s)" % [type_str, base_type])
var can_instantiate: bool = scr.can_instantiate()
# Inherited (native) properties come from the engine base via ClassDB...
var class_info := ClassIntrospection.build(String(base_type), {
"sections": ["properties"],
"include_inherited": true,
"limit": 0,
})
var props: Array = []
for native_prop in class_info.properties:
props.append(native_prop)
# ...and the script's own (and inherited script) exported properties come
# from the Script itself, so we never construct the resource. A real
# default isn't available without instantiating, so script props carry an
# explicit null — keeping one uniform key set across the array (native
# props carry their real default).
for raw_prop in scr.get_script_property_list():
var prop: Dictionary = raw_prop
var usage := int(prop.get("usage", 0))
if not (usage & PROPERTY_USAGE_EDITOR):
continue
props.append({
"name": str(prop.get("name", "")),
"type": type_string(int(prop.get("type", TYPE_NIL))),
"class_name": str(prop.get("class_name", "")),
"hint": int(prop.get("hint", PROPERTY_HINT_NONE)),
"hint_string": str(prop.get("hint_string", "")),
"usage": usage,
"default": null,
})
props.sort_custom(func(a, b): return a.name < b.name)
# parent_class is the immediate script parent when there is one (so a
# multi-level chain B -> A -> Resource reports A), else the native base.
var parent_name := String(base_type)
var base_script: Variant = scr.get_base_script()
if base_script is Script and not String(base_script.get_global_name()).is_empty():
parent_name = String(base_script.get_global_name())
return {"data": {
"type": type_str,
"parent_class": parent_name,
"can_instantiate": can_instantiate,
# is_abstract reflects real abstractness (the @abstract annotation),
# NOT editor-instantiability — a non-@tool concrete Resource has
# can_instantiate()==false in-editor but is not abstract.
"is_abstract": scr.is_abstract(),
"properties": props,
"property_count": props.size(),
}}
return null
+12 -2
View File
@@ -152,7 +152,7 @@ func connect_signal(params: Dictionary) -> Dictionary:
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' already connected to %s.%s" % [signal_name, params.target, method])
_undo_redo.create_action("MCP: Connect signal %s" % signal_name)
_undo_redo.add_do_method(source, "connect", signal_name, callable)
_undo_redo.add_do_method(source, "connect", signal_name, callable, Object.CONNECT_PERSIST)
_undo_redo.add_undo_method(source, "disconnect", signal_name, callable)
_undo_redo.commit_action()
@@ -174,9 +174,19 @@ func disconnect_signal(params: Dictionary) -> Dictionary:
if not source.is_connected(signal_name, callable):
return ErrorCodes.make(ErrorCodes.INVALID_PARAMS, "Signal '%s' is not connected to %s.%s" % [signal_name, params.target, method])
# Capture the connection's current flags so undo restores it exactly as it
# was, not unconditionally as CONNECT_PERSIST. Hardcoding PERSIST here would
# silently promote a runtime-only connection into one that serializes on the
# next save. (The connection still exists at this point — checked above.)
var reconnect_flags := 0
for conn in source.get_signal_connection_list(signal_name):
if conn.get("callable", Callable()) == callable:
reconnect_flags = int(conn.get("flags", 0))
break
_undo_redo.create_action("MCP: Disconnect signal %s" % signal_name)
_undo_redo.add_do_method(source, "disconnect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable)
_undo_redo.add_undo_method(source, "connect", signal_name, callable, reconnect_flags)
_undo_redo.commit_action()
return {"data": _signal_response(source, signal_name, target, method, scene_root)}