diff --git a/addons/godot_ai/README.md b/addons/godot_ai/README.md index c8efadb..3f91975 100644 --- a/addons/godot_ai/README.md +++ b/addons/godot_ai/README.md @@ -14,7 +14,7 @@ The plugin auto-starts the MCP server and connects over WebSocket. No manual con ## Requirements -- Godot 4.3+ (4.4+ recommended) +- Godot 4.3+ (4.7+ recommended) - [uv](https://docs.astral.sh/uv/) (used to install the Python server)
Install uv diff --git a/addons/godot_ai/client_configurator.gd b/addons/godot_ai/client_configurator.gd index 6d1e7cd..1e2fb8d 100644 --- a/addons/godot_ai/client_configurator.gd +++ b/addons/godot_ai/client_configurator.gd @@ -24,6 +24,7 @@ const CliStrategy := preload("res://addons/godot_ai/clients/_cli_strategy.gd") const ManualCommand := preload("res://addons/godot_ai/clients/_manual_command.gd") const CliFinder := preload("res://addons/godot_ai/clients/_cli_finder.gd") const WindowsPortReservation := preload("res://addons/godot_ai/utils/windows_port_reservation.gd") +const PortResolver := preload("res://addons/godot_ai/utils/port_resolver.gd") const SERVER_NAME := "godot-ai" @@ -37,8 +38,14 @@ const DEFAULT_WS_PORT := 9500 const STARTUP_TRACE_ENV := "GODOT_AI_STARTUP_TRACE" const MIN_PORT := 1024 const MAX_PORT := 65535 +## Cap on `can_bind_local_port` probes per `suggest_free_port` call so a +## pathological run of occupied ports can't stall the (cold-path) caller. +## 64 localhost binds are sub-millisecond; finding a free port realistically +## takes one or two probes, so this only bounds the worst case. +const SUGGEST_PORT_MAX_PROBES := 64 const SETTING_WS_PORT := "godot_ai/ws_port" const SETTING_STARTUP_TRACE := "godot_ai/log_startup_timing" +const _DISCOVERY_TIMEOUT_MS := 3000 ## Active HTTP port: user override (if in range) or `DEFAULT_HTTP_PORT`. @@ -131,15 +138,39 @@ static func excluded_domains() -> String: return ",".join(parts) -## Clamp `start` into the legal port range, then walk -## `candidate`..`candidate+span-1` and return the first port that is NOT -## currently excluded by Windows' winnat reservation table. Falls back to the -## clamped candidate if nothing clears (caller can apply anyway — user may -## just retry). On non-Windows this is a no-op: all ports pass, returns the -## clamped candidate. +## Suggest a port the caller can actually switch to. Walks +## `candidate`..`candidate+span-1` and returns the first port that is both +## (a) NOT inside a Windows winnat reservation range (Hyper-V / WSL2 / Docker +## grab these; bind fails with WinError 10013 and netstat shows nothing) and +## (b) actually bindable right now on 127.0.0.1. The bind probe is what makes +## "free" honest on macOS/Linux, where the reservation table is empty but the +## next port up may still be occupied — the same suggestion feeds the dock +## crash body, the port-picker spinbox, and the non-recoverable INCOMPATIBLE +## log line. Falls back to the clamped candidate if nothing in the window +## clears both checks (caller surfaces it as a best-effort hint; the user can +## retry or pick another). Best-effort by nature: a TOCTOU window remains +## between the probe and the caller actually binding the port. The bind probe +## is bounded to `SUGGEST_PORT_MAX_PROBES` attempts so this cold path can't +## stall on a pathological run of occupied ports. static func suggest_free_port(start: int, span: int = 2048) -> int: var candidate := clampi(start, MIN_PORT, MAX_PORT - span + 1) - return WindowsPortReservation.suggest_non_excluded_port(candidate, span, MAX_PORT) + var limit := mini(candidate + span - 1, MAX_PORT) + var p := candidate + var probes := 0 + while p <= limit and probes < SUGGEST_PORT_MAX_PROBES: + ## Jump past a whole Windows-reserved range in one step (no-op on + ## POSIX: returns `p` unchanged), so we don't probe port-by-port + ## through the large adjacent ranges those services reserve. The + ## jump itself runs no bind probes, so it doesn't count against the cap. + var not_reserved := WindowsPortReservation.suggest_non_excluded_port(p, limit - p + 1, MAX_PORT) + if not_reserved < p or not_reserved > limit: + break + p = not_reserved + probes += 1 + if PortResolver.can_bind_local_port(p): + return p + p += 1 + return candidate # --- Client operations (string id) --------------------------------------- @@ -509,8 +540,8 @@ static func invalidate_uvx_cli_cache() -> void: ## Thread safety: `CliFinder.invalidate()` guards `_cache` / `_searched` ## with a mutex so it can race safely against worker threads calling ## `find()` from `_run_client_action_worker`. The mutex is held only -## across the dictionary clear, never across `OS.execute`, so this call -## can never block the main thread on a subprocess. +## across the dictionary clear, never across the bounded subprocess lookup, +## so this call can never block the main thread on a subprocess. static func invalidate_cli_cache() -> void: CliFinder.invalidate() @@ -521,10 +552,9 @@ static var _uv_version_searched: bool = false ## Cached for the editor session. The dock's `_refresh_setup_status` ## (called via `call_deferred` from `_build_ui`) calls this on the -## main thread in user mode, so a single cold `OS.execute(uvx, -## ["--version"])` adds ~80 ms to the dock's first paint on Linux and -## more on Windows. Subsequent calls (focus-in refresh, manual Refresh -## clicks) reuse the cached string. +## main thread in user mode, so the cold `uvx --version` probe is +## wall-clock bounded and cached. Subsequent calls (focus-in refresh, +## manual Refresh clicks) reuse the cached string. ## ## Invalidate via `invalidate_uv_version_cache()` when the user ## installs / reinstalls uv via the dock so the next refresh reflects @@ -539,9 +569,10 @@ static func check_uv_version() -> String: _uv_version_searched = true _uv_version_cache = "" return "" - var output: Array = [] - if OS.execute(uvx, ["--version"], output, true) == 0 and output.size() > 0: - _uv_version_cache = output[0].strip_edges() + var result := McpCliExec.run(uvx, ["--version"], _DISCOVERY_TIMEOUT_MS, false) + if int(result.get("exit_code", -1)) == 0: + var lines := PackedStringArray(str(result.get("stdout", "")).split("\n")) + _uv_version_cache = lines[0].strip_edges() if lines.size() > 0 else "" else: _uv_version_cache = "" _uv_version_searched = true @@ -612,9 +643,12 @@ static func find_worktree_src_dir(start_dir: String) -> String: static func _find_system_install() -> String: var cmd := "which" if OS.get_name() != "Windows" else "where" - var output: Array = [] - if OS.execute(cmd, ["godot-ai"], output, true) == 0 and output.size() > 0: - var found: String = output[0].strip_edges() + var result := McpCliExec.run(cmd, ["godot-ai"], _DISCOVERY_TIMEOUT_MS, false) + if int(result.get("exit_code", -1)) == 0: + var lines := PackedStringArray(str(result.get("stdout", "")).split("\n")) + if lines.is_empty(): + return "" + var found := CliFinder._pick_best_path(lines) if OS.get_name() == "Windows" else lines[0].strip_edges() if not found.is_empty(): return found return "" diff --git a/addons/godot_ai/clients/_cli_exec.gd b/addons/godot_ai/clients/_cli_exec.gd index 3ea83ac..2feaece 100644 --- a/addons/godot_ai/clients/_cli_exec.gd +++ b/addons/godot_ai/clients/_cli_exec.gd @@ -34,6 +34,7 @@ extends RefCounted const DEFAULT_TIMEOUT_MS := 8000 const _POLL_INTERVAL_MS := 50 +const _KILL_GRACE_MS := 500 static func run( @@ -44,6 +45,21 @@ static func run( ) -> Dictionary: if exe.is_empty(): return _spawn_failed_result() + if _uses_blocking_legacy_path(): + ## Godot 4.3 keeps the old blocking path because execute_with_pipe + ## capture/exit semantics differ there. The bounded timeout/kill + ## behavior is available on Godot 4.4+ only. + return _run_blocking_legacy(exe, args) + + return _run_piped(exe, args, timeout_ms, capture_stderr) + + +static func _run_piped( + exe: String, + args: Array, + timeout_ms: int, + capture_stderr: bool, +) -> Dictionary: var spawn_exe := exe var spawn_args := args @@ -76,12 +92,18 @@ static func run( var deadline := Time.get_ticks_msec() + maxi(timeout_ms, _POLL_INTERVAL_MS) while OS.is_process_running(pid): if Time.get_ticks_msec() >= deadline: - ## Read whatever made it to the pipes before we kill the - ## process — partial output beats blank "timed out" when the - ## CLI was emitting useful diagnostics on its way to hanging. - var partial_stdout := _drain_pipe(stdio) - var partial_stderr := _drain_pipe(stderr_pipe) if capture_stderr else "" + ## Kill before draining: a pipe read can block while the child is + ## still alive. Once it exits, drain any buffered partial output. OS.kill(pid) + var kill_deadline := Time.get_ticks_msec() + _KILL_GRACE_MS + while OS.is_process_running(pid) and Time.get_ticks_msec() < kill_deadline: + OS.delay_msec(_POLL_INTERVAL_MS) + + var partial_stdout := "" + var partial_stderr := "" + if not OS.is_process_running(pid): + partial_stdout = _drain_pipe(stdio) + partial_stderr = _drain_pipe(stderr_pipe) if capture_stderr else "" _close_pipes(stdio, stderr_pipe) return { "exit_code": -1, @@ -107,6 +129,27 @@ static func run( } +static func _run_blocking_legacy(exe: String, args: Array) -> Dictionary: + ## Godot 4.3's OS.execute_with_pipe has capture/exit-code differences + ## locked by the 4.3 canary skips in test_cli_exec.gd. Preserve the old + ## blocking discovery behavior there so startup-critical probes keep the + ## same semantics that worked before the bounded-pipe path landed. + var output: Array = [] + var exit_code := OS.execute(exe, args, output, true) + var lines := PackedStringArray() + for line in output: + lines.append(str(line)) + var stdout := "\n".join(lines) + return { + "exit_code": exit_code, + "stdout": stdout, + "stderr": "", + "output": stdout, + "timed_out": false, + "spawn_failed": exit_code == -1, + } + + static func _spawn_failed_result() -> Dictionary: return { "exit_code": -1, @@ -119,9 +162,19 @@ static func _spawn_failed_result() -> Dictionary: static func _drain_pipe(pipe: Variant) -> String: - if pipe is FileAccess: - return (pipe as FileAccess).get_as_text() - return "" + if not (pipe is FileAccess): + return "" + var f := pipe as FileAccess + var bytes := PackedByteArray() + var max_bytes := 1 << 20 # 1 MiB, far above expected client CLI output. + while bytes.size() < max_bytes: + var chunk := f.get_buffer(mini(4096, max_bytes - bytes.size())) + if chunk.is_empty(): + break + bytes.append_array(chunk) + if f.eof_reached(): + break + return bytes.get_string_from_utf8() static func _join_streams(stdout: String, stderr_text: String) -> String: @@ -141,3 +194,10 @@ static func _close_pipes(stdio: Variant, stderr_pipe: Variant) -> void: (stdio as FileAccess).close() if stderr_pipe is FileAccess: (stderr_pipe as FileAccess).close() + + +static func _uses_blocking_legacy_path() -> bool: + var version := Engine.get_version_info() + var major := int(version.get("major", 4)) + var minor := int(version.get("minor", 0)) + return major < 4 or (major == 4 and minor < 4) diff --git a/addons/godot_ai/clients/_cli_finder.gd b/addons/godot_ai/clients/_cli_finder.gd index 5c6c7af..dc8d1f1 100644 --- a/addons/godot_ai/clients/_cli_finder.gd +++ b/addons/godot_ai/clients/_cli_finder.gd @@ -14,7 +14,7 @@ extends RefCounted ## the main thread (manual Refresh path). Godot `Dictionary` is not safe for ## concurrent mutation, so `_cache` / `_searched` access is guarded by ## `_mutex`. The mutex is held only across dictionary read/write — the slow -## `_resolve()` path (FileAccess + `OS.execute`) runs unlocked, so a +## `_resolve()` path (FileAccess + bounded subprocess lookup) runs unlocked, so a ## main-thread `invalidate()` can never block on a worker's subprocess. ## Two workers racing the same exe both call `_resolve()` and both write ## back the same answer; that's wasted work, not corruption. @@ -24,6 +24,8 @@ static var _mutex: Mutex = Mutex.new() static var _cache: Dictionary = {} # exe_name -> resolved path (or "") static var _searched: Dictionary = {} +const _LOOKUP_TIMEOUT_MS := 3000 + ## Find any of the supplied exe names; returns the first hit. ## On Windows pass the .exe variant in `exe_names` if relevant. @@ -54,8 +56,8 @@ static func _find_one(exe_name: String) -> String: _mutex.unlock() if already_searched: return cached - # `_resolve()` does FileAccess + `OS.execute` (forks `bash -lc` / - # `which`), which can take 100ms-1s. Holding the mutex across that + # `_resolve()` does FileAccess + bounded subprocess lookup (forks + # `bash -lc` / `which`), which can take 100ms-1s. Holding the mutex across that # would let a concurrent `invalidate()` on the main thread freeze the # editor for the duration of the subprocess — which defeats the whole # point of running CLI lookup off the main thread. @@ -81,20 +83,19 @@ static func _resolve(exe_name: String) -> String: var shell := OS.get_environment("SHELL") if shell.is_empty(): shell = "/bin/bash" - var login_output: Array = [] var stripped := exe_name.trim_suffix(".exe") - var login_exit := OS.execute(shell, ["-lc", "command -v %s" % stripped], login_output, true) - if login_exit == 0 and login_output.size() > 0: - var login_found: String = login_output[0].strip_edges() + var login_result := McpCliExec.run(shell, ["-lc", "command -v %s" % stripped], _LOOKUP_TIMEOUT_MS, false) + if int(login_result.get("exit_code", -1)) == 0: + var login_found: String = str(login_result.get("stdout", "")).strip_edges() if not login_found.is_empty() and FileAccess.file_exists(login_found): return login_found # 3. which / where with inherited PATH var lookup := "where" if is_windows else "which" - var output: Array = [] - var exit_code := OS.execute(lookup, [exe_name], output, true) - if exit_code == 0 and output.size() > 0: - var lines := PackedStringArray(output[0].split("\n")) + var result := McpCliExec.run(lookup, [exe_name], _LOOKUP_TIMEOUT_MS, false) + if int(result.get("exit_code", -1)) == 0: + var output := str(result.get("stdout", "")) + var lines := PackedStringArray(output.split("\n")) var found := _pick_best_path(lines) if is_windows else lines[0].strip_edges() if not found.is_empty(): return found diff --git a/addons/godot_ai/debugger/mcp_debugger_plugin.gd b/addons/godot_ai/debugger/mcp_debugger_plugin.gd index 7863f7a..a5b232a 100644 --- a/addons/godot_ai/debugger/mcp_debugger_plugin.gd +++ b/addons/godot_ai/debugger/mcp_debugger_plugin.gd @@ -64,6 +64,7 @@ const EVAL_PROBE_INTERVAL_SEC := 0.35 var _log_buffer: McpLogBuffer var _game_log_buffer: McpGameLogBuffer +var _editor_log_buffer: McpEditorLogBuffer ## Pending request_id -> {connection, timer, timeout_callable}. ## We retain the bound timeout lambda so `_clear_pending` can disconnect @@ -80,12 +81,16 @@ var _game_run_token := 0 var _ready_run_token := -1 var _game_session_id := -1 var _game_run_active := false +var _game_run_started_msec := 0 +var _game_run_started_editor_cursor := 0 +var _game_helper_expected := true signal game_ready -func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null) -> void: +func _init(log_buffer: McpLogBuffer = null, game_log_buffer: McpGameLogBuffer = null, editor_log_buffer: McpEditorLogBuffer = null) -> void: _log_buffer = log_buffer _game_log_buffer = game_log_buffer + _editor_log_buffer = editor_log_buffer func _has_capture(prefix: String) -> bool: @@ -107,14 +112,23 @@ func _setup_session(session_id: int) -> void: _game_session_id = session_id -func begin_game_run() -> void: +func begin_game_run(editor_log_cursor: int = 0, helper_expected: bool = true) -> void: _game_run_token += 1 _game_run_active = true _game_ready = false _ready_run_token = -1 _game_session_id = -1 + _game_run_started_msec = Time.get_ticks_msec() + _game_run_started_editor_cursor = maxi(0, editor_log_cursor) + _game_helper_expected = helper_expected + var run_id := "" + if _game_log_buffer: + run_id = _game_log_buffer.clear_for_new_run() if _log_buffer: - _log_buffer.log("[debug] game capture pending run token %d" % _game_run_token) + var log_text := "[debug] game capture pending run token %d" % _game_run_token + if not run_id.is_empty(): + log_text += " (run %s)" % run_id + _log_buffer.log(log_text) func end_game_run() -> void: @@ -128,6 +142,158 @@ func is_game_capture_ready() -> bool: return _game_run_active and _game_ready and _ready_run_token == _game_run_token +static func with_liveness_flags(status: Dictionary) -> Dictionary: + var enriched := status.duplicate(true) + var state := str(enriched.get("status", "stopped")) + enriched["helper_live"] = state == "live" + enriched["session_active"] = not state in ["not_live", "stopped"] + return enriched + + +func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary: + var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec + var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0)) + var elapsed_msec := maxi(0, resolved_now - _game_run_started_msec) if _game_run_active else 0 + ## "stopped" also covers idle/never-ran; no game run is currently active. + var status := "stopped" + if _game_run_active: + if is_game_capture_ready(): + status = "live" + elif not _game_helper_expected: + status = "no_helper" + elif elapsed_msec >= ready_wait_msec: + status = "not_live" + else: + status = "launching" + return with_liveness_flags({ + "status": status, + "run_token": _game_run_token, + "active": _game_run_active, + "ready": is_game_capture_ready(), + "helper_expected": _game_helper_expected, + "run_started_msec": _game_run_started_msec, + "elapsed_msec": elapsed_msec, + "ready_wait_msec": ready_wait_msec, + "editor_log_cursor": _game_run_started_editor_cursor, + }) + + +func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary: + var state := str(status.get("status", "stopped")) + var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0))) + var recent_errors: Array = errors_info.get("errors", []) + var recent_errors_scope := str(errors_info.get("scope", "none")) + var truncated := bool(errors_info.get("truncated", false)) + var data := { + "game_status": status.duplicate(true), + "recent_errors": recent_errors, + "recent_errors_scope": recent_errors_scope, + "recent_errors_may_predate_run": recent_errors_scope == "retained_recent", + "recent_errors_truncated": truncated, + } + var message := "" + match state: + "not_live": + if not recent_errors.is_empty() and recent_errors_scope == "run": + message = "The game failed to load or crashed 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: + message += " Editor logs since this run may be truncated; showing retained errors." + elif not recent_errors.is_empty(): + message = "The game is not responding and reported no load errors during this run. 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: + message = "The game is not responding and reported no load errors 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 retry." + "no_helper": + message = "The running game has no _mcp_game_helper autoload, so game-side tools cannot connect. If this is a headless or custom-main-loop project, use editor_screenshot(source='viewport') where applicable. Otherwise, re-enable the plugin and relaunch the game." + "launching": + message = "The game is still starting (%.1fs elapsed); the Godot AI game helper has not registered yet. Retry shortly." % (float(status.get("elapsed_msec", 0)) / 1000.0) + "stopped": + message = "The game is not running. Start the project and retry the game-side tool." + _: + message = "The game-side tool could not confirm the game is live (status=%s). Check logs_read(source='editor', include_details=true) and retry." % state + var err := ErrorCodes.make(code, message) + var inner: Dictionary = err.get("error", {}) + inner["data"] = data + err["error"] = inner + return err + + +func recent_editor_errors_since(cursor: int) -> Dictionary: + return _recent_editor_errors_since(cursor) + + +func _recent_editor_errors_since(cursor: int) -> Dictionary: + var out: Array[Dictionary] = [] + var truncated := false + if _editor_log_buffer == null: + return {"errors": out, "truncated": false, "scope": "none"} + var captured: Dictionary = _editor_log_buffer.get_since(maxi(0, cursor), -1) + truncated = bool(captured.get("truncated", false)) + for raw_entry in captured.get("entries", []): + var compact := _compact_editor_error(raw_entry) + if compact.is_empty(): + continue + out.append(compact) + if out.size() >= 5: + break + if not out.is_empty(): + return {"errors": out, "truncated": truncated, "scope": "run"} + + for raw_entry in _reversed_entries(_editor_log_buffer.get_recent(McpEditorLogBuffer.MAX_LINES)): + var compact := _compact_editor_error(raw_entry, true) + if compact.is_empty(): + continue + out.append(compact) + if out.size() >= 5: + break + if not out.is_empty(): + return {"errors": out, "truncated": false, "scope": "retained_recent"} + return {"errors": out, "truncated": false, "scope": "none"} + + +func _compact_editor_error(raw_entry: Variant, fallback_recent: bool = false) -> Dictionary: + if not raw_entry is Dictionary: + return {} + var entry := raw_entry as Dictionary + if str(entry.get("level", "info")) != "error": + return {} + var path := str(entry.get("path", "")) + if fallback_recent and _is_diagnostic_noise_path(path): + return {} + var compact := { + "source": "editor", + "level": "error", + "text": str(entry.get("text", "")), + "path": path, + "line": int(entry.get("line", 0)), + "function": str(entry.get("function", "")), + } + if entry.has("details"): + compact["details"] = entry["details"].duplicate(true) + return compact + + +func _is_diagnostic_noise_path(path: String) -> bool: + return path.begins_with("res://addons/godot_ai/") or path.begins_with("res://tests/") + + +func _reversed_entries(entries: Array[Dictionary]) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for i in range(entries.size() - 1, -1, -1): + out.append(entries[i]) + return out + + +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 _capture(message: String, data: Array, session_id: int) -> bool: ## Godot passes the full "prefix:tail" string as `message`. match message: @@ -152,18 +318,15 @@ func _capture(message: String, data: Array, session_id: int) -> bool: ## Boot beacon from the game-side autoload. Tells us the ## game has registered its "mcp" capture and is safe to send ## take_screenshot to — before this, Godot's debugger would - ## drop our message silently. Also marks a fresh play - ## cycle: rotate the game-log buffer so each run starts - ## clean and gets a new run_id. + ## drop our message silently. _game_ready = true _ready_run_token = _game_run_token game_ready.emit() - if _game_log_buffer: - var run_id := _game_log_buffer.clear_for_new_run() - if _log_buffer: - _log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % run_id) - elif _log_buffer: - _log_buffer.log("[debug] <- mcp:hello from game_helper") + if _log_buffer: + if _game_log_buffer: + _log_buffer.log("[debug] <- mcp:hello from game_helper (run %s)" % _game_log_buffer.run_id()) + else: + _log_buffer.log("[debug] <- mcp:hello from game_helper") return true "mcp:eval_response": _on_eval_response(data) @@ -262,8 +425,8 @@ func _wait_then_send( while not is_game_capture_ready() and Time.get_ticks_msec() < deadline: await tree.process_frame if not is_game_capture_ready(): - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Game-side autoload never registered its debugger capture within %ds. Is the game actually running? Check Project Settings → Autoload for _mcp_game_helper." % int(GAME_READY_WAIT_SEC)) + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) return _send_take_screenshot(tree, request_id, max_resolution, connection, timeout_sec) @@ -357,16 +520,23 @@ func _on_timeout(request_id: String) -> void: var connection: McpConnection = pending.connection if connection == null or not is_instance_valid(connection): return - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Game screenshot timed out. The running game must include the _mcp_game_helper autoload (added automatically when the plugin is enabled — check Project Settings → Autoload). If the autoload is missing, re-enable the plugin and relaunch the game. For headless or custom-main-loop builds, use source='viewport' instead.") + var status := get_game_status(-1, GAME_READY_WAIT_SEC) + var err := ErrorCodes.make(ErrorCodes.INTERNAL_ERROR, + "Game screenshot timed out after reaching the game helper. The game may be busy or unable to render a frame. Check logs_read(source='game') and retry.") + if status.get("status", "") != "live": + err = _explain_not_live(status, ErrorCodes.INTERNAL_ERROR) + _send_error_response(connection, request_id, err) if _log_buffer: _log_buffer.log("[debug] !! screenshot timeout (%s)" % request_id) func _send_error(connection: McpConnection, request_id: String, code: String, message: String) -> void: + _send_error_response(connection, request_id, ErrorCodes.make(code, message)) + + +func _send_error_response(connection: McpConnection, request_id: String, err: Dictionary) -> void: if connection == null or not is_instance_valid(connection): return - var err := ErrorCodes.make(code, message) connection.send_deferred_response(request_id, err) @@ -447,8 +617,8 @@ func _wait_then_eval( ## but the game-side capture didn't register within the short wait. Fast ## and caller-actionable; classifying it apart from the opaque 10s hang ## keeps the INTERNAL_ERROR telemetry bucket meaning "the eval truly hung". - _send_error(connection, request_id, ErrorCodes.EVAL_GAME_NOT_READY, - "Game-side capture didn't register within %ds. The play session is already running, so the game is most likely still booting — wait a moment and retry. If it persists, the _mcp_game_helper autoload is missing or disabled (Project Settings → Autoload; added automatically when the plugin is enabled), or the game uses a custom main loop." % int(EVAL_READY_WAIT_SEC)) + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, EVAL_READY_WAIT_SEC), ErrorCodes.EVAL_GAME_NOT_READY)) return _send_eval(tree, code, request_id, connection, timeout_sec) @@ -704,8 +874,8 @@ func _wait_then_game_command( while not is_game_capture_ready() and Time.get_ticks_msec() < deadline: await tree.process_frame if not is_game_capture_ready(): - _send_error(connection, request_id, ErrorCodes.INTERNAL_ERROR, - "Game-side autoload never registered its debugger capture within %ds. Is the game actually running?" % int(GAME_READY_WAIT_SEC)) + _send_error_response(connection, request_id, + _explain_not_live(get_game_status(-1, GAME_READY_WAIT_SEC), ErrorCodes.INTERNAL_ERROR)) return _send_game_command(tree, op, params, request_id, connection, timeout_sec) diff --git a/addons/godot_ai/dispatcher.gd b/addons/godot_ai/dispatcher.gd index baf2f12..1634e90 100644 --- a/addons/godot_ai/dispatcher.gd +++ b/addons/godot_ai/dispatcher.gd @@ -16,6 +16,7 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500 const DEFERRED_TIMEOUT_MS_BY_COMMAND := { "create_script": 4500, "stop_project": 4500, + "run_project": 6000, "take_screenshot": 30000, "game_eval": 15000, "game_command": 15000, diff --git a/addons/godot_ai/handlers/editor_handler.gd b/addons/godot_ai/handlers/editor_handler.gd index c7acd3e..97c673b 100644 --- a/addons/godot_ai/handlers/editor_handler.gd +++ b/addons/godot_ai/handlers/editor_handler.gd @@ -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, } } diff --git a/addons/godot_ai/handlers/node_handler.gd b/addons/godot_ai/handlers/node_handler.gd index 55dc662..0a4d67e 100644 --- a/addons/godot_ai/handlers/node_handler.gd +++ b/addons/godot_ai/handlers/node_handler.gd @@ -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 diff --git a/addons/godot_ai/handlers/project_handler.gd b/addons/godot_ai/handlers/project_handler.gd index 9511bac..93b6315 100644 --- a/addons/godot_ai/handlers/project_handler.gd +++ b/addons/godot_ai/handlers/project_handler.gd @@ -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 diff --git a/addons/godot_ai/handlers/resource_handler.gd b/addons/godot_ai/handlers/resource_handler.gd index 4cb8253..effc00f 100644 --- a/addons/godot_ai/handlers/resource_handler.gd +++ b/addons/godot_ai/handlers/resource_handler.gd @@ -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 diff --git a/addons/godot_ai/handlers/signal_handler.gd b/addons/godot_ai/handlers/signal_handler.gd index ad214fc..fe90638 100644 --- a/addons/godot_ai/handlers/signal_handler.gd +++ b/addons/godot_ai/handlers/signal_handler.gd @@ -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)} diff --git a/addons/godot_ai/mcp_dock.gd b/addons/godot_ai/mcp_dock.gd index bd02190..59e148c 100644 --- a/addons/godot_ai/mcp_dock.gd +++ b/addons/godot_ai/mcp_dock.gd @@ -40,8 +40,17 @@ const LogViewerScript := preload("res://addons/godot_ai/dock_panels/log_viewer.g const PortPickerPanelScript := preload("res://addons/godot_ai/dock_panels/port_picker_panel.gd") const DEV_MODE_SETTING := "godot_ai/dev_mode" +## "Change the port + reconfigure your clients" guide. Surfaced from the crash +## panel when a foreign process holds the HTTP port — the one piece of recovery +## (per-client config rewrite) that doesn't fit in the inline crash body. +## Resolved against the installed plugin version at click time (see +## `_port_conflict_docs_url`) so a shipped build opens the guide as it shipped, +## not tip-of-main, which may have drifted from that build's UI. +const PORT_CONFLICT_DOCS_PATH := "docs/port-conflicts.md" +const REPO_BLOB_BASE := "https://github.com/hi-godot/godot-ai/blob" const CLIENT_STATUS_REFRESH_COOLDOWN_MSEC := 15 * 1000 const CLIENT_STATUS_REFRESH_TIMEOUT_MSEC := 30 * 1000 +const CLIENT_ACTION_TIMEOUT_MSEC := 30 * 1000 static var COLOR_MUTED := Color(0.7, 0.7, 0.7) static var COLOR_HEADER := Color(0.95, 0.95, 0.95) ## Used for "in-progress" / "stale, action needed" UI: the startup-grace @@ -60,12 +69,13 @@ var _status_icon: ColorRect var _status_label: Label var _client_grid: VBoxContainer var _client_configure_all_btn: Button +var _client_empty_cta_btn: Button var _clients_summary_label: Label var _clients_window: Window var _dev_mode_toggle: CheckButton var _install_label: Label -# Settings tab (secondary window, Tab 2) — domain-exclusion UI for clients +# Tools tab (secondary window, Tab 2) — domain-exclusion UI for clients # that cap total tool count (Antigravity: 100). Pending set is mutated by # checkbox clicks; saved set reflects what the spawned server actually # sees. `Apply & Restart Server` writes pending → setting and triggers a @@ -147,23 +157,24 @@ static var _orphaned_client_status_refresh_threads: Array[Thread] = [] ## Per-row worker state for Configure / Remove. Issue #239: shelling out ## to a hung CLI on main hangs the editor. We dispatch each click to its -## own thread (one slot per client) and apply the result via call_deferred -## once the subprocess returns or the wall-clock budget in McpCliExec -## kicks in. The buttons stay disabled while the slot is busy so the user -## can't queue a re-click on the same row. +## own thread (one slot per client), then `_process` reaps completed workers +## and applies returned payloads on main. The buttons stay disabled while +## the slot is busy so the user can't queue a re-click on the same row. ## ## Per-client (not single-slot) so Configure-all can fan out — the ## workers are independent, only the row UI is shared, and McpCliExec ## bounds the wall-clock for each. ## -## No orphan-thread list (unlike the refresh worker): action threads -## never get abandoned mid-flight. McpCliExec's wall-clock budget caps -## the worst case at ~10s, so the `_exit_tree` / `McpUpdateManager` -## install-time drain blocks briefly and finishes — there's no path that -## "gives up" on an action thread the way `_abandon_client_status_refresh_thread` -## does for the refresh worker. +## A watchdog can abandon a slot when a worker fails to report completion. +## The thread object is retained in `_orphaned_client_action_threads` until +## it finishes so GDScript does not destroy a live Thread object. var _client_action_threads: Dictionary = {} var _client_action_generations: Dictionary = {} +var _client_action_started_msec: Dictionary = {} +var _client_action_names: Dictionary = {} +## Timed-out Configure/Remove workers are abandoned but retained here until +## they finish, so GDScript does not destroy a live Thread object. +static var _orphaned_client_action_threads: Array[Thread] = [] # Dev-mode only var _dev_section: VBoxContainer @@ -193,6 +204,11 @@ var _crash_panel: VBoxContainer var _crash_output: RichTextLabel var _crash_restart_btn: Button var _crash_reload_btn: Button +## Help link — visible only for the genuinely-foreign-occupant INCOMPATIBLE +## case (no `can_recover_incompatible` proof). The inline body names a free +## port; this button carries the per-client reconfigure steps that don't fit +## inline. See `PORT_CONFLICT_DOCS` and `_update_crash_panel`. +var _crash_docs_btn: Button ## Port-picker escape hatch — visible inside the crash panel when the root ## cause is port contention (PORT_EXCLUDED or FOREIGN_PORT). The dock writes ## the EditorSetting and reloads the plugin in response to the panel's @@ -238,10 +254,14 @@ func _ready() -> void: func _process(_delta: float) -> void: + _prune_orphaned_client_status_refresh_threads() + _prune_orphaned_client_action_threads() + _poll_completed_client_status_refresh_thread() + _poll_completed_client_action_threads() + _check_client_status_refresh_timeout() + _check_client_action_timeouts() if _connection == null: return - _prune_orphaned_client_status_refresh_threads() - _check_client_status_refresh_timeout() _retry_deferred_client_status_refresh() _update_status() if _log_viewer != null and _log_viewer.visible: @@ -275,6 +295,8 @@ func _exit_tree() -> void: ## drains directly because it has additional state-machine work ## (SHUTTING_DOWN sticky-set) that the install-time path must NOT inherit. func prepare_for_self_update_drain() -> void: + _poll_completed_client_status_refresh_thread() + _poll_completed_client_action_threads() _drain_client_status_refresh_workers() _drain_client_action_workers() @@ -309,13 +331,13 @@ func _drain_client_action_workers() -> void: ## plugin disable / install-update path reloads our script class, so any ## live Thread must finish before its slot is GC'd or we hit ## `~Thread … destroyed without its completion having been realized` → - ## VM corruption. Bounded by `McpCliExec` wall-clock budgets, so the - ## worst case is a ~10s blocking drain, vs. an unbounded SIGSEGV. + ## VM corruption. Normal UI recovery is handled by the per-row watchdog; + ## teardown still blocks because GDScript's Thread API has no kill/timeout + ## primitive and destroying a live Thread corrupts the VM. ## - ## Generation-bumped per-row so any pending `call_deferred( - ## "_apply_client_action_result")` from a worker that finished after we - ## started draining detects the generation mismatch and short-circuits - ## without touching freed UI state. + ## Generation-bumped per-row so any result from a worker that finished + ## after we started draining detects the generation mismatch and + ## short-circuits without touching freed UI state. ## ## After draining, restore the row UI for any in-flight rows: bare ## `_client_action_threads.clear()` would leave the dock stuck showing @@ -328,6 +350,8 @@ func _drain_client_action_workers() -> void: if t != null: t.wait_to_finish() _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) _finalize_action_buttons(String(client_id)) var row: Dictionary = _client_rows.get(String(client_id), {}) if not row.is_empty(): @@ -337,6 +361,71 @@ func _drain_client_action_workers() -> void: "" ) _client_action_threads.clear() + for thread in _orphaned_client_action_threads: + if thread != null: + thread.wait_to_finish() + _orphaned_client_action_threads.clear() + _client_action_started_msec.clear() + _client_action_names.clear() + + +func _check_client_action_timeouts() -> void: + var now := Time.get_ticks_msec() + for client_id in _client_action_threads.keys(): + if not _client_action_started_msec.has(client_id): + continue + var started := int(_client_action_started_msec.get(client_id, 0)) + if now - started >= CLIENT_ACTION_TIMEOUT_MSEC: + _abandon_client_action_thread(String(client_id)) + + +func _abandon_client_action_thread(client_id: String) -> void: + if not _client_action_threads.has(client_id): + return + var thread: Thread = _client_action_threads[client_id] + var elapsed := Time.get_ticks_msec() - int(_client_action_started_msec.get(client_id, Time.get_ticks_msec())) + var worker_alive := thread != null and thread.is_alive() + if thread != null: + _orphaned_client_action_threads.append(thread) + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + var action := str(_client_action_names.get(client_id, "configure")) + _client_action_names.erase(client_id) + _client_action_generations[client_id] = int(_client_action_generations.get(client_id, 0)) + 1 + _finalize_action_buttons(client_id) + print("MCP | client action timed out: client=%s action=%s elapsed_ms=%d worker_alive=%s" % [ + client_id, + action, + elapsed, + str(worker_alive), + ]) + var label := "Remove" if action == "remove" else "Configure" + _apply_row_status( + client_id, + Client.Status.ERROR, + "%s did not report completion in time; refreshing current status." % label + ) + _refresh_clients_summary() + if is_inside_tree(): + _request_client_status_refresh(true) + + +func _prune_orphaned_client_action_threads() -> void: + var completed_orphan := false + for i in range(_orphaned_client_action_threads.size() - 1, -1, -1): + var thread := _orphaned_client_action_threads[i] + if thread == null: + _orphaned_client_action_threads.remove_at(i) + elif not thread.is_alive(): + thread.wait_to_finish() + _orphaned_client_action_threads.remove_at(i) + completed_orphan = true + if completed_orphan and is_inside_tree(): + _request_client_action_completion_refresh() + + +func _request_client_action_completion_refresh() -> void: + _request_client_status_refresh(true) func _notification(what: int) -> void: @@ -473,6 +562,13 @@ func _build_ui() -> void: _crash_reload_btn.pressed.connect(_on_reload_plugin) _crash_panel.add_child(_crash_reload_btn) + _crash_docs_btn = Button.new() + _crash_docs_btn.text = "How to change the port" + _crash_docs_btn.tooltip_text = "Open the guide: change godot_ai/http_port and reconfigure your MCP clients" + _crash_docs_btn.visible = false + _crash_docs_btn.pressed.connect(func(): OS.shell_open(_port_conflict_docs_url())) + _crash_panel.add_child(_crash_docs_btn) + _crash_panel.add_child(HSeparator.new()) add_child(_crash_panel) @@ -558,30 +654,45 @@ func _build_ui() -> void: add_child(HSeparator.new()) # --- Clients --- - var clients_row := HBoxContainer.new() - clients_row.add_theme_constant_override("separation", 8) + var clients_header_row := HBoxContainer.new() + clients_header_row.add_theme_constant_override("separation", 8) var clients_header := _make_header("Clients") - clients_row.add_child(clients_header) + clients_header_row.add_child(clients_header) _clients_summary_label = Label.new() _clients_summary_label.add_theme_color_override("font_color", COLOR_MUTED) + _clients_summary_label.clip_text = true + _clients_summary_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS _clients_summary_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - clients_row.add_child(_clients_summary_label) + clients_header_row.add_child(_clients_summary_label) + + var clients_actions := HFlowContainer.new() + clients_actions.add_theme_constant_override("h_separation", 8) + clients_actions.add_theme_constant_override("v_separation", 4) var clients_refresh_btn := Button.new() clients_refresh_btn.text = "Refresh" clients_refresh_btn.tooltip_text = "Refresh client status in the background. Cached status stays visible while checks run." clients_refresh_btn.pressed.connect(_on_refresh_clients_pressed) - clients_row.add_child(clients_refresh_btn) + clients_actions.add_child(clients_refresh_btn) var clients_open_btn := Button.new() - clients_open_btn.text = "Clients & Settings" - clients_open_btn.tooltip_text = "Open the MCP settings window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)." + clients_open_btn.text = "Clients & Tools" + clients_open_btn.tooltip_text = "Open the Clients & Tools window — configure AI clients, choose telemetry preferences, or disable tool domains to fit under a client's hard tool-count cap (e.g. Antigravity's 100)." clients_open_btn.pressed.connect(_on_open_clients_window) - clients_row.add_child(clients_open_btn) + clients_actions.add_child(clients_open_btn) - add_child(clients_row) + add_child(clients_header_row) + add_child(clients_actions) + + _client_empty_cta_btn = Button.new() + _client_empty_cta_btn.text = "Configure an AI client ->" + _client_empty_cta_btn.tooltip_text = "Open the Clients tab to configure an AI coding client for this Godot AI server." + _client_empty_cta_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _client_empty_cta_btn.visible = false + _client_empty_cta_btn.pressed.connect(_on_open_clients_window) + add_child(_client_empty_cta_btn) # Drift banner — hidden until a sweep finds at least one mismatched client. _drift_banner = VBoxContainer.new() @@ -600,7 +711,7 @@ func _build_ui() -> void: add_child(_drift_banner) _clients_window = Window.new() - _clients_window.title = "MCP Clients & Settings" + _clients_window.title = "Godot AI" ## `Vector2i * float` yields Vector2; wrap the result back to Vector2i. _clients_window.min_size = Vector2i(Vector2(560, 460) * EditorInterface.get_editor_scale()) _clients_window.visible = false @@ -758,7 +869,7 @@ func _build_client_row(client_id: String) -> void: # --- Status updates --- func _update_status() -> void: - var connected: bool = _connection.is_connected + var connected: bool = _connection != null and _connection.is_connected ## During plugin self-update there's a brief window where this dock ## script is already the new version (Godot hot-reloads scripts on ## file change) but `_plugin` is still the old `EditorPlugin` instance @@ -785,7 +896,7 @@ func _update_status() -> void: status_text = "Restarting server..." status_color = COLOR_AMBER elif connected: - status_text = "Connected" + status_text = _connected_status_text() status_color = Color.GREEN elif state == ServerStateScript.CRASHED: var exit_ms: int = server_status.get("exit_ms", 0) @@ -858,6 +969,15 @@ func _update_crash_panel(server_status: Dictionary) -> void: not show_recovery_restart and state != ServerStateScript.INCOMPATIBLE ) + ## Docs link only for the genuinely-foreign occupant: a recoverable + ## (older godot-ai) server gets Restart Server instead, and the inline + ## body already names a free port — the link carries the per-client + ## reconfigure steps that don't fit inline. + if _crash_docs_btn != null: + _crash_docs_btn.visible = ( + state == ServerStateScript.INCOMPATIBLE + and not bool(server_status.get("can_recover_incompatible", false)) + ) var port_picker_visible := ( state == ServerStateScript.PORT_EXCLUDED @@ -887,9 +1007,16 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> if not message.is_empty(): return "%s Click Restart Server below to replace it with godot-ai v%s." % [message, expected] return "Port %d is occupied by an older godot-ai server. Click Restart Server below to replace it with godot-ai v%s." % [port, expected] + ## Genuinely foreign occupant (no recovery proof). Name a concrete + ## free port so the user doesn't have to hunt for one, and let the + ## crash panel's "How to change the port" link carry the per-client + ## reconfigure steps. `suggest_free_port` already routes through the + ## Windows reservation table, so the named port won't itself fail + ## with WinError 10013. + var hint := _free_port_hint(port) if not message.is_empty(): - return message - return "Port %d is occupied by an incompatible server. Stop it or change both HTTP and WS ports." % port + return "%s %s" % [message, hint] + return "Port %d is occupied by an incompatible server. %s" % [port, hint] ServerStateScript.FOREIGN_PORT: return "Another process is already bound to port %d. Pick a free port or stop the other process." % port ServerStateScript.CRASHED: @@ -907,6 +1034,31 @@ static func _crash_body_for_state(state: int, server_status: Dictionary = {}) -> return "" +## One sentence naming concrete free ports for the user to switch to. Names +## BOTH http and ws: this branch also fires for an incompatible godot-ai +## server we can't prove we own, which commonly holds both ports — moving only +## http would then leave the new server unable to bind ws. Both suggestions are +## routed through `suggest_free_port` so they clear Windows' winnat reservation +## table (no point suggesting a port that 10013s on bind). Only the http port +## reaches client configs; the ws port is server↔plugin, hence the wording. +## The per-client reconfigure steps live behind the crash panel's docs link. +static func _free_port_hint(port: int) -> String: + var free_http := ClientConfigurator.suggest_free_port(port + 1) + var free_ws := ClientConfigurator.suggest_free_port(ClientConfigurator.ws_port() + 1) + return "Ports %d (HTTP) and %d (WS) are free — set `godot_ai/http_port` and `godot_ai/ws_port` in Editor Settings, then update your client config with the new HTTP port (How to change the port, below)." % [free_http, free_ws] + + +## URL for the port-conflict guide, pinned to the release tag that matches the +## installed plugin version (releases are tagged `v`). The crash-panel +## button only exists in builds that ship `docs/port-conflicts.md`, so the +## versioned ref always resolves — and a shipped build never points users at a +## tip-of-main guide that has drifted from its own UI. +static func _port_conflict_docs_url() -> String: + var version := ClientConfigurator.get_plugin_version() + var git_ref := ("v%s" % version) if not version.is_empty() else "main" + return "%s/%s/%s" % [REPO_BLOB_BASE, git_ref, PORT_CONFLICT_DOCS_PATH] + + ## Build the mixed-state banner. Hidden until `_refresh_mixed_state_banner` ## confirms `*.update_backup` files exist in the addons tree. Mirrors the ## issue #354 fix shape: structured, agent-readable diagnostic that survives @@ -1480,6 +1632,30 @@ func _update_dev_section_buttons() -> void: _dev_stop_btn.tooltip_text = stop_state["tooltip"] +func _configured_client_count() -> int: + var configured := 0 + for client_id in _client_rows: + var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) + if status == Client.Status.CONFIGURED: + configured += 1 + return configured + + +func _client_status_refresh_has_completed() -> bool: + return _last_client_status_refresh_completed_msec > 0 + + +func _connected_status_text() -> String: + var configured := _configured_client_count() + if configured == 0: + if not _client_status_refresh_has_completed(): + return "Server connected · checking AI client configuration" + return "Server connected · no AI client configured" + if configured == 1: + return "Server connected · 1 AI client configured" + return "Server connected · %d AI clients configured" % configured + + func _on_install_uv() -> void: match OS.get_name(): "Windows": @@ -1548,28 +1724,65 @@ func _dispatch_client_action(client_id: String, action: String) -> void: _client_action_generations[client_id] = generation var thread := Thread.new() _client_action_threads[client_id] = thread + _client_action_started_msec[client_id] = Time.get_ticks_msec() + _client_action_names[client_id] = action var err := thread.start( Callable(self, "_run_client_action_worker").bind(client_id, action, server_url, generation) ) if err != OK: _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) _finalize_action_buttons(client_id) _apply_row_status(client_id, Client.Status.ERROR, "couldn't start worker thread") _refresh_clients_summary() -func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> void: +func _run_client_action_worker(client_id: String, action: String, server_url: String, generation: int) -> Dictionary: var result: Dictionary if action == "remove": result = ClientConfigurator.remove(client_id, server_url) else: result = ClientConfigurator.configure(client_id, server_url) - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - call_deferred("_apply_client_action_result", client_id, action, result, generation) + return { + "client_id": client_id, + "action": action, + "result": result, + "generation": generation, + } + + +func _poll_completed_client_action_threads() -> void: + for client_id in _client_action_threads.keys(): + var thread: Thread = _client_action_threads[client_id] + if thread == null or thread.is_alive(): + continue + var payload: Variant = thread.wait_to_finish() + _client_action_threads[client_id] = null + if payload is Dictionary: + var data := payload as Dictionary + var result: Dictionary = data.get("result", {}) + _apply_client_action_result( + String(data.get("client_id", client_id)), + String(data.get("action", _client_action_names.get(client_id, "configure"))), + result, + int(data.get("generation", _client_action_generations.get(client_id, 0))) + ) + else: + _apply_client_action_result( + String(client_id), + String(_client_action_names.get(client_id, "configure")), + {"status": "error", "message": "worker returned no result"}, + int(_client_action_generations.get(client_id, 0)) + ) func _apply_client_action_result(client_id: String, action: String, result: Dictionary, generation: int) -> void: if int(_client_action_generations.get(client_id, 0)) != generation: + if _client_action_threads.get(client_id, null) == null: + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) return if _refresh_state == ClientRefreshStateScript.SHUTTING_DOWN: return @@ -1577,7 +1790,9 @@ func _apply_client_action_result(client_id: String, action: String, result: Dict var t: Thread = _client_action_threads[client_id] if t != null: t.wait_to_finish() - _client_action_threads.erase(client_id) + _client_action_threads.erase(client_id) + _client_action_started_msec.erase(client_id) + _client_action_names.erase(client_id) _finalize_action_buttons(client_id) if _server_blocks_client_health(): _apply_row_status(client_id, Client.Status.ERROR, _server_blocked_client_message()) @@ -1645,7 +1860,7 @@ func _on_configure_all_clients() -> void: _apply_row_status(String(client_id), Client.Status.ERROR, _server_blocked_client_message()) _refresh_clients_summary() return - if ClientRefreshStateScript.has_worker_alive(_refresh_state): + if ClientRefreshStateScript.should_disable_client_actions(_refresh_state): return for client_id in _client_rows: var status: Client.Status = _client_rows[client_id].get("status", Client.Status.NOT_CONFIGURED) @@ -1700,7 +1915,7 @@ func _build_tools_tab(tabs: TabContainer) -> void: var tools_tab := VBoxContainer.new() tools_tab.add_theme_constant_override("separation", 8) var tools_margin := _build_margin_container() - tools_margin.name = "Settings" + tools_margin.name = "Tools" tools_margin.add_child(tools_tab) tabs.add_child(tools_margin) @@ -1976,8 +2191,11 @@ func _refresh_clients_summary() -> void: ) _clients_summary_label.text = text if _client_configure_all_btn != null: - _client_configure_all_btn.disabled = ClientRefreshStateScript.has_worker_alive(_refresh_state) + _client_configure_all_btn.disabled = ClientRefreshStateScript.should_disable_client_actions(_refresh_state) + if _client_empty_cta_btn != null: + _client_empty_cta_btn.visible = configured == 0 and _client_status_refresh_has_completed() _refresh_drift_banner(mismatched_ids) + _update_status() func _show_manual_command_for(client_id: String) -> void: @@ -2167,8 +2385,8 @@ func _warm_strategy_bytecode() -> void: func _begin_client_status_refresh_run() -> int: ## Marks a refresh as starting and returns the new generation token. - ## Generation is bumped here (not at completion) so that a worker callback - ## arriving after `_abandon_client_status_refresh_thread` or `_exit_tree` + ## Generation is bumped here (not at completion) so that a worker result + ## reaped after `_abandon_client_status_refresh_thread` or `_exit_tree` ## fires can be detected as stale via generation mismatch. _refresh_state = ClientRefreshStateScript.RUNNING _client_status_refresh_pending = false @@ -2181,7 +2399,7 @@ func _begin_client_status_refresh_run() -> int: func _finalize_completed_refresh() -> void: ## Stamps cooldown and clears in-flight state. Called at the end of every - ## refresh that successfully applied results — the worker callback path + ## refresh that successfully applied results — the worker reaping path ## and the no-CLI fast path in `_perform_initial_client_status_refresh`. _last_client_status_refresh_completed_msec = Time.get_ticks_msec() if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: @@ -2308,7 +2526,7 @@ func _retry_deferred_client_status_refresh() -> void: _request_client_status_refresh(force) -func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> void: +func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_url: String, generation: int) -> Dictionary: var results: Dictionary = {} for probe in client_probes: var client_id := String(probe.get("id", "")) @@ -2325,8 +2543,25 @@ func _run_client_status_refresh_worker(client_probes: Array[Dictionary], server_ "installed": installed, "error_msg": details.get("error_msg", ""), } - if _refresh_state != ClientRefreshStateScript.SHUTTING_DOWN: - call_deferred("_apply_client_status_refresh_results", results, generation) + return {"results": results, "generation": generation} + + +func _poll_completed_client_status_refresh_thread() -> void: + if _client_status_refresh_thread == null: + return + if _client_status_refresh_thread.is_alive(): + return + var payload: Variant = _client_status_refresh_thread.wait_to_finish() + _client_status_refresh_thread = null + if payload is Dictionary: + var data := payload as Dictionary + var results: Dictionary = data.get("results", {}) + _apply_client_status_refresh_results( + results, + int(data.get("generation", _client_status_refresh_generation)) + ) + else: + _apply_client_status_refresh_results({}, _client_status_refresh_generation) func _apply_client_status_refresh_results(results: Dictionary, generation: int) -> void: diff --git a/addons/godot_ai/plugin.cfg b/addons/godot_ai/plugin.cfg index 17609e1..dd408ac 100644 --- a/addons/godot_ai/plugin.cfg +++ b/addons/godot_ai/plugin.cfg @@ -3,5 +3,5 @@ name="Godot AI" description="MCP server and AI tools for Godot" author="Godot AI" -version="2.7.6" +version="2.8.1" script="plugin.gd" diff --git a/addons/godot_ai/plugin.gd b/addons/godot_ai/plugin.gd index f3d0e02..3f0d4f6 100644 --- a/addons/godot_ai/plugin.gd +++ b/addons/godot_ai/plugin.gd @@ -233,14 +233,14 @@ func _enter_tree() -> void: _telemetry = Telemetry.new(_connection) - _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer) + _debugger_plugin = DebuggerPlugin.new(_log_buffer, _game_log_buffer, _editor_log_buffer) add_debugger_plugin(_debugger_plugin) _ensure_game_helper_autoload() var editor_handler := EditorHandler.new(_log_buffer, _connection, _debugger_plugin, _game_log_buffer, _editor_log_buffer) var scene_handler := SceneHandler.new(_connection) var node_handler := NodeHandler.new(get_undo_redo()) - var project_handler := ProjectHandler.new(_connection, _debugger_plugin) + var project_handler := ProjectHandler.new(_connection, _debugger_plugin, _editor_log_buffer) var client_handler := ClientHandler.new() var script_handler := ScriptHandler.new(get_undo_redo(), _connection) var resource_handler := ResourceHandler.new(get_undo_redo(), _connection) diff --git a/addons/godot_ai/runtime/loggers/game_logger.gd b/addons/godot_ai/runtime/loggers/game_logger.gd index 3fb8edc..271f3ef 100644 --- a/addons/godot_ai/runtime/loggers/game_logger.gd +++ b/addons/godot_ai/runtime/loggers/game_logger.gd @@ -81,9 +81,9 @@ func _log_error( ## Collect every function name in the first non-empty backtrace so ## game_helper can match its eval's uniquely named wrapper function. var funcs := PackedStringArray() - for bt in script_backtraces: + for bt: RefCounted in script_backtraces: if bt != null and bt.get_frame_count() > 0: - for i in bt.get_frame_count(): + for i: int in bt.get_frame_count(): funcs.append(bt.get_frame_function(i)) break _mutex.lock() diff --git a/addons/godot_ai/utils/game_log_buffer.gd b/addons/godot_ai/utils/game_log_buffer.gd index 268aa17..e3cce01 100644 --- a/addons/godot_ai/utils/game_log_buffer.gd +++ b/addons/godot_ai/utils/game_log_buffer.gd @@ -6,9 +6,8 @@ extends McpStructuredLogRing ## ferried back from the playing game over the EngineDebugger channel. ## ## Larger cap than McpEditorLogBuffer because games can be noisy. `run_id` -## rotates each time clear_for_new_run() fires (called on the game's -## mcp:hello boot beacon), giving agents a stable cursor for "lines since -## this play started". +## rotates at play-start, giving agents a stable cursor for "lines from +## this run" even when the game never reaches the mcp:hello boot beacon. ## ## Single-threaded — game_helper.gd drains its logger from `_process` and ## calls `append` from the main thread, so this subclass can use the base @@ -17,6 +16,7 @@ extends McpStructuredLogRing const MAX_LINES := 2000 var _run_id := "" +var _run_seq := 0 func _init() -> void: @@ -24,17 +24,22 @@ func _init() -> void: func append(level: String, text: String, details: Dictionary = {}) -> void: - var entry := {"source": "game", "level": _coerce_level(level), "text": text} + var entry := { + "source": "game", + "level": _coerce_level(level), + "text": text, + "run_id": _run_id, + } if not details.is_empty(): entry["details"] = details.duplicate(true) _append_entry(entry) -## Rotate the run identifier and drop all buffered entries. Called when the -## game-side autoload sends its mcp:hello beacon, marking a fresh play cycle. -## Returns the new run_id. +## Rotate the run identifier without dropping buffered entries. Called at +## play-start so even no-hello parse failures get a fresh current-run identity. +## Historical lines stay tagged with their original run_id and can still be +## queried explicitly. func clear_for_new_run() -> String: - _clear_storage() _run_id = _generate_run_id() return _run_id @@ -43,8 +48,38 @@ func run_id() -> String: return _run_id -static func _generate_run_id() -> String: +func get_run_range(run_id: String, offset: int, count: int) -> Array[Dictionary]: + return get_run_page(run_id, offset, count).entries + + +func run_total_count(run_id: String) -> int: + return int(get_run_page(run_id, 0, 0).total_count) + + +func get_run_page(run_id: String, offset: int, count: int) -> Dictionary: + var entries := _entries_for_run(run_id) + var start := mini(maxi(0, offset), entries.size()) + var stop := mini(entries.size(), start + maxi(0, count)) + var out: Array[Dictionary] = [] + for i in range(start, stop): + out.append(entries[i]) + return { + "entries": out, + "total_count": entries.size(), + } + + +func _entries_for_run(run_id: String) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + for entry in get_range(0, total_count()): + if str(entry.get("run_id", "")) == run_id: + out.append(entry) + return out + + +func _generate_run_id() -> String: ## Opaque to agents — they only check equality. Time-based is plenty - ## unique within a single editor session and avoids the RNG-seed - ## reproducibility footgun. - return "r%d" % Time.get_ticks_msec() + ## unique within a single editor session; the local sequence protects + ## fast back-to-back test runs within the same millisecond. + _run_seq += 1 + return "r%d-%d" % [Time.get_ticks_msec(), _run_seq] diff --git a/addons/godot_ai/utils/mcp_client_refresh_state.gd b/addons/godot_ai/utils/mcp_client_refresh_state.gd index 28d2c3b..db8fe8a 100644 --- a/addons/godot_ai/utils/mcp_client_refresh_state.gd +++ b/addons/godot_ai/utils/mcp_client_refresh_state.gd @@ -57,6 +57,14 @@ static func has_worker_alive(state: int) -> bool: return state == RUNNING or state == RUNNING_TIMED_OUT +## True while the status worker is still within its healthy budget. Once a +## refresh has timed out, the dock keeps the warning badge but must let users +## retry Configure / Configure all instead of stranding the controls behind an +## orphaned, uninterruptible worker. +static func should_disable_client_actions(state: int) -> bool: + return state == RUNNING + + ## True when the dock should reject new refresh spawns. Used by the ## focus-in / manual button / cooldown-timer entrypoints. static func is_blocked_for_spawn(state: int) -> bool: diff --git a/addons/godot_ai/utils/server_lifecycle.gd b/addons/godot_ai/utils/server_lifecycle.gd index 8d06ada..a924935 100644 --- a/addons/godot_ai/utils/server_lifecycle.gd +++ b/addons/godot_ai/utils/server_lifecycle.gd @@ -300,6 +300,15 @@ func _set_incompatible_server(live: Dictionary, expected_version: String, port: var proof_name := str(proof.get("proof", "")) _can_recover_incompatible = not proof_name.is_empty() print("MCP | proof: %s" % (proof_name if _can_recover_incompatible else "(none)")) + if not _can_recover_incompatible: + ## Non-recoverable: a foreign / unprovable occupant holds the port and + ## we have no ownership proof, so we must NOT kill it — surface a + ## concrete free port the user can switch to instead (the same hint + ## the dock crash body renders). Logging it to the editor output also + ## lets `ci-stale-server-smoke --mode foreign` assert this upstream + ## classification from CI. Reservation-aware on Windows. + var suggested := ClientConfigurator.suggest_free_port(port + 1) + print("MCP | port %d occupant not recoverable (no ownership proof); suggested free port %d (set godot_ai/http_port)" % [port, suggested]) _host._refresh_dock_client_statuses() diff --git a/addons/godot_ai/utils/update_manager.gd b/addons/godot_ai/utils/update_manager.gd index 3abae44..348db5c 100644 --- a/addons/godot_ai/utils/update_manager.gd +++ b/addons/godot_ai/utils/update_manager.gd @@ -314,6 +314,8 @@ static func _is_trusted_download_url(url: String) -> bool: const SCHEME := "https://" if not url.begins_with(SCHEME): return false + if url.find("\\") >= 0: + return false var rest := url.substr(SCHEME.length()) var authority := rest var slash := rest.find("/") @@ -457,7 +459,7 @@ func _on_checksum_completed( print("MCP | self-update checksum verified (sha256 %s)" % actual) install_state_changed.emit({"button_text": "Installing..."}) - _install_zip() + _install_zip.call_deferred() ## Surface an integrity-check failure and drop the staged zip so the bad @@ -483,8 +485,9 @@ static func _parse_sha256_digest(text: String) -> String: if trimmed.is_empty(): return "" ## First whitespace-delimited token; `sha256sum` separates digest and - ## filename with two spaces, so allow_empty=false collapses the run. - var tokens := trimmed.split(" ", false) + ## filename with two spaces, but some tools use tabs. + var normalized := trimmed.replace("\t", " ").replace("\n", " ").replace("\r", " ") + var tokens := normalized.split(" ", false) if tokens.is_empty(): return "" var digest := String(tokens[0]).strip_edges().to_lower() diff --git a/assets/characters/mektons/mekton_bull.glb b/assets/characters/mektons/mekton_bull.glb new file mode 100644 index 0000000..0705c12 Binary files /dev/null and b/assets/characters/mektons/mekton_bull.glb differ diff --git a/assets/characters/mektons/mekton_bull.glb.import b/assets/characters/mektons/mekton_bull.glb.import new file mode 100644 index 0000000..6fb1b70 --- /dev/null +++ b/assets/characters/mektons/mekton_bull.glb.import @@ -0,0 +1,42 @@ +[remap] + +importer="scene" +importer_version=1 +type="PackedScene" +uid="uid://dsi2vtxfvqdm7" +path="res://.godot/imported/mekton_bull.glb-55daae491fc640a3c58139492aec6bb6.scn" + +[deps] + +source_file="res://assets/characters/mektons/mekton_bull.glb" +dest_files=["res://.godot/imported/mekton_bull.glb-55daae491fc640a3c58139492aec6bb6.scn"] + +[params] + +nodes/root_type="" +nodes/root_name="" +nodes/root_script=null +nodes/apply_root_scale=true +nodes/root_scale=1.0 +nodes/import_as_skeleton_bones=false +nodes/use_name_suffixes=true +nodes/use_node_type_suffixes=true +meshes/ensure_tangents=true +meshes/generate_lods=true +meshes/create_shadow_meshes=true +meshes/light_baking=1 +meshes/lightmap_texel_size=0.2 +meshes/force_disable_compression=false +skins/use_named_skins=true +animation/import=true +animation/fps=30 +animation/trimming=false +animation/remove_immutable_tracks=true +animation/import_rest_as_RESET=false +import_script/path="" +materials/extract=0 +materials/extract_format=0 +materials/extract_path="" +_subresources={} +gltf/naming_version=2 +gltf/embedded_image_handling=1 diff --git a/assets/characters/mektons/mekton_bull_texture_pbr_20250901.png b/assets/characters/mektons/mekton_bull_texture_pbr_20250901.png new file mode 100644 index 0000000..1fc42da Binary files /dev/null and b/assets/characters/mektons/mekton_bull_texture_pbr_20250901.png differ diff --git a/assets/characters/mektons/mekton_bull_texture_pbr_20250901.png.import b/assets/characters/mektons/mekton_bull_texture_pbr_20250901.png.import new file mode 100644 index 0000000..a641d92 --- /dev/null +++ b/assets/characters/mektons/mekton_bull_texture_pbr_20250901.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5nsxhrn7kgln" +path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.s3tc.ctex" +path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "74daccd2f257ba6ddc877d0e3112b374" +} + +[deps] + +source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901.png" +dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901.png-97dcb0c09da703b22c3ddc99680d3a89.etc2.ctex"] + +[params] + +compress/mode=2 +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=true +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=0 diff --git a/assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png new file mode 100644 index 0000000..0bba7d3 Binary files /dev/null and b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png differ diff --git a/assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png.import b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png.import new file mode 100644 index 0000000..e80dabb --- /dev/null +++ b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ctf5kpns0edj8" +path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.s3tc.ctex" +path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "30794e134a9d90aee14b4cd127e9d1cb" +} + +[deps] + +source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png" +dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901_metallic-texture_pbr_20250901_roughness.png-62ac095e69dd6ffb8b3cae70302b88e6.etc2.ctex"] + +[params] + +compress/mode=2 +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=true +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=0 diff --git a/assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png new file mode 100644 index 0000000..e2c13db Binary files /dev/null and b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png differ diff --git a/assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png.import b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png.import new file mode 100644 index 0000000..7435438 --- /dev/null +++ b/assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png.import @@ -0,0 +1,45 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://2f4nyuypxmfl" +path.s3tc="res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.s3tc.ctex" +path.etc2="res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.etc2.ctex" +metadata={ +"imported_formats": ["s3tc_bptc", "etc2_astc"], +"vram_texture": true +} +generator_parameters={ +"md5": "a8a4c3a73bd7e809157f8fc65b8872c9" +} + +[deps] + +source_file="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png" +dest_files=["res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.s3tc.ctex", "res://.godot/imported/mekton_bull_texture_pbr_20250901_normal.png-9d20462c1c392e7ca3ebdf37e3bf1c72.etc2.ctex"] + +[params] + +compress/mode=2 +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=1 +compress/channel_pack=0 +mipmaps/generate=true +mipmaps/limit=-1 +roughness/mode=1 +roughness/src_normal="res://assets/characters/mektons/mekton_bull_texture_pbr_20250901_normal.png" +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=0 diff --git a/godot-ai-LICENSE.txt b/godot-ai-LICENSE.txt new file mode 100644 index 0000000..7806d22 --- /dev/null +++ b/godot-ai-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Godot AI contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/patch.txt b/patch.txt deleted file mode 100644 index 3953802..0000000 --- a/patch.txt +++ /dev/null @@ -1,6 +0,0 @@ -@rpc("any_peer", "call_local") -func remove_slow_effect(): - slow_timer = 0.0 - self.is_slowed = false - if movement_manager: - movement_manager.set_speed_multiplier(1.0) diff --git a/patch_gauntlet.txt b/patch_gauntlet.txt deleted file mode 100644 index 0752f3e..0000000 --- a/patch_gauntlet.txt +++ /dev/null @@ -1,6 +0,0 @@ -@rpc("authority", "call_local", "reliable") -func sync_clear_sticky_cell(pos: Vector2i) -> void: - sticky_cells.erase(pos) - mark_cleansed(pos) - if gridmap: - gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), -1) diff --git a/patch_hud.txt b/patch_hud.txt deleted file mode 100644 index a50d1b6..0000000 --- a/patch_hud.txt +++ /dev/null @@ -1,68 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="1_font"] - -[node name="GauntletHUD" type="CanvasLayer"] -layer = 5 -visible = false - -[node name="TopContainer" type="CenterContainer" parent="."] -anchors_preset = 5 -anchor_left = 0.5 -anchor_right = 0.5 -offset_top = 70.0 -grow_horizontal = 2 - -[node name="SlowMoLabel" type="Label" parent="TopContainer"] -layout_mode = 2 -theme_override_font_sizes/font_size = 18 -theme_override_colors/font_color = Color(0.3, 0.5, 1.0, 1) -theme_override_colors/font_outline_color = Color(0, 0, 0, 1) -theme_override_constants/outline_size = 4 -theme_override_fonts/font = ExtResource("1_font") -text = "SLOW-MO" -horizontal_alignment = 1 -visible = false - -[node name="BottomContainer" type="CenterContainer" parent="."] -anchors_preset = 7 -anchor_left = 0.5 -anchor_top = 1.0 -anchor_right = 0.5 -anchor_bottom = 1.0 -offset_top = -120.0 -grow_horizontal = 2 -grow_vertical = 0 - -[node name="VBoxContainer" type="VBoxContainer" parent="BottomContainer"] -layout_mode = 2 -theme_override_constants/separation = 4 - -[node name="PhaseLabel" type="Label" parent="BottomContainer/VBoxContainer"] -layout_mode = 2 -theme_override_font_sizes/font_size = 24 -theme_override_colors/font_color = Color(1, 0.6, 0.8, 1) -theme_override_colors/font_outline_color = Color(0, 0, 0, 1) -theme_override_constants/outline_size = 6 -theme_override_fonts/font = ExtResource("1_font") -text = "🍬 OPEN ARENA" -horizontal_alignment = 1 - -[node name="CleanserHBox" type="HBoxContainer" parent="BottomContainer/VBoxContainer"] -layout_mode = 2 -theme_override_constants/separation = 6 -alignment = 1 - -[node name="CleanserIcon" type="TextureRect" parent="BottomContainer/VBoxContainer/CleanserHBox"] -layout_mode = 2 -custom_minimum_size = Vector2(20, 20) -stretch_mode = 5 - -[node name="CleanserLabel" type="Label" parent="BottomContainer/VBoxContainer/CleanserHBox"] -layout_mode = 2 -theme_override_font_sizes/font_size = 20 -theme_override_colors/font_outline_color = Color(0, 0, 0, 1) -theme_override_constants/outline_size = 6 -theme_override_fonts/font = ExtResource("1_font") -text = "[E] Cleanser (0)" -horizontal_alignment = 1 diff --git a/patch_particles.txt b/patch_particles.txt deleted file mode 100644 index 986b373..0000000 --- a/patch_particles.txt +++ /dev/null @@ -1,48 +0,0 @@ -func _spawn_cleanser_particles(pos: Vector2i) -> void: - """Spawn bright cleansing particles when sticky is cleared.""" - if not main_scene or not gridmap: - return - - var world_pos = Vector3( - pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0, - 0.5, - pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 - ) - - var particles = GPUParticles3D.new() - particles.emitting = true - particles.one_shot = true - particles.amount = 12 - particles.lifetime = 0.6 - particles.explosiveness = 0.9 - - var material = ParticleProcessMaterial.new() - material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE - material.emission_sphere_radius = 0.3 - material.direction = Vector3(0, 1, 0) - material.spread = 180.0 - material.initial_velocity_min = 3.0 - material.initial_velocity_max = 5.0 - material.gravity = Vector3(0, -5.0, 0) - material.scale_min = 0.05 - material.scale_max = 0.15 - - var mesh = SphereMesh.new() - mesh.radius = 0.2 - mesh.height = 0.4 - var spatial_mat = StandardMaterial3D.new() - spatial_mat.albedo_color = Color(0.2, 1.0, 1.0) # Cyan/Blue for cleanser - spatial_mat.emission_enabled = true - spatial_mat.emission = Color(0.2, 1.0, 1.0) - spatial_mat.emission_energy_multiplier = 3.0 - mesh.material = spatial_mat - particles.draw_pass_1 = mesh - - particles.process_material = material - particles.position = world_pos - - main_scene.add_child(particles) - - await get_tree().create_timer(1.2).timeout - if particles and is_instance_valid(particles): - particles.queue_free() diff --git a/patch_player.txt b/patch_player.txt deleted file mode 100644 index 11cae1f..0000000 --- a/patch_player.txt +++ /dev/null @@ -1,24 +0,0 @@ -func _find_valid_drop_position() -> Vector2i: - # Try random adjacent cells - var neighbors = enhanced_gridmap.get_neighbors(current_position, 0) - neighbors.shuffle() - - for neighbor in neighbors: - var pos = neighbor.position - # Check item layer - var item_cell = Vector3i(pos.x, 1, pos.y) - if enhanced_gridmap.get_cell_item(item_cell) == -1: - if not is_position_occupied(pos): - # Gauntlet Mode explicit overrides - var gm = null - var main_gauntlet = get_tree().root.get_node_or_null("Main") - if main_gauntlet and main_gauntlet.get("gauntlet_manager"): - gm = main_gauntlet.gauntlet_manager - if gm and gm.is_active: - if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1: - continue - if gm._is_npc_zone(pos): - continue - return pos - - return Vector2i(-1, -1) diff --git a/patch_player2.txt b/patch_player2.txt deleted file mode 100644 index 5ebad59..0000000 --- a/patch_player2.txt +++ /dev/null @@ -1,8 +0,0 @@ -@rpc("any_peer", "call_local") -func remove_slow_effect(): - slow_timer = 0.0 - self.is_slowed = false - if movement_manager: - # INSTANT response: restore speed multiplier to 1.0 immediately - movement_manager.set_speed_multiplier(1.0) - print("Player %s slow effect removed early" % name) diff --git a/patch_player_sed.txt b/patch_player_sed.txt deleted file mode 100644 index a15d9b3..0000000 --- a/patch_player_sed.txt +++ /dev/null @@ -1,25 +0,0 @@ -/func _find_valid_drop_position/,/return Vector2i(-1, -1)/c\ -func _find_valid_drop_position() -> Vector2i:\ - # Try random adjacent cells\ - var neighbors = enhanced_gridmap.get_neighbors(current_position, 0)\ - neighbors.shuffle()\ - \ - for neighbor in neighbors:\ - var pos = neighbor.position\ - # Check item layer\ - var item_cell = Vector3i(pos.x, 1, pos.y)\ - if enhanced_gridmap.get_cell_item(item_cell) == -1:\ - if not is_position_occupied(pos):\ - # Gauntlet Mode explicit overrides\ - var gm = null\ - var main_gauntlet = get_tree().root.get_node_or_null("Main")\ - if main_gauntlet and main_gauntlet.get("gauntlet_manager"):\ - gm = main_gauntlet.gauntlet_manager\ - if gm and gm.is_active:\ - if pos.x == 0 or pos.x == gm.ARENA_COLUMNS - 1 or pos.y == 0 or pos.y == gm.ARENA_ROWS - 1:\ - continue\ - if gm._is_npc_zone(pos):\ - continue\ - return pos\ - \ - return Vector2i(-1, -1) diff --git a/scenes/arena/mekton_bulls_arena.tscn b/scenes/arena/mekton_bulls_arena.tscn new file mode 100644 index 0000000..468c1dd --- /dev/null +++ b/scenes/arena/mekton_bulls_arena.tscn @@ -0,0 +1,7 @@ +[gd_scene load_steps=2 format=3 uid="uid://c5xk3jdg2s11m"] + +[node name="MektonBullsArena" type="Node3D"] + +[node name="GridMap" type="GridMap" parent="."] + +[node name="Area3D" type="Area3D" parent="."] diff --git a/scenes/main.gd b/scenes/main.gd index 2d91e09..e0b7fea 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -17,6 +17,7 @@ var is_match_ended: bool = false var obstacle_manager var portal_mode_manager var gauntlet_manager +var mekton_bulls_manager var vfx_manager # Minimal local state @@ -258,7 +259,14 @@ func _init_managers(): gauntlet_manager.name = "GauntletManager" add_child(gauntlet_manager) gauntlet_manager.initialize(self, $EnhancedGridMap) - + + # Mekton Bulls manager + if LobbyManager.game_mode == "Mekton Bulls": + mekton_bulls_manager = load("res://scripts/managers/mekton_bulls_manager.gd").new() + mekton_bulls_manager.name = "MektonBullsManager" + add_child(mekton_bulls_manager) + mekton_bulls_manager.initialize(self, $EnhancedGridMap) + # Screen shake manager for impact feedback screen_shake_manager = load("res://scripts/managers/screen_shake.gd").new() screen_shake_manager.name = "ScreenShakeManager" @@ -623,6 +631,8 @@ func _setup_host_game(): portal_mode_manager.setup_arena_locally() elif LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: gauntlet_manager._setup_arena() + elif LobbyManager.game_mode == "Mekton Bulls" and mekton_bulls_manager: + mekton_bulls_manager._setup_arena() else: # Randomize grid first to ensure Floor 0 is walkable for pre-calculation randomize_game_grid() @@ -728,10 +738,20 @@ func _setup_client_game(): # Initialize arena locally for Tekton Doors if LobbyManager.game_mode == "Tekton Doors" and portal_mode_manager: portal_mode_manager.setup_arena_locally() - - # Initialize arena locally for Candy Pump Survival + + # Special initialization for Gauntlet mode if LobbyManager.game_mode == "Candy Pump Survival" and gauntlet_manager: gauntlet_manager._apply_arena_setup() + + if LobbyManager.game_mode == "Mekton Bulls" and mekton_bulls_manager: + mekton_bulls_manager.hud_node = load("res://scenes/ui/mekton_bulls_hud.tscn").instantiate() + mekton_bulls_manager.hud_node.name = "MektonBullsHUD" + add_child(mekton_bulls_manager.hud_node) + if mekton_bulls_manager.hud_node.has_method("initialize"): + mekton_bulls_manager.hud_node.initialize(mekton_bulls_manager) + if mekton_bulls_manager.hud_node.has_method("set_local_player"): + mekton_bulls_manager.hud_node.set_local_player(multiplayer.get_unique_id()) + mekton_bulls_manager._apply_arena_setup() # Ensure local player setup (UI, controls) is verified var player_character = get_node_or_null(str(my_id)) @@ -876,10 +896,15 @@ func _start_game(): elif LobbyManager.game_mode == "Candy Pump Survival": if gauntlet_manager: gauntlet_manager.start_game_mode() - if goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration), true) # Enable cycles for 3x3 pattern missions + elif LobbyManager.game_mode == "Mekton Bulls": + if mekton_bulls_manager: + mekton_bulls_manager.start_game_mode() + if goals_cycle_manager: + var match_duration = LobbyManager.get_match_duration() + goals_cycle_manager.start_match(float(match_duration)) elif goals_cycle_manager: var match_duration = LobbyManager.get_match_duration() goals_cycle_manager.start_match(float(match_duration)) diff --git a/scenes/npcs/mekton_bull.tscn b/scenes/npcs/mekton_bull.tscn new file mode 100644 index 0000000..0dfd6ee --- /dev/null +++ b/scenes/npcs/mekton_bull.tscn @@ -0,0 +1,21 @@ +[gd_scene load_steps=5 format=3 uid="uid://bull1234abcd"] + +[ext_resource type="Script" uid="uid://bullscript1" path="res://scripts/npcs/mekton_bull.gd" id="1_bull"] +[ext_resource type="PackedScene" uid="uid://df7h7y7y7y7y7" path="res://scenes/static_tekton_mesh.tscn" id="2_mesh"] + +[sub_resource type="BoxShape3D" id="BoxShape3D_bull"] +size = Vector3(1.5, 2.0, 1.5) + +[node name="MektonBull" type="Area3D" groups=["MektonBulls"]] +collision_layer = 4 +collision_mask = 2 +script = ExtResource("1_bull") + +[node name="CollisionShape3D" type="CollisionShape3D" parent="."] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.0, 0) +shape = SubResource("BoxShape3D_bull") + +[node name="Visuals" type="Node3D" parent="."] + +[node name="Mesh" parent="Visuals" instance=ExtResource("2_mesh")] +transform = Transform3D(0.4, 0, 0, 0, 0.4, 0, 0, 0, 0.4, 0, 0, 0) diff --git a/scenes/ui/mekton_bulls_hud.tscn b/scenes/ui/mekton_bulls_hud.tscn new file mode 100644 index 0000000..8bee949 --- /dev/null +++ b/scenes/ui/mekton_bulls_hud.tscn @@ -0,0 +1,91 @@ +[gd_scene format=3 uid="uid://bullhud"] + +[ext_resource type="Script" uid="uid://bullhudscript" path="res://scripts/ui/mekton_bulls_hud.gd" id="1_hud"] + +[node name="MektonBullsHUD" 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_hud") + +[node name="BullTracker" type="Control" parent="."] +anchors_preset = 0 + +[node name="PowerCounters" type="Control" parent="."] +anchors_preset = 0 +layout_mode = 1 +anchors_preset = 2 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_top = -60.0 +offset_right = 300.0 +grow_vertical = 0 + +[node name="Label" type="Label" parent="PowerCounters"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +text = "Freeze: 0 | Knock: 0" +vertical_alignment = 1 + +[node name="PowerPicker" type="PanelContainer" parent="."] +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -100.0 +offset_top = -50.0 +offset_right = 100.0 +offset_bottom = 50.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="HBoxContainer" type="HBoxContainer" parent="PowerPicker"] +layout_mode = 2 + +[node name="FreezeBtn" type="Button" parent="PowerPicker/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Freeze" + +[node name="KnockBtn" type="Button" parent="PowerPicker/HBoxContainer"] +layout_mode = 2 +size_flags_horizontal = 3 +text = "Knock" + +[node name="PlacementPanel" type="PanelContainer" parent="."] +visible = false +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -200.0 +offset_top = -150.0 +offset_right = 200.0 +offset_bottom = 150.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="VBoxContainer" type="VBoxContainer" parent="PlacementPanel"] +layout_mode = 2 + +[node name="Label" type="Label" parent="PlacementPanel/VBoxContainer"] +layout_mode = 2 +text = "Match Placements" +horizontal_alignment = 1 + +[node name="List" type="VBoxContainer" parent="PlacementPanel/VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 + +[connection signal="pressed" from="PowerPicker/HBoxContainer/FreezeBtn" to="." method="_on_freeze_btn_pressed"] +[connection signal="pressed" from="PowerPicker/HBoxContainer/KnockBtn" to="." method="_on_knock_btn_pressed"] diff --git a/scripts/bot_strategic_planner.gd b/scripts/bot_strategic_planner.gd index b68c209..a1ea4dd 100644 --- a/scripts/bot_strategic_planner.gd +++ b/scripts/bot_strategic_planner.gd @@ -134,6 +134,56 @@ func _normalize_tile(tile: int) -> int: return tile - 4 # 11->7, 12->8, etc. return tile +# ============================================================================= +# Mekton Bulls mode helpers +# ============================================================================= + +func is_mekton_bulls_mode() -> bool: + return LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.MEKTON_BULLS) + +func _get_mekton_bulls_manager() -> Node: + if gauntlet_manager_override and is_instance_valid(gauntlet_manager_override): + return gauntlet_manager_override + + var current = actor + while current != null: + var bm = current.get_node_or_null("MektonBullsManager") + if bm: return bm + current = current.get_parent() + + var root = actor.get_tree().root + var main = root.get_node_or_null("Main") + if main: + return main.get_node_or_null("MektonBullsManager") + return null + +func _get_active_bulls() -> Array: + return actor.get_tree().get_nodes_in_group("MektonBulls") + +func _is_cell_unsafe_in_mekton_bulls(pos: Vector2i) -> bool: + """Cell is unsafe if it's WATER, or if it's on the boundary (soon to be flooded).""" + if not is_mekton_bulls_mode(): return false + var bm = _get_mekton_bulls_manager() + if not bm: return false + + # Check if water + var tile = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) + if tile == 24: # TILE_WATER + return true + + if bm.has_method("_is_boundary") and bm._is_boundary(pos): + return true + + # Bull proximity + var bulls = _get_active_bulls() + for b in bulls: + var b_pos = enhanced_gridmap.local_to_map(b.position) + # If cell is adjacent to the bull, it's unsafe. + if abs(b_pos.x - pos.x) <= 1 and abs(b_pos.z - pos.y) <= 1: + return true + + return false + # ============================================================================= # Goal Analysis # ============================================================================= @@ -345,14 +395,28 @@ func find_best_tile_to_grab() -> Dictionary: func find_nearest_tile_of_type(tile_types: Array) -> Vector2i: """Find nearest tile matching any type in array using optimized spiral search.""" - var current_pos = actor.current_position - if not enhanced_gridmap: return Vector2i(-1, -1) - + + if is_mekton_bulls_mode(): + # Return the nearest uncollected tile from our blueprint + var bm = _get_mekton_bulls_manager() + var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + if bm and pid != null and bm.player_blueprints.has(pid): + var bp = bm.player_blueprints[pid] + var best_tile = Vector2i(-1, -1) + var best_dist = INF + for c in bp.cells: + if enhanced_gridmap.get_cell_item(Vector3i(c.x, 0, c.y)) == bp.color: + var dist = actor.current_position.distance_to(c) + if dist < best_dist and _is_valid_move_target(c, true): + best_dist = dist + best_tile = c + if best_tile != Vector2i(-1, -1): + return best_tile + + var current_pos = actor.current_position # Optimization: Start check at simple radius - # If we find something in the spiral, it is guaranteed to be one of the nearest (by Chebyshev distance logic broadly, or just good enough) - var max_radius = 25 # Limit search range to prevent full map scans on huge maps if OS.has_feature("mobile"): max_radius = 15 # Stricter limit on mobile @@ -438,10 +502,43 @@ func find_nearest_roaming_tekton() -> Node3D: # Movement Strategy # ============================================================================= +func _should_use_freeze() -> bool: + if not is_mekton_bulls_mode(): return false + var bm = _get_mekton_bulls_manager() + if not bm: return false + var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + if not bm.player_powers.has(pid) or bm.player_powers[pid]["FREEZE"] <= 0: return false + + var bulls = _get_active_bulls() + var bot_pos = enhanced_gridmap.local_to_map(actor.position) + for b in bulls: + var b_pos = enhanced_gridmap.local_to_map(b.position) + if abs(bot_pos.x - b_pos.x) <= 3 and abs(bot_pos.z - b_pos.y) <= 3: + return true + return false + func find_optimal_move_target() -> Vector2i: - """Calculate the best position to move towards.""" + """Core decision logic. Evaluates sabotaging vs making progress.""" var main = actor.get_tree().get_root().get_node_or_null("Main") - var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) + if is_mekton_bulls_mode(): + # In Mekton Bulls, use powers if viable. + if _should_use_freeze(): + var bm = _get_mekton_bulls_manager() + var pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + bm.try_use_freeze.rpc_id(1) # Try emitting to server + + # Knock another nearby player + var mb_pid = actor.get("peer_id") if "peer_id" in actor else actor.name.to_int() + var mb_bm = _get_mekton_bulls_manager() + if mb_bm and mb_bm.player_powers.has(mb_pid) and mb_bm.player_powers[mb_pid]["KNOCK"] > 0: + var opps = _get_opponents() + for op in opps: + var dist = actor.position.distance_to(op.position) + if dist < enhanced_gridmap.cell_size.x * 2.0: + mb_bm.try_use_knock.rpc_id(1, op.name.to_int(), actor.position.direction_to(op.position).normalized()) + break + + var is_sng = LobbyManager and LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO) var gc_manager = main.get_node_or_null("GoalsCycleManager") if main else null var time_left = gc_manager.get_global_time_remaining() if gc_manager else 999.0 var is_match_running = gc_manager.is_match_running() if gc_manager else false @@ -602,6 +699,11 @@ func _is_valid_move_target(pos: Vector2i, ignore_players: bool = false) -> bool: if not enhanced_gridmap or not enhanced_gridmap.is_position_valid(pos): return false + if is_mekton_bulls_mode(): + # Do not move into WATER or the boundary + if _is_cell_unsafe_in_mekton_bulls(pos): + return false + # Check Floor 0 (Ground/Walls) var floor_item = enhanced_gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) if floor_item == -1 or floor_item in enhanced_gridmap.non_walkable_items: diff --git a/scripts/game_mode.gd b/scripts/game_mode.gd index 7525039..d0f0474 100644 --- a/scripts/game_mode.gd +++ b/scripts/game_mode.gd @@ -5,7 +5,8 @@ enum Mode { FREEMODE = 0, STOP_N_GO = 1, TEKTON_DOORS = 2, - GAUNTLET = 3 + GAUNTLET = 3, + MEKTON_BULLS = 4 } static func from_string(mode: String) -> Mode: @@ -18,6 +19,8 @@ static func from_string(mode: String) -> Mode: return Mode.TEKTON_DOORS "Candy Pump Survival": return Mode.GAUNTLET + "Mekton Bulls": + return Mode.MEKTON_BULLS _: return Mode.FREEMODE @@ -31,11 +34,13 @@ static func mode_to_string(mode: Mode) -> String: return "Tekton Doors" Mode.GAUNTLET: return "Candy Pump Survival" + Mode.MEKTON_BULLS: + return "Mekton Bulls" _: return "Freemode" static func is_restricted(mode: Mode) -> bool: - return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET + return mode == Mode.STOP_N_GO or mode == Mode.TEKTON_DOORS or mode == Mode.GAUNTLET or mode == Mode.MEKTON_BULLS static func get_all_modes() -> Array[String]: - return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival"] + return ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"] diff --git a/scripts/managers/lobby_manager.gd b/scripts/managers/lobby_manager.gd index 4701ddc..fb0321d 100644 --- a/scripts/managers/lobby_manager.gd +++ b/scripts/managers/lobby_manager.gd @@ -36,6 +36,11 @@ signal gauntlet_round_duration_changed(duration: int) signal gauntlet_growth_interval_changed(interval: float) signal gauntlet_cells_per_tick_changed(cells: Dictionary) +# Mekton Bulls settings signals +signal mekton_bulls_round_duration_changed(duration: int) +signal mekton_bulls_phase_interval_changed(interval: int) +signal mekton_bulls_points_changed(min_pts: int, max_pts: int) + # Room data structure var current_room: Dictionary = {} var players_in_room: Array = [] # [{id, name, is_ready}] @@ -88,13 +93,19 @@ var gauntlet_cells_per_tick: Dictionary = { "phase3": [8, 10], } +# Mekton Bulls settings +var mekton_bulls_round_duration: int = 120 +var mekton_bulls_phase_interval: int = 30 +var mekton_bulls_min_points: int = 100 +var mekton_bulls_max_points: int = 1000 + # Rematch tracking var rematch_votes: Array = [] # [player_id, ...] # Character and area selection var available_characters: Array[String] = ["Copper", "Dabro", "Gatot", "Pip", "Random"] var available_areas: Array[String] = [] -var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Candy Pump Survival"] +var available_game_modes: Array[String] = ["Freemode", "Stop n Go", "Tekton Doors", "Candy Pump Survival", "Mekton Bulls"] var selected_area: String = "Freemode Arena" # Host-controlled var game_mode: String = "Freemode" # Host-controlled var local_character_index: int = 0 # Local player's character index @@ -149,8 +160,12 @@ func _update_available_areas(mode: String) -> void: available_areas = ["Freemode Arena", "Classic", "Colloseum"] "Stop n Go": available_areas = ["Stop N Go Arena"] + "Tekton Doors": + available_areas = ["Tekton Doors Area"] "Candy Pump Survival": available_areas = ["Gauntlet Arena"] + "Mekton Bulls": + available_areas = ["Mekton Bulls Arena"] _: available_areas = ["Classic"] @@ -584,6 +599,39 @@ func sync_gauntlet_cells_per_tick(cells: Dictionary) -> void: gauntlet_cells_per_tick = cells emit_signal("gauntlet_cells_per_tick_changed", cells) +# ============================================================================= +# Mekton Bulls Settings +# ============================================================================= + +func set_mekton_bulls_round_duration(duration: int) -> void: + mekton_bulls_round_duration = duration + if is_host: rpc("sync_mekton_bulls_round_duration", duration) + +@rpc("authority", "call_local", "reliable") +func sync_mekton_bulls_round_duration(duration: int) -> void: + mekton_bulls_round_duration = duration + emit_signal("mekton_bulls_round_duration_changed", duration) + +func set_mekton_bulls_phase_interval(interval: int) -> void: + mekton_bulls_phase_interval = interval + if is_host: rpc("sync_mekton_bulls_phase_interval", interval) + +@rpc("authority", "call_local", "reliable") +func sync_mekton_bulls_phase_interval(interval: int) -> void: + mekton_bulls_phase_interval = interval + emit_signal("mekton_bulls_phase_interval_changed", interval) + +func set_mekton_bulls_points(min_pts: int, max_pts: int) -> void: + mekton_bulls_min_points = min_pts + mekton_bulls_max_points = max_pts + if is_host: rpc("sync_mekton_bulls_points", min_pts, max_pts) + +@rpc("authority", "call_local", "reliable") +func sync_mekton_bulls_points(min_pts: int, max_pts: int) -> void: + mekton_bulls_min_points = min_pts + mekton_bulls_max_points = max_pts + emit_signal("mekton_bulls_points_changed", min_pts, max_pts) + # ============================================================================= # Character Selection # ============================================================================= @@ -792,6 +840,10 @@ func start_game(force: bool = false) -> void: rpc("sync_gauntlet_round_duration", gauntlet_round_duration) rpc("sync_gauntlet_growth_interval", gauntlet_growth_interval) rpc("sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) + # Sync mekton bulls + rpc("sync_mekton_bulls_round_duration", mekton_bulls_round_duration) + rpc("sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval) + rpc("sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points) # Sync game mode rpc("sync_game_mode", game_mode) @@ -870,6 +922,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration) rpc_id(requester_id, "sync_gauntlet_growth_interval", gauntlet_growth_interval) rpc_id(requester_id, "sync_gauntlet_cells_per_tick", gauntlet_cells_per_tick) + rpc_id(requester_id, "sync_mekton_bulls_round_duration", mekton_bulls_round_duration) + rpc_id(requester_id, "sync_mekton_bulls_phase_interval", mekton_bulls_phase_interval) + rpc_id(requester_id, "sync_mekton_bulls_points", mekton_bulls_min_points, mekton_bulls_max_points) rpc_id(requester_id, "sync_game_mode", game_mode) rpc_id(requester_id, "sync_area", selected_area) @@ -1021,3 +1076,7 @@ func reset() -> void: doors_swap_time = 15 doors_refresh_time = 25 doors_required_goals = 8 + mekton_bulls_round_duration = 120 + mekton_bulls_phase_interval = 30 + mekton_bulls_min_points = 100 + mekton_bulls_max_points = 1000 diff --git a/scripts/managers/mekton_bulls_manager.gd b/scripts/managers/mekton_bulls_manager.gd new file mode 100644 index 0000000..bb101d5 --- /dev/null +++ b/scripts/managers/mekton_bulls_manager.gd @@ -0,0 +1,593 @@ +extends Node +class_name MektonBullsManager + +class Blueprint3x3 extends RefCounted: + var anchor: Vector2i + var color: int + var cells: Array[Vector2i] = [] + var progress: int = 0 + +signal phase_changed(phase_index: int) +signal player_eliminated(player_id: int) + +# Nodes +var main_scene: Node +var gridmap: Node + +# Phase State +var current_phase: int = 1 +var arena_size: Vector2i = Vector2i(20, 20) + +var round_duration: float = 120.0 +var phase_interval: float = 30.0 +var round_timer: float = 120.0 +var phase_timer: float = 30.0 + +signal time_remaining_changed(remaining: float) + +var bull_node: Node3D = null +const BULL_SCENE = preload("res://scenes/npcs/mekton_bull.tscn") + +enum CellState { + SAFE, + WATER, + BLOCKED +} +var arena_cells: Dictionary = {} + +var is_active: bool = false +var flood_cooldown: float = 0.0 + + +var player_blueprints: Dictionary = {} # { player_id: Blueprint3x3 } +var player_powers: Dictionary = {} # { player_id: { "FREEZE": 0, "KNOCK": 0 } } + +var player_cooldowns: Dictionary = {} # { player_id: float } +enum PowerType { FREEZE, KNOCK } + +# Placement Tracking +var player_placement: Dictionary = {} # { pid: placement_rank } 1=first out +var elimination_order: Array = [] # List of pids + +var candy_tick_timer: float = 0.0 +const GOAL_COLORS = [7, 8, 9, 10] + + +const TILE_WALKABLE: int = 0 +const TILE_WATER: int = 24 # Water tile +const TILE_OBSTACLE: int = 4 # Wall/obstacle + +func initialize(main: Node, grid: Node) -> void: + main_scene = main + gridmap = grid + + +func start_game_mode() -> void: + print("[MektonBulls] Starting Mekton Bulls game mode...") + + round_duration = float(LobbyManager.mekton_bulls_round_duration) + phase_interval = float(LobbyManager.mekton_bulls_phase_interval) + round_timer = round_duration + phase_timer = phase_interval + + is_active = true + _setup_arena() + + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + _spawn_bull() + _assign_initial_blueprints() + + +func _ready(): + player_eliminated.connect(_on_player_eliminated) + +func _setup_arena() -> void: + if not gridmap: + gridmap = get_parent().get_node_or_null("EnhancedGridMap") + if not gridmap: + gridmap = get_node_or_null("/root/Main/EnhancedGridMap") + if not gridmap: + push_error("[MektonBulls] No EnhancedGridMap found!") + return + + print("[MektonBulls] Setting up Phase 1 Arena...") + + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + rpc("sync_arena_setup") + _apply_arena_setup() + +@rpc("authority", "call_remote", "reliable") +func sync_arena_setup() -> void: + _apply_arena_setup() + +func _apply_arena_setup() -> void: + if not gridmap: + gridmap = get_parent().get_node_or_null("EnhancedGridMap") + if not gridmap: return + + current_phase = 1 + arena_size = arena_size_for_phase(current_phase) + + gridmap.set("columns", 20) + gridmap.set("rows", 20) + gridmap.clear() + + # Initial build 20x20 + for x in range(20): + for z in range(20): + var pos = Vector2i(x, z) + + if _is_boundary(pos): + # Perimeter + gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) + gridmap.set_cell_item(Vector3i(x, 1, z), -1) + arena_cells[pos] = CellState.SAFE + else: + # Walkable floor + gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE) + gridmap.set_cell_item(Vector3i(x, 1, z), -1) + arena_cells[pos] = CellState.SAFE + + gridmap.set_cell_item(Vector3i(x, 2, z), -1) + + gridmap.diagonal_movement = true + gridmap.update_grid_data() + gridmap.initialize_astar() + + _reposition_npc() + _validate_blueprint_after_shrink() + + +func arena_size_for_phase(phase: int) -> Vector2i: + match phase: + 1: return Vector2i(20, 20) + 2: return Vector2i(19, 19) + 3: return Vector2i(18, 18) + 4: return Vector2i(17, 17) + _: return Vector2i(17, 17) # Final phase + +func _is_boundary(pos: Vector2i) -> bool: + var bounds = arena_size_for_phase(current_phase) + # Grid shrinks symmetrically towards (0,0) by bounds. + return pos.x == 0 or pos.x == bounds.x - 1 or pos.y == 0 or pos.y == bounds.y - 1 + +func _shrink_arena() -> void: + if current_phase >= 4: + return + + current_phase += 1 + var new_bounds = arena_size_for_phase(current_phase) + var old_bounds = arena_size_for_phase(current_phase - 1) + + print("[MektonBulls] Shrinking arena to Phase %d (%dx%d)" % [current_phase, new_bounds.x, new_bounds.y]) + + # Apply locally first + _apply_ring_shrink(old_bounds, new_bounds) + + if multiplayer.has_multiplayer_peer() and multiplayer.is_server(): + rpc("sync_shrink_arena", current_phase) + + emit_signal("phase_changed", current_phase) + +@rpc("authority", "call_remote", "reliable") +func sync_shrink_arena(new_phase: int) -> void: + var old_bounds = arena_size_for_phase(current_phase) + current_phase = new_phase + var new_bounds = arena_size_for_phase(current_phase) + _apply_ring_shrink(old_bounds, new_bounds) + emit_signal("phase_changed", current_phase) + +func _apply_ring_shrink(old_bounds: Vector2i, new_bounds: Vector2i) -> void: + for x in range(20): + for z in range(20): + var pos = Vector2i(x, z) + + if x >= new_bounds.x or z >= new_bounds.y: + # It is now outside the new bounds -> WATER + gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER) + gridmap.set_cell_item(Vector3i(x, 1, z), -1) + arena_cells[pos] = CellState.WATER + elif _is_boundary(pos): + # New boundary -> No wall, just the edge of SAFE + gridmap.set_cell_item(Vector3i(x, 1, z), -1) + arena_cells[pos] = CellState.SAFE + + gridmap.update_grid_data() + gridmap.initialize_astar() + + _reposition_npc() + _validate_blueprint_after_shrink() + + +func _reposition_npc() -> void: + # Reposition Mekton Bull to the center of the current bounds + if not bull_node: + return + + var bounds = arena_size_for_phase(current_phase) + # Center in world units + var cx = (bounds.x / 2.0) * gridmap.cell_size.x + var cz = (bounds.y / 2.0) * gridmap.cell_size.z + + bull_node.position = Vector3(cx, 0, cz) + +func get_spawn_points(player_count: int) -> Array[Vector2i]: + var spawns: Array[Vector2i] = [] + var bounds = arena_size_for_phase(current_phase) + # 4 players: inner corners + spawns.append(Vector2i(1, 1)) + if bounds.x > 2: spawns.append(Vector2i(bounds.x - 2, 1)) + if bounds.y > 2: spawns.append(Vector2i(1, bounds.y - 2)) + if bounds.x > 2 and bounds.y > 2: spawns.append(Vector2i(bounds.x - 2, bounds.y - 2)) + + if player_count > 4: + if bounds.x > 4: spawns.append(Vector2i(bounds.x / 2, 1)) + if bounds.x > 4 and bounds.y > 2: spawns.append(Vector2i(bounds.x / 2, bounds.y - 2)) + if player_count > 6: + if bounds.y > 4: spawns.append(Vector2i(1, bounds.y / 2)) + if bounds.x > 2 and bounds.y > 4: spawns.append(Vector2i(bounds.x - 2, bounds.y / 2)) + + return spawns.slice(0, player_count) + +func _spawn_bull() -> void: + if bull_node == null: + bull_node = BULL_SCENE.instantiate() + bull_node.name = "MektonBull" + # Use multiplayer spawner if appropriate, else just add child + main_scene.add_child(bull_node) + + var bounds = arena_size_for_phase(current_phase) + var cx = (bounds.x / 2.0) * gridmap.cell_size.x + var cz = (bounds.y / 2.0) * gridmap.cell_size.z + var start_pos = Vector3(cx, 0, cz) + + if bull_node.has_method("initialize"): + bull_node.initialize(self, gridmap, start_pos) + + +func _process(delta: float) -> void: + if not multiplayer.has_multiplayer_peer() or multiplayer.multiplayer_peer == null: return + if not is_active: return + + if multiplayer.is_server(): + round_timer -= delta + time_remaining_changed.emit(round_timer) + rpc("sync_time_remaining", round_timer) + + if round_timer <= 0: + _on_round_time_expired() + + phase_timer -= delta + if phase_timer <= 0: + phase_timer = phase_interval + _shrink_arena() + + if flood_cooldown > 0: + + flood_cooldown -= delta + + if multiplayer.is_server(): + if flood_cooldown <= 0 and bull_node: + var bull_pos_3d = gridmap.local_to_map(bull_node.position) + var bull_pos_2d = Vector2i(bull_pos_3d.x, bull_pos_3d.z) + if _is_boundary(bull_pos_2d): + _trigger_water_flood() + + + candy_tick_timer -= delta + if candy_tick_timer <= 0: + candy_tick_timer = 0.1 + _process_candy_tick() + + for pid in player_cooldowns.keys(): + if player_cooldowns[pid] > 0: + player_cooldowns[pid] -= delta + + + + +@rpc("authority", "call_local", "reliable") +func sync_time_remaining(time: float) -> void: + round_timer = time + time_remaining_changed.emit(round_timer) + +func _on_round_time_expired() -> void: + is_active = false + var players = get_tree().get_nodes_in_group("Players") + for p in players: + if p.is_in_group("Players"): + var pid = p.name.to_int() + if not elimination_order.has(pid): + elimination_order.append(pid) + player_placement[pid] = elimination_order.size() + + _end_round() + +func _trigger_water_flood() -> void: + flood_cooldown = 3.0 + print("[MektonBulls] Bull is on boundary! Flooding outer ring!") + + # Network sync + rpc("sync_water_flood") + _apply_water_flood() + +@rpc("authority", "call_local", "reliable") +func sync_water_flood() -> void: + _apply_water_flood() + +func _apply_water_flood() -> void: + # 1. Eliminate any player whose cell is currently _is_boundary + # (which is the outermost ring of the current arena_size) + var players = get_tree().get_nodes_in_group("Players") + var bounds = arena_size_for_phase(current_phase) + + for p in players: + if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated(): + var p_cell_3d = gridmap.local_to_map(p.position) + var p_cell_2d = Vector2i(p_cell_3d.x, p_cell_3d.z) + if _is_boundary(p_cell_2d): + if multiplayer.is_server(): + if p.has_method("eliminate"): + p.eliminate() + else: + player_eliminated.emit(p.name.to_int()) + + # 2. Play VFX / SFX (placeholder print if no actual scene yet) + if main_scene and main_scene.get("vfx_manager") and main_scene.vfx_manager.has_method("play_splash"): + var cx = (bounds.x / 2.0) * gridmap.cell_size.x + var cz = (bounds.y / 2.0) * gridmap.cell_size.z + main_scene.vfx_manager.play_splash(Vector3(cx, 0, cz)) + + if has_node("/root/SfxManager"): + get_node("/root/SfxManager").play_rpc("water_flood") + else: + print("[MektonBulls] WHOOSH! Water flood VFX played.") + + # 3. Set those cells to TILE_WATER + for x in range(20): + for z in range(20): + var pos = Vector2i(x, z) + if _is_boundary(pos): + gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WATER) + gridmap.set_cell_item(Vector3i(x, 1, z), -1) + arena_cells[pos] = CellState.WATER + + gridmap.update_grid_data() + +func _assign_initial_blueprints() -> void: + var players = get_tree().get_nodes_in_group("Players") + for p in players: + if p.is_in_group("Players"): + _reroll_blueprint(p.name.to_int()) + +func _reroll_blueprint(player_id: int) -> void: + var bp = Blueprint3x3.new() + bp.color = GOAL_COLORS[randi() % GOAL_COLORS.size()] + bp.anchor = _get_valid_3x3_anchor() + + # Generate 3x3 cells + for dx in range(3): + for dz in range(3): + var cpos = Vector2i(bp.anchor.x + dx, bp.anchor.y + dz) + bp.cells.append(cpos) + # Paint it + gridmap.set_cell_item(Vector3i(cpos.x, 0, cpos.y), bp.color) + + player_blueprints[player_id] = bp + gridmap.update_grid_data() + rpc("sync_painted_cells", bp.cells, bp.color) + +@rpc("authority", "call_local", "reliable") +func sync_painted_cells(cells: Array, color: int) -> void: + for c in cells: + gridmap.set_cell_item(Vector3i(c.x, 0, c.y), color) + +func _get_valid_3x3_anchor() -> Vector2i: + var bounds = arena_size_for_phase(current_phase) + # boundary is 0 and bounds-1 + # inner is 1 to bounds-2 + # 3x3 needs x to x+2 -> x+2 <= bounds-2 -> x <= bounds-4 + var max_x = bounds.x - 4 + var max_y = bounds.y - 4 + + if max_x < 1: max_x = 1 + if max_y < 1: max_y = 1 + + return Vector2i(randi_range(1, max_x), randi_range(1, max_y)) + +func _process_candy_tick() -> void: + var players = get_tree().get_nodes_in_group("Players") + for p in players: + if not p.is_in_group("Players") or (p.has_method("is_eliminated") and p.is_eliminated()): + continue + + var pid = p.name.to_int() + if not player_blueprints.has(pid): + continue + + var bp: Blueprint3x3 = player_blueprints[pid] + var pos_3d = gridmap.local_to_map(p.position) + var pos = Vector2i(pos_3d.x, pos_3d.z) + + if pos in bp.cells: + var item = gridmap.get_cell_item(Vector3i(pos.x, 0, pos.y)) + if item == bp.color: + # Pickup! + bp.progress += 1 + # Remove color local + gridmap.set_cell_item(Vector3i(pos.x, 0, pos.y), TILE_WALKABLE) + rpc("sync_painted_cells", [pos], TILE_WALKABLE) + + if bp.progress >= 9: + _grant_power(pid) + _reroll_blueprint(pid) + + +func _grant_power(player_id: int) -> void: + print("[MektonBulls] Blueprint complete! Prompting power picker for ", player_id) + # Safely call rpc_id + if multiplayer.has_multiplayer_peer() and multiplayer.get_peers().has(player_id) or player_id == multiplayer.get_unique_id(): + rpc_id(player_id, "prompt_power_picker") + +@rpc("authority", "call_remote", "reliable") +func prompt_power_picker() -> void: + if hud_node and hud_node.has_method("show_power_picker"): + hud_node.show_power_picker() + +var hud_node: Node = null + +@rpc("authority", "call_local", "reliable") +func sync_score_completed(placements: Dictionary) -> void: + if hud_node and hud_node.has_method("show_placement"): + hud_node.show_placement(placements) + +@rpc("any_peer", "call_local", "reliable") +func try_pick_power(power_type_str: String) -> void: + var sender = multiplayer.get_remote_sender_id() + if not multiplayer.is_server(): return + + if not player_powers.has(sender): + player_powers[sender] = { "FREEZE": 0, "KNOCK": 0 } + + if power_type_str == "FREEZE" or power_type_str == "KNOCK": + player_powers[sender][power_type_str] += 1 + rpc("sync_player_powers", sender, player_powers[sender]) + +@rpc("authority", "call_local", "reliable") +func sync_player_powers(pid: int, powers: Dictionary) -> void: + player_powers[pid] = powers + +@rpc("any_peer", "call_local", "reliable") +func try_use_freeze() -> void: + var sender = multiplayer.get_remote_sender_id() + if not multiplayer.is_server(): return + + if player_cooldowns.get(sender, 0.0) > 0: return + if not player_powers.has(sender) or player_powers[sender]["FREEZE"] <= 0: return + + player_powers[sender]["FREEZE"] -= 1 + player_cooldowns[sender] = 1.0 + rpc("sync_player_powers", sender, player_powers[sender]) + + if bull_node and bull_node.has_method("apply_slow"): + bull_node.apply_slow(3.0) + if has_node("/root/SfxManager"): + get_node("/root/SfxManager").play_rpc("freeze_burst") + +@rpc("any_peer", "call_local", "reliable") +func try_use_knock(target_id: int, dir: Vector3) -> void: + var sender = multiplayer.get_remote_sender_id() + if not multiplayer.is_server(): return + + if player_cooldowns.get(sender, 0.0) > 0: return + if not player_powers.has(sender) or player_powers[sender]["KNOCK"] <= 0: return + + player_powers[sender]["KNOCK"] -= 1 + player_cooldowns[sender] = 1.0 + rpc("sync_player_powers", sender, player_powers[sender]) + + rpc("sync_apply_knock", target_id, dir) + + +@rpc("authority", "call_local", "reliable") +func sync_apply_knock(target_id: int, dir: Vector3) -> void: + var players = get_tree().get_nodes_in_group("Players") + for p in players: + if p.name == str(target_id): + # Translate position 1 cell in dir + var move_dist = gridmap.cell_size.x + # Normalize to 4 directions + var dx = 0 + var dz = 0 + if abs(dir.x) > abs(dir.z): + dx = sign(dir.x) + else: + dz = sign(dir.z) + + p.position += Vector3(dx * move_dist, 0, dz * move_dist) + + # Play a smack sound if available + if has_node("/root/SfxManager"): + get_node("/root/SfxManager").play_rpc("knock_burst") + elif main_scene and main_scene.get("audio_manager") and main_scene.audio_manager.has_method("play_sfx"): + main_scene.audio_manager.play_sfx("smack") + + +func _validate_blueprint_after_shrink() -> void: + if not multiplayer.is_server(): return + var bounds = arena_size_for_phase(current_phase) + + for pid in player_blueprints.keys(): + var bp: Blueprint3x3 = player_blueprints[pid] + var outside = false + for c in bp.cells: + if c.x <= 0 or c.x >= bounds.x - 1 or c.y <= 0 or c.y >= bounds.y - 1: + outside = true + break + + if outside: + # Clear old ones + rpc("sync_painted_cells", bp.cells, TILE_WALKABLE) + _reroll_blueprint(pid) + +func _on_player_eliminated(player_id: int) -> void: + if not elimination_order.has(player_id): + elimination_order.append(player_id) + print("[MektonBulls] Player %d eliminated. Rank: %d" % [player_id, elimination_order.size()]) + # Placement rank: 1 is first out + player_placement[player_id] = elimination_order.size() + + if multiplayer.is_server(): + # Check if only 1 player remains + var players = get_tree().get_nodes_in_group("Players") + var alive_count = 0 + var last_alive: int = -1 + for p in players: + if p.is_in_group("Players"): + var pid = p.name.to_int() + if not elimination_order.has(pid): + alive_count += 1 + last_alive = pid + + if alive_count <= 1: + if last_alive != -1 and not elimination_order.has(last_alive): + elimination_order.append(last_alive) + player_placement[last_alive] = elimination_order.size() + is_active = false + _end_round() + +func _end_round() -> void: + if not multiplayer.is_server(): return + print("[MektonBulls] Round ended. Computing placement scores...") + + var total_players = elimination_order.size() + if total_players == 0: return + + var min_pts = LobbyManager.mekton_bulls_min_points + var max_pts = LobbyManager.mekton_bulls_max_points + + var scores = {} + for i in range(total_players): + var pid = elimination_order[i] + var rank = i + 1 # 1 = first out + + var pts = min_pts + if total_players > 1: + var t = float(rank - 1) / float(total_players - 1) + pts = int(lerp(float(min_pts), float(max_pts), t)) + + scores[pid] = pts + print("[MektonBulls] Player %d finished rank %d -> %d pts" % [pid, rank, pts]) + + # In the real game, we'd sync this to the scores manager + # main_scene.rpc("sync_score_updated", scores) - wait, is there a direct scoreboard in Mekton Bulls? + # Typically GoalsCycleManager tracks scores, or main.gd + if main_scene and main_scene.get("goals_cycle_manager"): + for pid in scores.keys(): + main_scene.goals_cycle_manager.add_score(pid, scores[pid]) + + rpc("sync_score_completed", player_placement) + + # End the goal cycle match if it hasn't already + if main_scene and main_scene.get("goals_cycle_manager") and main_scene.goals_cycle_manager.is_active: + main_scene.goals_cycle_manager.end_match() diff --git a/scripts/managers/mekton_bulls_manager.gd.uid b/scripts/managers/mekton_bulls_manager.gd.uid new file mode 100644 index 0000000..72e65a1 --- /dev/null +++ b/scripts/managers/mekton_bulls_manager.gd.uid @@ -0,0 +1 @@ +uid://bnsxsqvj2ea7f diff --git a/scripts/npcs/mekton_bull.gd b/scripts/npcs/mekton_bull.gd new file mode 100644 index 0000000..8421cce --- /dev/null +++ b/scripts/npcs/mekton_bull.gd @@ -0,0 +1,162 @@ +extends Area3D + +enum State { + ROAM, + CHARGE, + COOLDOWN +} + +var current_state: State = State.ROAM +var gridmap: Node +var arena_manager: Node + +var move_speed: float = 3.0 +var charge_speed: float = 12.0 +var target_pos: Vector3 +var state_timer: float = 0.0 +var slow_timer: float = 0.0 + +func _ready(): + add_to_group("MektonBulls") + body_entered.connect(_on_body_entered) + +func initialize(manager: Node, grid: Node, start_pos: Vector3): + arena_manager = manager + gridmap = grid + position = start_pos + _pick_roam_target() + +func apply_slow(duration: float) -> void: + slow_timer = duration + +func _physics_process(delta: float): + if not multiplayer.is_server(): + return + + state_timer -= delta + if slow_timer > 0: + slow_timer -= delta + + match current_state: + State.ROAM: + _process_roam(delta) + if state_timer <= 0: + _try_start_charge() + State.CHARGE: + _process_charge(delta) + State.COOLDOWN: + if state_timer <= 0: + current_state = State.ROAM + _pick_roam_target() + +func _process_roam(delta: float): + var dist = position.distance_to(target_pos) + if dist < 0.1: + _pick_roam_target() + else: + var dir = (target_pos - position).normalized() + dir.y = 0 + if dir.length_squared() > 0: + var actual_speed = move_speed + if slow_timer > 0: actual_speed *= 0.5 + position += dir * actual_speed * delta + # Face direction implicitly + var look_target = position + dir + look_target.y = position.y + if position.distance_squared_to(look_target) > 0.01: + look_at(look_target, Vector3.UP) + +func _pick_roam_target(): + if not arena_manager: return + + var bounds = arena_manager.arena_size_for_phase(arena_manager.current_phase) + # Random walkable position within bounds + # We know 0,0 is boundary, bounds.x-1 is boundary + var min_x = 1 + var max_x = bounds.x - 2 + var min_z = 1 + var max_z = bounds.y - 2 + + if min_x > max_x: max_x = min_x + if min_z > max_z: max_z = min_z + + # Avoid exact center (where the static delivery target presumably sits) + var cx = int(bounds.x / 2.0) + var cz = int(bounds.y / 2.0) + + var rx = cx + var rz = cz + while rx == cx and rz == cz: + rx = randi_range(min_x, max_x) + rz = randi_range(min_z, max_z) + + var world_x = rx * gridmap.cell_size.x + gridmap.cell_size.x / 2.0 + var world_z = rz * gridmap.cell_size.z + gridmap.cell_size.z / 2.0 + + target_pos = Vector3(world_x, position.y, world_z) + state_timer = randf_range(2.0, 4.0) + +func _try_start_charge(): + # Find closest player + var players = get_tree().get_nodes_in_group("Players") + var closest_player = null + var min_dist = INF + + for p in players: + if p.is_in_group("Players") and p.has_method("is_eliminated") and not p.is_eliminated(): + var d = position.distance_to(p.position) + if d < min_dist and d < 15.0: # Range check + min_dist = d + closest_player = p + + if closest_player: + current_state = State.CHARGE + target_pos = closest_player.position + target_pos.y = position.y + state_timer = 2.0 # Max charge duration + if has_node("/root/SfxManager"): + get_node("/root/SfxManager").play_rpc("bull_charge") + else: + _pick_roam_target() + +func _process_charge(delta: float): + var dir = (target_pos - position).normalized() + dir.y = 0 + var actual_speed = charge_speed + if slow_timer > 0: actual_speed *= 0.5 + position += dir * actual_speed * delta + + var look_target = position + dir + look_target.y = position.y + if position.distance_squared_to(look_target) > 0.01: + look_at(look_target, Vector3.UP) + + var dist = position.distance_to(target_pos) + if dist < 0.5 or state_timer <= 0: + # Hit destination or timeout + current_state = State.COOLDOWN + state_timer = 1.5 + +func _on_body_entered(body: Node3D): + if body.is_in_group("Players") and multiplayer.is_server(): + # Knock them out + if body.has_method("eliminate"): + body.eliminate() + else: + print("[MektonBull] Knocked out player", body.name) + # Dispatch via manager + if arena_manager and arena_manager.has_signal("player_eliminated"): + arena_manager.player_eliminated.emit(body.name.to_int()) + + # Polish: SFX + Camera Shake + rpc("sync_bull_impact") + +@rpc("authority", "call_local", "unreliable") +func sync_bull_impact() -> void: + if has_node("/root/SfxManager"): + get_node("/root/SfxManager").play("bull_impact") + + var root = get_tree().root + var main = root.get_node_or_null("Main") + if main and main.get("screen_shake_manager"): + main.screen_shake_manager.shake(0.2, 0.5) diff --git a/scripts/npcs/mekton_bull.gd.uid b/scripts/npcs/mekton_bull.gd.uid new file mode 100644 index 0000000..6347062 --- /dev/null +++ b/scripts/npcs/mekton_bull.gd.uid @@ -0,0 +1 @@ +uid://ciytpot4av5gw diff --git a/scripts/ui/mekton_bulls_hud.gd b/scripts/ui/mekton_bulls_hud.gd new file mode 100644 index 0000000..95a2d27 --- /dev/null +++ b/scripts/ui/mekton_bulls_hud.gd @@ -0,0 +1,57 @@ +extends Control + +@onready var bull_tracker = $BullTracker +@onready var power_picker = $PowerPicker +@onready var placement_panel = $PlacementPanel +@onready var placement_list = $PlacementPanel/VBoxContainer/List +@onready var counters_lbl = $PowerCounters/Label + +var local_pid: int = -1 +var arena_manager: Node + +func _ready(): + power_picker.hide() + placement_panel.hide() + +func set_local_player(pid: int): + local_pid = pid + +func initialize(manager: Node): + arena_manager = manager + +func _process(delta: float): + # Hide tracker if bull is close/visible, else point to it + if not arena_manager: return + + var local_powers = arena_manager.player_powers.get(local_pid, {"FREEZE": 0, "KNOCK": 0}) + counters_lbl.text = "Freeze: %d | Knock: %d" % [local_powers.get("FREEZE", 0), local_powers.get("KNOCK", 0)] + +func show_power_picker(): + power_picker.show() + +func _on_freeze_btn_pressed(): + if arena_manager: + arena_manager.rpc_id(1, "try_pick_power", "FREEZE") + power_picker.hide() + +func _on_knock_btn_pressed(): + if arena_manager: + arena_manager.rpc_id(1, "try_pick_power", "KNOCK") + power_picker.hide() + +func show_placement(scores: Dictionary): + placement_panel.show() + for child in placement_list.get_children(): + child.queue_free() + + var items = [] + for pid in scores.keys(): + items.append({"pid": pid, "rank": scores[pid]}) + + items.sort_custom(func(a, b): return a.rank < b.rank) + + for item in items: + var lbl = Label.new() + lbl.text = "Player %s - Rank %s" % [str(item.pid), str(item.rank)] + placement_list.add_child(lbl) + diff --git a/scripts/ui/mekton_bulls_hud.gd.uid b/scripts/ui/mekton_bulls_hud.gd.uid new file mode 100644 index 0000000..99c0186 --- /dev/null +++ b/scripts/ui/mekton_bulls_hud.gd.uid @@ -0,0 +1 @@ +uid://h2uragoekxs1