2026-10-09 12:49:07 +02:00

366 lines
17 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@tool
extends RefCounted
## Play-Session Control (editor side): start or stop the editor playtest and
## confirm the live runtime's readiness.
##
## Owns the game.start / game.stop handlers (Mode A — start/stop a play session
## via EditorInterface.play_*/stop_playing_scene), the runtime-readiness probe
## (a WebSocket connect→auth→ping health-check over the live game's Mode-B
## server), and start-failure diagnosis (a LogBuffer scan that explains why the
## game never started). game.start marks the debug-log session as started by
## calling the log-reader's public mark_session_started() — the one-datum
## handoff to the Debug-Log Retrieval child.
## An extracted submodule of the playtest-command group, reached via a `preload` alias.
const Modules := preload("res://addons/godot_mcp_toolkit/core/modules.gd")
const FileGuard = Modules.FileGuard
const RegistryClient = Modules.RegistryClient
const Helpers = Modules.CommandHelpers
const MCPAuth := preload("res://addons/godot_mcp_toolkit/security/auth.gd")
const _LogReader := preload("res://addons/godot_mcp_toolkit/commands/playtest_log_reader.gd")
const RUNTIME_HOST := "127.0.0.1"
const RUNTIME_POLL_TIMEOUT_MS := 5000
const _REGISTRY_POLL_INTERVAL_MS := 100
# -- Commands -----------------------------------------------------------------
static func cmd_game_start(parameters: Dictionary) -> Dictionary:
# A headless editor cannot launch the game process, so the runtime (Mode B) never
# comes up — the pre-guard success:true was a false-success (nothing stays playing).
# Fail deterministically like editor.screenshot so an LLM branches instead of polling
# a runtime that will never connect. Display path is byte-identical below the guard.
if Modules.VersionUtils.is_headless():
return MCPToolkitError.fail("HEADLESS_UNSUPPORTED",
"playtest requires a display server (the game process cannot be launched headless) — use script_check, scene/node inspection, or editor_get_console for verification.")
var target := str(parameters.get("scene_path", "current"))
var wait_for_runtime_raw = parameters.get("wait_for_runtime", true)
var wait_for_runtime := bool(wait_for_runtime_raw) \
if typeof(wait_for_runtime_raw) == TYPE_BOOL else true
var runtime_poll_raw = parameters.get("runtime_poll", false)
var runtime_poll := bool(runtime_poll_raw) \
if typeof(runtime_poll_raw) == TYPE_BOOL else false
var if_running := str(parameters.get("if_running", "fail"))
if not (if_running in ["return", "fail"]):
return MCPToolkitError.fail("INVALID_PARAMS",
"if_running must be 'return' or 'fail' (got %s); default is 'fail'" % if_running)
if runtime_poll:
if not EditorInterface.is_playing_scene():
# runtime_poll never launches the game (probe-only), so reaching here means
# nothing was running to probe. An empty buffer can't prove a compile failure,
# so diagnose by captured evidence rather than asserting a cause.
var comp := _scan_compilation_errors()
var diag := _diagnose_startup(comp, false, Modules.VersionUtils.get_engine_version_pair())
return MCPToolkitError.fail(diag["code"], diag["message"], diag["hint"])
else:
if EditorInterface.is_playing_scene():
if if_running == "return":
var runtime_port := RegistryClient.get_runtime_port()
return MCPToolkitSuccess.ok({"status": "already_running",
"runtime_port": runtime_port if runtime_port > 0 else null})
return MCPToolkitError.fail("ALREADY_PLAYING",
"a game is already running; call game.stop first, or use runtime_poll:true to re-probe the runtime connection")
# Mark that a play session has started so the editor-side debugger.get_log
# cache reads the game's log. The engine truncates godot.log fresh on every
# launch, so the whole file is this session (the reader reads from byte 0).
_LogReader.mark_session_started()
match target:
"main":
# Pre-guard: play_main_scene() with no main scene defined pops an
# undismissable "No main scene has ever been defined" modal and returns,
# leaving the plugin to falsely report success (a later runtime_poll then
# misreads it as COMPILATION_FAILED). Fail deterministically instead —
# mirrors the "current" branch's NO_SCENE guard.
if _main_scene_missing():
return MCPToolkitError.fail("NO_SCENE",
"no main scene set — set one in Project Settings > Application > Run > Main Scene "
+ "(or project_set_setting application/run/main_scene='res://YourScene.tscn'), "
+ "or use target:'current' or a res:// scene path.")
EditorInterface.play_main_scene()
"current":
if Helpers.get_edited_root() == null:
return MCPToolkitError.fail("NO_SCENE",
"no currently-edited scene; use target:'main' or target:<res://path>, or scene.open first")
EditorInterface.play_current_scene()
_:
var guard := FileGuard.resolve_safe(target)
if guard["error"] != null:
return MCPToolkitError.fail("PATH_DENIED", str(guard["reason"]))
if target.get_extension().to_lower() != "tscn":
return MCPToolkitError.fail("INVALID_PATH",
"game.start only plays .tscn files (got %s)" % target)
if not FileAccess.file_exists(target):
return MCPToolkitError.fail("NOT_FOUND",
"no scene file at %s; use scene.create first" % target, MCPToolkitError.HINT_FILE_PATH)
EditorInterface.play_custom_scene(target)
# Runtime readiness check.
var runtime_port := -1
var runtime_ready := false
var runtime_failure := ""
var bridge_discovery := false
if runtime_poll:
# Explicit re-probe: full poll loop (runtime_poll:true path).
var deadline := Time.get_ticks_msec() + RUNTIME_POLL_TIMEOUT_MS
while Time.get_ticks_msec() < deadline:
runtime_port = RegistryClient.get_runtime_port()
if runtime_port > 0:
break
await Engine.get_main_loop().create_timer(_REGISTRY_POLL_INTERVAL_MS / 1000.0).timeout
if runtime_port > 0:
var remaining := maxi(500, deadline - Time.get_ticks_msec())
var probe := await _poll_runtime_ready(
RUNTIME_HOST, runtime_port, remaining)
runtime_ready = probe["ready"]
if not runtime_ready:
runtime_failure = str(probe.get("failure", "unknown"))
else:
runtime_failure = "registry_timeout"
elif wait_for_runtime:
# Godot's editor is single-threaded: play_custom_scene() defers
# game launch to the event loop, so blocking polls can never
# succeed here (main thread blocked → game cannot spawn).
# The agent must follow up with:
# game_start(if_running:"return", runtime_poll:true)
bridge_discovery = true
var response := MCPToolkitSuccess.ok({
"target": target,
"runtime_port": runtime_port if runtime_port > 0 else null,
"runtime_ready": runtime_ready,
})
if bridge_discovery:
response["runtime_discovery"] = "bridge"
# Hint text suppressed when wait_for_runtime=true — the MCP server
# absorbs the async gap and returns a single combined response.
# Non-server clients can key off runtime_discovery:"bridge" to follow
# up with game_start(if_running:'return', runtime_poll:true).
if not wait_for_runtime:
response["hint"] = (
"Game launched but runtime not yet connected (Godot defers the "
+ "game process — it cannot start during a blocking call). "
+ "Follow up with game_start(if_running:'return', runtime_poll:true) "
+ "to wait for runtime readiness."
)
if runtime_poll:
response["runtime_poll"] = true
if (wait_for_runtime or runtime_poll) and not runtime_ready and not bridge_discovery:
response["runtime_failure"] = runtime_failure
match runtime_failure:
"registry_timeout":
if not EditorInterface.is_playing_scene():
# The game was playing when the poll began but has stopped — a
# ran-then-died soft path, framed as a runtime crash (it began
# running, so a compile error is unlikely). runtime_ready:false +
# runtime_failure:"registry_timeout" are the honest structured signals.
var comp := _scan_compilation_errors()
var diag := _diagnose_startup(comp, true, Modules.VersionUtils.get_engine_version_pair())
response["hint"] = diag["hint"]
if not (diag["errors"] as Array).is_empty():
response["compilation_errors"] = diag["errors"]
else:
response["hint"] = "Runtime port never appeared in registry within the timeout. The game may need more time to start. Try game_start with runtime_poll:true to re-probe, or check editor_get_console for startup errors."
"token_read_failed":
response["hint"] = "Could not read auth token — the token file may be missing or empty. Re-enable the plugin in Project Settings > Plugins."
"ws_connect_timeout":
response["hint"] = "Port %d found but WebSocket connection failed — the runtime server may have crashed on startup. Check editor_get_console for errors." % runtime_port
"auth_timeout":
response["hint"] = "WebSocket connected but auth handshake timed out — the token may be stale. Re-enable the plugin in Project Settings > Plugins to regenerate it."
"ping_timeout":
response["hint"] = "Authenticated but ping/pong timed out — the runtime server may be overloaded. Use runtime_poll:true to retry without restarting the game."
_:
response["hint"] = "Runtime not ready (%s). Checklist: (1) Is the MCP Runtime autoload enabled? (2) Is the runtime port (default 6570) available? (3) Check editor_get_console for errors. (4) If runtime tools aren't loaded yet, call discover_tools({request: 'runtime'})." % runtime_failure
# P-006: When no poll was requested and runtime isn't ready, warn that
# runtime tools will block/fail. Prevents agents from calling
# runtime_screenshot / input_simulate on a game that didn't connect.
elif not runtime_ready and not wait_for_runtime and not runtime_poll:
response["hint"] = (
"runtime_ready is false — runtime tools (runtime_screenshot, input_simulate, "
+ "execute_code, etc.) will NOT work until the runtime connects. "
+ "Call game_start with wait_for_runtime:true to wait for connection, "
+ "or use runtime_poll:true to re-probe. "
+ "Check debugger_get_log or editor_get_console for startup errors."
)
return response
## True when no main scene is configured (application/run/main_scene unset or blank).
## Read FRESH from ProjectSettings — not a cached value — so a just-written setting is
## honored. Backs the game.start "main" pre-guard: fail deterministically rather than
## let play_main_scene() pop the engine's undismissable "No main scene has ever been
## defined" modal (which returns silently → a false success). Pure query; unit-tested.
static func _main_scene_missing() -> bool:
return str(ProjectSettings.get_setting("application/run/main_scene", "")).strip_edges().is_empty()
static func cmd_game_stop(_parameters: Dictionary) -> Dictionary:
var was_running := EditorInterface.is_playing_scene()
EditorInterface.stop_playing_scene()
return MCPToolkitSuccess.ok({"was_running": was_running})
# -- Runtime probe ------------------------------------------------------------
## WebSocket health-check: connect, authenticate, send ping, wait for response.
## Only reports true when the full JSON-RPC layer is operational (no false
## positives from a TCP-only probe).
## Returns {"ready": true} on success, or {"ready": false, "failure": <stage>}
## where stage is one of: token_read_failed, ws_connect_timeout, auth_timeout,
## ping_timeout.
static func _poll_runtime_ready(
host: String, port: int, timeout_ms: int,
) -> Dictionary:
var token_path := MCPAuth.get_token_path()
var token_file := FileAccess.open(token_path, FileAccess.READ)
if token_file == null:
return {"ready": false, "failure": "token_read_failed"}
var token := token_file.get_as_text().strip_edges()
token_file.close()
if token.is_empty():
return {"ready": false, "failure": "token_read_failed"}
var furthest := "ws_connect_timeout"
var deadline := Time.get_ticks_msec() + timeout_ms
while Time.get_ticks_msec() < deadline:
var ws := WebSocketPeer.new()
var err := ws.connect_to_url("ws://%s:%d" % [host, port])
if err != OK:
await Engine.get_main_loop().create_timer(0.1).timeout
continue
var auth_sent := false
var ping_sent := false
while Time.get_ticks_msec() < deadline:
ws.poll()
var state := ws.get_ready_state()
if state == WebSocketPeer.STATE_CLOSED \
or state == WebSocketPeer.STATE_CLOSING:
break
if state != WebSocketPeer.STATE_OPEN:
await Engine.get_main_loop().create_timer(0.01).timeout
continue
if furthest == "ws_connect_timeout":
furthest = "auth_timeout"
if not auth_sent:
ws.send_text(JSON.stringify({"auth": token}))
auth_sent = true
await Engine.get_main_loop().create_timer(0.01).timeout
continue
while ws.get_available_packet_count() > 0:
var text := ws.get_packet().get_string_from_utf8()
var parser := JSON.new()
if parser.parse(text) != OK:
continue
var msg = parser.data
if typeof(msg) != TYPE_DICTIONARY:
continue
if not ping_sent:
if msg.get("authed", false) == true:
ws.send_text(JSON.stringify({
"jsonrpc": "2.0", "method": "ping", "id": 0}))
ping_sent = true
furthest = "ping_timeout"
else:
if msg.has("result"):
ws.close(1000)
return {"ready": true}
await Engine.get_main_loop().create_timer(0.01).timeout
ws.close()
await Engine.get_main_loop().create_timer(0.1).timeout
return {"ready": false, "failure": furthest}
## Scan LogBuffer for recent errors — used to detect compilation failures
## when the game fails to start.
static func _scan_compilation_errors() -> Dictionary:
var buf := Modules.LogBuffer.get_entries(10, ["error"])
var errors: Array = []
for entry in buf.get("entries", []):
errors.append(str(entry.get("message", "")))
return {
"found": errors.size() > 0,
"errors": errors,
"source": "logger" if Modules.LogBuffer.uses_logger_api() else "file_tail",
}
## Phase-aware diagnosis of why a runtime probe found no live game, given the
## log-scan result and whether the game was observed running.
##
## Pure over its inputs so the whole decision is headless-unit-testable. Takes the
## [param comp] scan dict from [method _scan_compilation_errors]
## ([code]{"found", "errors", "source"}[/code]), [param was_running] (true when the
## game was seen playing this call — the soft "ran-then-died" path, false when
## nothing was ever confirmed running — the hard "never-started" path), and the
## running-engine [param version_pair] (e.g. [code]"4.5"[/code]) for the file-tail
## caveat. Returns [code]{"code", "message", "hint", "errors"}[/code].[br]
## [br]
## The framing only claims a compile failure when errors are actually in hand
## ([code]found[/code]) AND the game never ran: an empty buffer proves nothing about
## compilation (a cold-start parse error may not have landed, and 4.2–4.4 file-tail
## mode can miss parse errors entirely), so an empty buffer states the
## [code]GAME_NOT_RUNNING[/code] fact and offers both hypotheses rather than asserting
## a cause. A game that ran then stopped is framed as a runtime crash, not a compile
## error (it began running).
static func _diagnose_startup(
comp: Dictionary, was_running: bool, version_pair: String,
) -> Dictionary:
# comp[...] is Variant at the JSON-shaped scan boundary → coerce, never infer.
var found := bool(comp.get("found", false))
var errors: Array = comp.get("errors", [])
var is_file_tail := str(comp.get("source", "")) == "file_tail"
# The file-tail signal is version-gated (4.2–4.4); name the version only there so
# the agent knows the empty buffer may be a capture limit, not a clean startup.
var file_tail_caveat := (
" (Godot %s tails the log file and can miss compile errors.)" % version_pair
if is_file_tail else ""
)
if found:
var joined := "\n".join(errors)
if was_running:
return {
"code": "GAME_NOT_RUNNING",
"message": "Game stopped mid-probe with errors captured — likely a runtime crash:\n" + joined,
"hint": "The game began running, so a compile error is unlikely. Check debugger_get_log or editor_get_console for the crash." + file_tail_caveat,
"errors": errors,
}
return {
"code": "COMPILATION_FAILED",
"message": "Game failed to start — likely a startup or parse error. Recent errors:\n" + joined,
"hint": "Fix the errors shown, then call game_start again." + file_tail_caveat,
"errors": errors,
}
if was_running:
return {
"code": "GAME_NOT_RUNNING",
"message": "Game started then stopped before the probe completed.",
"hint": "Likely a runtime crash or early exit (it began running, so a compile error is unlikely). Check debugger_get_log or editor_get_console." + file_tail_caveat,
"errors": errors,
}
return {
"code": "GAME_NOT_RUNNING",
"message": "No running game to probe.",
"hint": (
"Either (a) nothing launched yet / still cold-starting — call game_start (without runtime_poll), "
+ "or retry runtime_poll:true shortly; or (b) it failed to compile. To surface a compile error not "
+ "in the buffer, editor_refresh then editor_get_console." + file_tail_caveat
),
"errors": errors,
}