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
+47 -12
View File
@@ -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]
@@ -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:
@@ -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()
+6 -3
View File
@@ -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()