Skip to content

Commit e635e77

Browse files
Report game liveness from project_run (#606)
1 parent defba5c commit e635e77

10 files changed

Lines changed: 438 additions & 40 deletions

File tree

docs/TOOLS.md

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ not the MCP tool names.
1414

1515
| Tool | Description |
1616
|------|-------------|
17-
| `editor_state` | Editor version, project name, current scene, readiness, play state |
17+
| `editor_state` | Editor version, project name, current scene, readiness, play state, and game liveness status |
1818
| `scene_get_hierarchy` | Paginated scene tree walk (depth, offset, limit) |
1919
| `node_get_properties` | Full property snapshot of a node |
2020
| `session_activate` | Pin subsequent calls to a specific connected editor |
@@ -27,7 +27,7 @@ not the MCP tool names.
2727
| `node_create` / `node_set_property` / `node_find` | Common node writes + search |
2828
| `scene_open` / `scene_save` | Open and save scenes |
2929
| `script_create` / `script_attach` / `script_patch` | Create, attach, anchor-edit GDScript files |
30-
| `project_run` | Play the project (autosave persists in-memory MCP edits unless `autosave=False`) |
30+
| `project_run` | Play the project, then wait briefly for game liveness (autosave persists in-memory MCP edits unless `autosave=False`) |
3131
| `test_run` | Run GDScript test suites in the editor |
3232
| `logs_read` | Read plugin / game / editor / combined log buffers. `source="editor"` surfaces parse errors, GDScript reload warnings, @tool/EditorPlugin runtime errors, push_error/push_warning, and visible Debugger dock Errors-tab rows — use this when the editor's Output or Debugger Errors panel shows red/yellow rows |
3333
| `editor_screenshot` | Capture editor viewport, cinematic camera, or running game framebuffer |
@@ -40,6 +40,27 @@ Godot `_log_error` code/rationale when available, error type, resolved source
4040
location, and stack/error-tree context corresponding to the Debugger dock's
4141
Errors tab.
4242

43+
`project_run` starts playback and waits briefly for the running game to become
44+
live through `_mcp_game_helper`. Its response includes `game_status`,
45+
`helper_live`, `session_active`, and any `recent_errors` found while waiting.
46+
The booleans are derived once inside `game_status` and mirrored at the top
47+
level for convenience.
48+
`game_status.status="live"` means the helper checked in. `"not_live"` means the
49+
game launched but did not become live before the helper-ready window elapsed;
50+
if a run-scoped parse/load error appeared, the response names it and points to
51+
`logs_read(source="editor", include_details=true)`. `"no_helper"` means the
52+
game launched but this project has no `_mcp_game_helper` autoload, as with some
53+
headless/custom-main-loop setups: `helper_live=false` while
54+
`session_active=true`. `"launching"` is a soft "not live yet" state and can
55+
reconcile on a later `editor_state` poll.
56+
57+
`editor_state` includes the same `game_status` object in addition to the legacy
58+
`is_playing` boolean and `game_capture_ready`. It also mirrors
59+
`game_status.helper_live` (`game_status.status=="live"`) and
60+
`game_status.session_active` (`game_status.status` is not `"not_live"` or
61+
`"stopped"`). `is_playing` remains raw editor play-state for compatibility; use
62+
`game_status.status` for liveness decisions.
63+
4364
For game logs, `logs_read(source="game")` returns lines from the current game
4465
run only. Each play-start creates a new `run_id`, even if the game never reaches
4566
the `_mcp_game_helper` hello beacon; prior run lines stay retained but do not
@@ -51,12 +72,15 @@ one. There is no single `source="game"` call that returns every retained game
5172
line across all runs; consumers that need history should retain run ids and
5273
query each run explicitly.
5374

54-
Game and combined log responses also include `game_status` and a liveness-based
55-
`is_running`. `is_running` is no longer raw editor play-state: it is `false` for
75+
Game and combined log responses also include `game_status`, `helper_live`, and
76+
`session_active`; the top-level booleans mirror `game_status.helper_live` and
77+
`game_status.session_active`. For compatibility, `is_running` is retained as an
78+
alias of `session_active`; it is no longer raw editor play-state. Both are `false` for
5679
`game_status.status` of `"not_live"` or `"stopped"`, and `true` for `"live"`,
5780
`"launching"`, or `"no_helper"`. This lets a parse/load failure that leaves the
5881
editor play button active report as not running, while a legitimate headless or
59-
custom-main-loop project without `_mcp_game_helper` remains running with
82+
custom-main-loop project without `_mcp_game_helper` remains active with
83+
`helper_live=false`, `session_active=true`, and
6084
`game_status.status="no_helper"`.
6185

6286
For incremental editor-log polling, call `logs_read(source="editor")` once and

plugin/addons/godot_ai/debugger/mcp_debugger_plugin.gd

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ func is_game_capture_ready() -> bool:
142142
return _game_run_active and _game_ready and _ready_run_token == _game_run_token
143143

144144

145+
static func with_liveness_flags(status: Dictionary) -> Dictionary:
146+
var enriched := status.duplicate(true)
147+
var state := str(enriched.get("status", "stopped"))
148+
enriched["helper_live"] = state == "live"
149+
enriched["session_active"] = not state in ["not_live", "stopped"]
150+
return enriched
151+
152+
145153
func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT_SEC) -> Dictionary:
146154
var resolved_now := Time.get_ticks_msec() if now_msec < 0 else now_msec
147155
var ready_wait_msec := maxi(0, int(ready_wait_sec * 1000.0))
@@ -157,7 +165,7 @@ func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT
157165
status = "not_live"
158166
else:
159167
status = "launching"
160-
return {
168+
return with_liveness_flags({
161169
"status": status,
162170
"run_token": _game_run_token,
163171
"active": _game_run_active,
@@ -167,12 +175,12 @@ func get_game_status(now_msec: int = -1, ready_wait_sec: float = GAME_READY_WAIT
167175
"elapsed_msec": elapsed_msec,
168176
"ready_wait_msec": ready_wait_msec,
169177
"editor_log_cursor": _game_run_started_editor_cursor,
170-
}
178+
})
171179

172180

173181
func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ERROR) -> Dictionary:
174182
var state := str(status.get("status", "stopped"))
175-
var errors_info := _recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
183+
var errors_info := recent_editor_errors_since(int(status.get("editor_log_cursor", 0)))
176184
var recent_errors: Array = errors_info.get("errors", [])
177185
var recent_errors_scope := str(errors_info.get("scope", "none"))
178186
var truncated := bool(errors_info.get("truncated", false))
@@ -209,6 +217,10 @@ func _explain_not_live(status: Dictionary, code: String = ErrorCodes.INTERNAL_ER
209217
return err
210218

211219

220+
func recent_editor_errors_since(cursor: int) -> Dictionary:
221+
return _recent_editor_errors_since(cursor)
222+
223+
212224
func _recent_editor_errors_since(cursor: int) -> Dictionary:
213225
var out: Array[Dictionary] = []
214226
var truncated := false

plugin/addons/godot_ai/dispatcher.gd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const DEFAULT_DEFERRED_TIMEOUT_MS := 4500
1616
const DEFERRED_TIMEOUT_MS_BY_COMMAND := {
1717
"create_script": 4500,
1818
"stop_project": 4500,
19+
"run_project": 6000,
1920
"take_screenshot": 30000,
2021
"game_eval": 15000,
2122
"game_command": 15000,

plugin/addons/godot_ai/handlers/editor_handler.gd

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ func _init(log_buffer: McpLogBuffer, connection: McpConnection = null, debugger_
2828

2929
func get_editor_state(_params: Dictionary) -> Dictionary:
3030
var scene_root := EditorInterface.get_edited_scene_root()
31+
var game_status := _current_game_status()
3132
var data := {
3233
"godot_version": Engine.get_version_info().get("string", "unknown"),
3334
"project_name": ProjectSettings.get_setting("application/config/name", ""),
@@ -38,6 +39,9 @@ func get_editor_state(_params: Dictionary) -> Dictionary:
3839
## false between Play→Stop cycles. Lets capture-source=game callers
3940
## poll for a real ready signal instead of guessing with sleep().
4041
"game_capture_ready": _debugger_plugin != null and _debugger_plugin.is_game_capture_ready(),
42+
"game_status": game_status,
43+
"helper_live": bool(game_status.get("helper_live", false)),
44+
"session_active": bool(game_status.get("session_active", false)),
4145
}
4246
## Half-installed addon tree from a failed self-update rollback. When
4347
## non-empty, the agent / dock paint the operator-facing recovery copy
@@ -93,20 +97,15 @@ func get_logs(params: Dictionary) -> Dictionary:
9397

9498
func _current_game_status() -> Dictionary:
9599
if _debugger_plugin == null:
96-
return {
100+
return McpDebuggerPlugin.with_liveness_flags({
97101
"status": "stopped",
98102
"active": false,
99103
"ready": false,
100104
"helper_expected": true,
101-
}
105+
})
102106
return _debugger_plugin.get_game_status()
103107

104108

105-
func _is_game_log_running(game_status: Dictionary) -> bool:
106-
var status := str(game_status.get("status", "stopped"))
107-
return not status in ["not_live", "stopped"]
108-
109-
110109
func _get_plugin_logs(count: int, offset: int) -> Dictionary:
111110
var all_lines := _log_buffer.get_recent(_log_buffer.total_count())
112111
var page: Array[Dictionary] = []
@@ -126,6 +125,8 @@ func _get_plugin_logs(count: int, offset: int) -> Dictionary:
126125

127126
func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id: String = "") -> Dictionary:
128127
var game_status := _current_game_status()
128+
var helper_live := bool(game_status.get("helper_live", false))
129+
var session_active := bool(game_status.get("session_active", false))
129130
if _game_log_buffer == null:
130131
return {
131132
"data": {
@@ -136,7 +137,9 @@ func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id
136137
"offset": offset,
137138
"run_id": "",
138139
"current_run_id": "",
139-
"is_running": false,
140+
"is_running": session_active,
141+
"helper_live": helper_live,
142+
"session_active": session_active,
140143
"game_status": game_status,
141144
"dropped_count": 0,
142145
"stale_run_id": false,
@@ -156,7 +159,9 @@ func _get_game_logs(count: int, offset: int, include_details: bool, since_run_id
156159
"offset": offset,
157160
"run_id": target_run_id,
158161
"current_run_id": current_run_id,
159-
"is_running": _is_game_log_running(game_status),
162+
"is_running": session_active,
163+
"helper_live": helper_live,
164+
"session_active": session_active,
160165
"game_status": game_status,
161166
"dropped_count": _game_log_buffer.dropped_count(),
162167
"stale_run_id": stale_run_id,
@@ -266,7 +271,9 @@ func _get_all_logs(count: int, offset: int, include_details: bool) -> Dictionary
266271
"offset": offset,
267272
"run_id": run_id,
268273
"current_run_id": current_run_id,
269-
"is_running": _is_game_log_running(game_status),
274+
"is_running": bool(game_status.get("session_active", false)),
275+
"helper_live": bool(game_status.get("helper_live", false)),
276+
"session_active": bool(game_status.get("session_active", false)),
270277
"game_status": game_status,
271278
"dropped_count": dropped,
272279
}

0 commit comments

Comments
 (0)