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

This commit is contained in:
2026-07-01 10:39:21 +08:00
parent cc584c3251
commit b6b37b5aac
48 changed files with 2548 additions and 397 deletions
+68 -8
View File
@@ -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)
+12 -11
View File
@@ -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