Files
agame/addons/godot_ai/plugin.gd
T
2026-09-21 16:16:04 +08:00

203 lines
6.7 KiB
GDScript

@tool
extends EditorPlugin
## Temporary v3-compatible entry point delivered through the signed legacy
## updater asset. It owns presentation only. Verification and staging run in
## the detached bridge node; the value-only coordinator survives this plugin
## handing off to the shared in-editor activation runner.
const MigrationBridge := preload("res://addons/godot_ai/migration_bridge.gd")
const MigrationCoordinator := preload("res://addons/godot_ai/migration_coordinator.gd")
const MigrationFallback := preload("res://addons/godot_ai/migration_fallback.gd")
const FLOOR_REFUSAL := "Godot AI v4 migration requires Godot 4.7 or newer; bridge remains inactive."
const FLOOR_MESSAGE := "Godot AI v4 requires Godot 4.7 or newer. Upgrade Godot, reopen this project, then click Retry."
const FLOOR_RESTORING := (
"Godot AI v4 requires Godot 4.7 or newer. Restoring the previous Godot AI version; "
+ "upgrade Godot to update."
)
const EDITOR_REQUIRED := (
"Godot AI v4 migration needs the interactive Godot editor. "
+ "Reopen the project in the editor (not headless or exported), then click Retry migration."
)
var _dock: VBoxContainer
var _label: Label
var _retry: Button
var _bridge
var _coordinator
func _enter_tree() -> void:
_build_dock()
var prior := _bridge_status()
if not prior.is_empty():
_present_error(str(prior.get("error", "The previous migration attempt did not finish.")))
return
if not _editor_available():
## Headless imports and exports must never mutate the tree, not even
## to restore final v3; the next interactive start handles it.
_present_state(EDITOR_REQUIRED, true, false)
return
if not _meets_floor():
print(FLOOR_REFUSAL)
## The user's updater already overlaid the capsule over their v3 tree.
## Put the final v3 back so nothing is lost; without the fallback (an
## older capsule) the bridge can only wait for a newer Godot.
if MigrationFallback.available():
_present_state(FLOOR_RESTORING, false, false)
var fallback: Node = MigrationFallback.new()
_parent_node().add_child(fallback)
fallback.start()
return
_present_state(FLOOR_MESSAGE, true, false)
return
_start_migration.call_deferred()
func _exit_tree() -> void:
_free_bridge()
if _coordinator != null and is_instance_valid(_coordinator):
## The coordinator outlives this plugin on purpose; it disables the
## capsule and keeps running. Only stop listening to it.
if _coordinator.state_changed.is_connected(_present_state):
_coordinator.state_changed.disconnect(_present_state)
_coordinator = null
if _dock != null and is_instance_valid(_dock):
remove_control_from_docks(_dock)
_dock.queue_free()
_dock = null
func _build_dock() -> void:
_dock = VBoxContainer.new()
_dock.name = "Godot AI"
_label = Label.new()
_label.text = "Preparing the signed Godot AI v4 migration…"
_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_label.add_theme_font_size_override("font_size", 15)
_dock.add_child(_label)
_retry = Button.new()
_retry.text = "Retry migration"
_retry.visible = false
_retry.pressed.connect(_on_retry)
_dock.add_child(_retry)
add_control_to_dock(DOCK_SLOT_RIGHT_UL, _dock)
func _start_migration() -> void:
if _bridge != null or _coordinator != null:
return
_clear_bridge_status()
_retry.visible = false
_label.text = "Preparing the signed Godot AI v4 migration…"
print("MCP | v3 bridge verifying and staging the signed v4 tree")
_bridge = MigrationBridge.new()
_bridge.state_changed.connect(_present_state)
_bridge.prepared.connect(_activate_prepared)
_parent_node().add_child(_bridge)
_bridge.start()
func _present_state(message: String, failed: bool = false, record: bool = true) -> void:
if failed:
print("MCP | v3 bridge: %s" % message)
if record:
MigrationCoordinator.record_status(message)
_free_bridge()
_coordinator = null
if _label != null:
_label.text = message
if _retry != null:
_retry.visible = failed
func _present_error(message: String) -> void:
_present_state(message, true)
func _activate_prepared(package: Dictionary) -> void:
_free_bridge()
print("MCP | v3 bridge activating canonical v4 tree")
_coordinator = MigrationCoordinator.new()
_coordinator.state_changed.connect(_present_state)
_parent_node().add_child(_coordinator)
_coordinator.start(package)
func _on_retry() -> void:
if not _meets_floor():
_present_state(FLOOR_MESSAGE, true, false)
return
if not _editor_available():
_present_state(EDITOR_REQUIRED, true, false)
return
_start_migration()
func _free_bridge() -> void:
if _bridge != null and is_instance_valid(_bridge):
_bridge.queue_free()
_bridge = null
func _parent_node() -> Node:
var parent: Node = EditorInterface.get_base_control()
return parent if parent != null else get_tree().root
static func _meets_floor() -> bool:
## Test-only: lets a scenario exercise the refusal path on a current Godot.
if OS.get_environment("GODOT_AI_TEST_GODOT_FLOOR") == "unmet":
return false
var version := Engine.get_version_info()
return int(version.get("major", 0)) == 4 and int(version.get("minor", 0)) >= 7
## Updates are interactive-only: a headless import or an export run loads
## enabled plugins too, and must never swap the add-on tree or restart.
static func _editor_available() -> bool:
if not Engine.is_editor_hint():
return false
if _truthy(OS.get_environment("GODOT_AI_ALLOW_HEADLESS")):
## The override the v4 plugin honours too, so CI can drive the
## crossing in a headless editor. Never set it for a real install.
return EditorInterface.get_base_control() != null
if DisplayServer.get_name().to_lower() == "headless":
return false
if _args_request_headless(OS.get_cmdline_args()):
return false
return EditorInterface.get_base_control() != null
static func _truthy(value: String) -> bool:
var lowered := value.strip_edges().to_lower()
return lowered == "1" or lowered == "true" or lowered == "yes" or lowered == "on"
static func _args_request_headless(args: PackedStringArray) -> bool:
for i in range(args.size()):
var arg := args[i]
if arg == "--headless":
return true
if arg == "--display-driver" and i + 1 < args.size() and args[i + 1] == "headless":
return true
if arg.begins_with("--display-driver=") and arg.get_slice("=", 1) == "headless":
return true
return false
static func _bridge_status() -> Dictionary:
var settings := EditorInterface.get_editor_settings()
var key := MigrationCoordinator.status_setting()
if settings == null or not settings.has_setting(key):
return {}
var parsed: Variant = JSON.parse_string(str(settings.get_setting(key)))
return parsed if parsed is Dictionary else {}
static func _clear_bridge_status() -> void:
var settings := EditorInterface.get_editor_settings()
var key := MigrationCoordinator.status_setting()
if settings != null and settings.has_setting(key):
settings.erase(key)