@satelliteoflove/godot-mcp 3.7.1 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/addon/command_router.gd +1 -0
  2. package/addon/command_router.gd.uid +1 -0
  3. package/addon/commands/animation_commands.gd.uid +1 -0
  4. package/addon/commands/debug_commands.gd +20 -1
  5. package/addon/commands/debug_commands.gd.uid +1 -0
  6. package/addon/commands/game_time_commands.gd +83 -0
  7. package/addon/commands/game_time_commands.gd.uid +1 -0
  8. package/addon/commands/node_commands.gd.uid +1 -0
  9. package/addon/commands/profiler_commands.gd.uid +1 -0
  10. package/addon/commands/project_commands.gd.uid +1 -0
  11. package/addon/commands/resource_commands.gd.uid +1 -0
  12. package/addon/commands/runtime_state_commands.gd.uid +1 -0
  13. package/addon/commands/scene3d_commands.gd.uid +1 -0
  14. package/addon/commands/scene_commands.gd.uid +1 -0
  15. package/addon/commands/screenshot_commands.gd.uid +1 -0
  16. package/addon/commands/script_commands.gd.uid +1 -0
  17. package/addon/commands/selection_commands.gd.uid +1 -0
  18. package/addon/commands/tilemap_commands.gd.uid +1 -0
  19. package/addon/core/base_command.gd.uid +1 -0
  20. package/addon/core/mcp_constants.gd.uid +1 -0
  21. package/addon/core/mcp_debugger_plugin.gd.uid +1 -0
  22. package/addon/core/mcp_enums.gd.uid +1 -0
  23. package/addon/core/mcp_logger.gd +1 -1
  24. package/addon/core/mcp_logger.gd.uid +1 -0
  25. package/addon/core/mcp_utils.gd.uid +1 -0
  26. package/addon/game_bridge/mcp_frame_profiler.gd.uid +1 -0
  27. package/addon/game_bridge/mcp_game_bridge.gd +572 -3
  28. package/addon/game_bridge/mcp_game_bridge.gd.uid +1 -0
  29. package/addon/game_bridge/mcp_runtime_state_sampler.gd.uid +1 -0
  30. package/addon/game_bridge/onscreen.gd.uid +1 -0
  31. package/addon/plugin.cfg +1 -1
  32. package/addon/plugin.gd.uid +1 -0
  33. package/addon/ui/status_panel.gd.uid +1 -0
  34. package/addon/websocket_server.gd.uid +1 -0
  35. package/dist/__tests__/tools/editor.test.js +8 -0
  36. package/dist/__tests__/tools/editor.test.js.map +1 -1
  37. package/dist/__tests__/tools/game-time.test.d.ts +2 -0
  38. package/dist/__tests__/tools/game-time.test.d.ts.map +1 -0
  39. package/dist/__tests__/tools/game-time.test.js +204 -0
  40. package/dist/__tests__/tools/game-time.test.js.map +1 -0
  41. package/dist/tools/editor.d.ts +1 -0
  42. package/dist/tools/editor.d.ts.map +1 -1
  43. package/dist/tools/editor.js +9 -2
  44. package/dist/tools/editor.js.map +1 -1
  45. package/dist/tools/game-time.d.ts +30 -0
  46. package/dist/tools/game-time.d.ts.map +1 -0
  47. package/dist/tools/game-time.js +125 -0
  48. package/dist/tools/game-time.js.map +1 -0
  49. package/dist/tools/index.d.ts +1 -0
  50. package/dist/tools/index.d.ts.map +1 -1
  51. package/dist/tools/index.js +3 -0
  52. package/dist/tools/index.js.map +1 -1
  53. package/dist/tools/input.d.ts +5 -0
  54. package/dist/tools/input.d.ts.map +1 -1
  55. package/dist/tools/input.js +1 -1
  56. package/dist/tools/input.js.map +1 -1
  57. package/package.json +1 -1
@@ -27,8 +27,18 @@ func _ready() -> void:
27
27
  _sampler = MCPRuntimeStateSampler.new()
28
28
  add_child(_sampler)
29
29
  EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
30
+ set_physics_process(false) # only counts ticks during a step window
30
31
  MCPLog.info("Game bridge initialized")
31
32
 
33
+ # Launch-frozen: the editor sets this env var just before spawning the game
34
+ # (godot_editor run with frozen=true), so the freeze lands before the first
35
+ # process frame — agent latency between run and the first input costs the
36
+ # game nothing. Scene _ready callbacks still run; processing does not start.
37
+ if OS.get_environment(LAUNCH_FROZEN_ENV) == "1":
38
+ _launched_frozen = true
39
+ _engage_freeze()
40
+ MCPLog.info("Game bridge: launched frozen")
41
+
32
42
 
33
43
  func _exit_tree() -> void:
34
44
  # Guaranteed cleanup: never leave an action latched when the bridge node
@@ -40,7 +50,19 @@ func _exit_tree() -> void:
40
50
  EngineDebugger.unregister_profiler("mcp_frame_profiler")
41
51
 
42
52
 
43
- func _process(_delta: float) -> void:
53
+ func _process(delta: float) -> void:
54
+ _game_time_process(delta)
55
+ _sequence_process()
56
+
57
+
58
+ # Processing is needed by three independent features; only switch it off when
59
+ # none of them is active (the frozen monitor must run every frame, so the old
60
+ # "disable after the sequence" shortcut no longer applies unconditionally).
61
+ func _update_processing() -> void:
62
+ set_process(_sequence_running or _frozen or _step_active)
63
+
64
+
65
+ func _sequence_process() -> void:
44
66
  if not _sequence_running or _sequence_events.is_empty():
45
67
  return
46
68
 
@@ -61,7 +83,7 @@ func _process(_delta: float) -> void:
61
83
 
62
84
  if _sequence_events.is_empty():
63
85
  _sequence_running = false
64
- set_process(false)
86
+ _update_processing()
65
87
  EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
66
88
  "completed": true,
67
89
  "actions_executed": _actions_completed,
@@ -142,6 +164,21 @@ func _on_debugger_message(message: String, data: Array) -> bool:
142
164
  "watch_stop":
143
165
  _handle_watch_stop()
144
166
  return true
167
+ "game_time_freeze":
168
+ _handle_game_time_freeze(data)
169
+ return true
170
+ "game_time_step":
171
+ _handle_game_time_step(data)
172
+ return true
173
+ "game_time_step_until":
174
+ _handle_game_time_step_until(data)
175
+ return true
176
+ "game_time_thaw":
177
+ _handle_game_time_thaw(data)
178
+ return true
179
+ "game_time_status":
180
+ _handle_game_time_status(data)
181
+ return true
145
182
  return false
146
183
 
147
184
 
@@ -965,7 +1002,7 @@ func _handle_execute_input_sequence(data: Array) -> void:
965
1002
 
966
1003
  _sequence_start_time = Time.get_ticks_msec()
967
1004
  _sequence_running = true
968
- set_process(true)
1005
+ _update_processing()
969
1006
 
970
1007
 
971
1008
  func _handle_type_text(data: Array) -> void:
@@ -1022,3 +1059,535 @@ func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
1022
1059
  "chars_typed": text.length(),
1023
1060
  "submitted": submit,
1024
1061
  }])
1062
+
1063
+
1064
+ # ---------------------------------------------------------------------------
1065
+ # Game-time control (freeze / step / thaw / status)
1066
+ #
1067
+ # Real-time games race ahead of high-latency agents: 10-40s of consequences
1068
+ # land between every observation and the action it informs. These primitives
1069
+ # make game time answer to the agent's clock instead: freeze the tree, think
1070
+ # arbitrarily long (all observation tools work while frozen — rendering
1071
+ # continues during pause), then step forward a bounded slice of game time
1072
+ # with inputs riding inside the window.
1073
+ #
1074
+ # tree.paused is a single bit that two parties now write: the game's own
1075
+ # pause menu and this freeze. The bridge layers them — effective state is
1076
+ # (game_paused OR frozen) — by observing and re-asserting: it cannot
1077
+ # intercept writes, but it processes every frame (PROCESS_MODE_ALWAYS, and
1078
+ # as an autoload it runs BEFORE the scene), so a game-code flip is caught on
1079
+ # the next frame, recorded as the game layer's new intent, and overridden
1080
+ # while frozen. step/thaw restore the game's wish, not whatever we found.
1081
+ #
1082
+ # What freeze means: exactly what runs during the game's own pause menu runs
1083
+ # during freeze (WHEN_PAUSED/ALWAYS nodes, process_always timers). A game
1084
+ # with a correct pause menu has already partitioned pause-immune from
1085
+ # pausable code; freeze rides that contract. Games that "pause" by writing
1086
+ # Engine.time_scale = 0 instead are frozen solid too, but their pause is
1087
+ # invisible to the layer model (it looks like gameplay state, not pause
1088
+ # state) — documented limitation.
1089
+ # ---------------------------------------------------------------------------
1090
+
1091
+ const LAUNCH_FROZEN_ENV := "GODOT_MCP_LAUNCH_FROZEN"
1092
+ # Timeout cascade: step request <= 20s game time, wall budget 25s, editor
1093
+ # relay 28s, server command timeout 30s. Each layer answers before the one
1094
+ # above it gives up.
1095
+ const STEP_MAX_MS := 20000
1096
+ const STEP_MAX_FRAMES := 1200
1097
+ const STEP_WALL_BUDGET_MS := 25000
1098
+ const STEP_MAX_TRANSITIONS := 50
1099
+ const FREEZE_CONTESTED_THRESHOLD := 10
1100
+
1101
+ var _frozen := false
1102
+ var _game_paused := false # the game layer's own pause intent, inferred by observation
1103
+ var _launched_frozen := false
1104
+ var _freeze_started_ticks := 0
1105
+ var _freeze_transition_count := 0
1106
+
1107
+ var _step_active := false
1108
+ var _step_finish_pending := false
1109
+ var _step_needs_settle := false
1110
+ var _step_wall_exceeded := false
1111
+ var _step_target_ms := 0.0
1112
+ var _step_target_frames := 0
1113
+ var _step_elapsed_ms := 0.0 # accumulated scaled delta = game time (wall-of-step, includes game-paused stretches)
1114
+ var _step_gameplay_ms := 0.0 # the unpaused portion: what gameplay actually experienced
1115
+ var _step_frames := 0
1116
+ var _step_physics_ticks := 0
1117
+ var _step_wall_start := 0
1118
+ var _step_events: Array = [] # in-step input timeline, scheduled on the game-time clock
1119
+ var _step_events_fired := 0
1120
+ var _step_transitions: Array = []
1121
+ var _step_last_tree_paused := false
1122
+
1123
+ # step_until adds a predicate evaluated each frame. _step_predicate is null for a
1124
+ # fixed-budget step, set for step_until; _step_response_type routes _finish_step's
1125
+ # reply to the matching command (the relay correlates by message type). _step_report
1126
+ # is the optional readings the agent wants back at stop time (in one round-trip,
1127
+ # instead of a separate observation call) — each is [{src: String, expr: Expression}].
1128
+ var _step_predicate: Expression = null
1129
+ var _step_predicate_inputs: Array = []
1130
+ var _step_predicate_met := false
1131
+ var _step_predicate_error := ""
1132
+ var _step_report: Array = []
1133
+ var _step_response_type := "game_time_step"
1134
+
1135
+
1136
+ func _send_game_time_response(msg_type: String, result: Dictionary) -> void:
1137
+ EngineDebugger.send_message("godot_mcp:game_response", [msg_type, result])
1138
+
1139
+
1140
+ func _engage_freeze() -> void:
1141
+ if _frozen:
1142
+ return
1143
+ var tree := get_tree()
1144
+ _game_paused = tree.paused
1145
+ _frozen = true
1146
+ _freeze_started_ticks = Time.get_ticks_msec()
1147
+ _freeze_transition_count = 0
1148
+ tree.paused = true
1149
+ _update_processing()
1150
+
1151
+
1152
+ # Per-frame monitor: dispatches to the step runner during a window, otherwise
1153
+ # holds the freeze against game-code writes.
1154
+ func _game_time_process(delta: float) -> void:
1155
+ if _step_active:
1156
+ _step_process(delta)
1157
+ return
1158
+ if not _frozen:
1159
+ return
1160
+ var tree := get_tree()
1161
+ if not tree.paused:
1162
+ # Game code unpaused under the freeze (a WHEN_PAUSED resume button, an
1163
+ # auto-unpausing cutscene). Record the game layer's new intent and
1164
+ # re-assert — the freeze answers to the agent; the game's wish is
1165
+ # restored on step/thaw. Only unpause flips are observable here: while
1166
+ # frozen, tree.paused is already true.
1167
+ _game_paused = false
1168
+ _freeze_transition_count += 1
1169
+ tree.paused = true
1170
+
1171
+
1172
+ func _physics_process(_delta: float) -> void:
1173
+ if _step_active and not _step_finish_pending and not get_tree().paused:
1174
+ _step_physics_ticks += 1
1175
+
1176
+
1177
+ func _handle_game_time_freeze(_data: Array) -> void:
1178
+ if _step_active:
1179
+ _send_game_time_response("game_time_freeze", {"error": "Step in progress"})
1180
+ return
1181
+ var was_frozen := _frozen
1182
+ _engage_freeze()
1183
+ _send_game_time_response("game_time_freeze", {
1184
+ "frozen": true,
1185
+ "was_frozen": was_frozen,
1186
+ "game_paused": _game_paused,
1187
+ })
1188
+
1189
+
1190
+ func _handle_game_time_thaw(_data: Array) -> void:
1191
+ if _step_active:
1192
+ _send_game_time_response("game_time_thaw", {"error": "Step in progress"})
1193
+ return
1194
+ var was_frozen := _frozen
1195
+ var result: Dictionary = {"frozen": false, "was_frozen": was_frozen}
1196
+ if was_frozen:
1197
+ # Real wall-clock the freeze was held; game time did not advance while frozen.
1198
+ result["frozen_wall_ms"] = Time.get_ticks_msec() - _freeze_started_ticks
1199
+ _frozen = false
1200
+ get_tree().paused = _game_paused
1201
+ _update_processing()
1202
+ result["game_paused"] = _game_paused if was_frozen else get_tree().paused
1203
+ _send_game_time_response("game_time_thaw", result)
1204
+
1205
+
1206
+ func _handle_game_time_status(_data: Array) -> void:
1207
+ var tree := get_tree()
1208
+ var tree_paused: bool = tree.paused if tree else false
1209
+ var result: Dictionary = {
1210
+ "frozen": _frozen,
1211
+ "game_paused": _game_paused if _frozen else tree_paused,
1212
+ "tree_paused": tree_paused,
1213
+ "engine_time_scale": Engine.time_scale,
1214
+ "physics_ticks_per_second": Engine.physics_ticks_per_second,
1215
+ }
1216
+ # `frozen` is the authoritative current state. `launched_frozen` is a historical
1217
+ # fact (this run booted frozen via GODOT_MCP_LAUNCH_FROZEN) and stays true after
1218
+ # thaw, so it must not be read as the present freeze state.
1219
+ if _launched_frozen:
1220
+ result["launched_frozen"] = true
1221
+ if _frozen:
1222
+ # Real wall-clock since freeze engaged, not game time (which is stopped).
1223
+ result["frozen_wall_ms"] = Time.get_ticks_msec() - _freeze_started_ticks
1224
+ result["freeze_transitions"] = _freeze_transition_count
1225
+ if _freeze_transition_count >= FREEZE_CONTESTED_THRESHOLD:
1226
+ # Something (an ALWAYS-mode node?) is repeatedly unpausing under
1227
+ # the freeze. Each re-assert can leak up to one frame; report the
1228
+ # contest rather than pretend the freeze is airtight.
1229
+ result["freeze_contested"] = true
1230
+ if _step_active:
1231
+ result["step_active"] = true
1232
+ _send_game_time_response("game_time_status", result)
1233
+
1234
+
1235
+ func _handle_game_time_step(data: Array) -> void:
1236
+ var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
1237
+ if _step_active:
1238
+ _send_game_time_response("game_time_step", {"error": "Step already in progress"})
1239
+ return
1240
+
1241
+ var duration_ms: int = int(params.get("duration_ms", 0))
1242
+ var frames: int = int(params.get("frames", 0))
1243
+ if duration_ms <= 0 and frames <= 0:
1244
+ _send_game_time_response("game_time_step", {"error": "step requires duration_ms or frames"})
1245
+ return
1246
+ duration_ms = mini(duration_ms, STEP_MAX_MS)
1247
+ frames = mini(frames, STEP_MAX_FRAMES)
1248
+
1249
+ # Validate and schedule the in-step input timeline (start_ms is game-time
1250
+ # from window start). Inputs must ride inside the step: an event injected
1251
+ # while frozen lands on a frame gameplay never processes, so its
1252
+ # is_action_just_pressed edge would be silently missed.
1253
+ var compiled := _compile_step_events(params.get("inputs", []))
1254
+ if compiled.has("error"):
1255
+ _send_game_time_response("game_time_step", {"error": compiled["error"]})
1256
+ return
1257
+
1258
+ # Step from a running game is allowed — it freezes first, so "advance
1259
+ # 500ms then wait for me" is a single atomic call.
1260
+ _engage_freeze()
1261
+
1262
+ _step_target_ms = float(duration_ms)
1263
+ _step_target_frames = frames
1264
+ _step_elapsed_ms = 0.0
1265
+ _step_gameplay_ms = 0.0
1266
+ _step_frames = 0
1267
+ _step_physics_ticks = 0
1268
+ _step_events = compiled["events"]
1269
+ _step_events_fired = 0
1270
+ _step_transitions = []
1271
+ _step_needs_settle = false
1272
+ _step_finish_pending = false
1273
+ _step_wall_exceeded = false
1274
+ _step_wall_start = Time.get_ticks_msec()
1275
+ _step_predicate = null
1276
+ _step_response_type = "game_time_step"
1277
+ _step_active = true
1278
+
1279
+ # Open the window: restore the game layer's own pause wish for the
1280
+ # duration. If the game's menu is holding it paused, the window still
1281
+ # elapses (and reports gameplay_ms ~0) — never deadlock waiting for
1282
+ # gameplay time that cannot come.
1283
+ var tree := get_tree()
1284
+ tree.paused = _game_paused
1285
+ _step_last_tree_paused = tree.paused
1286
+ set_physics_process(true)
1287
+ _update_processing()
1288
+
1289
+
1290
+ func _compile_step_events(inputs: Array) -> Dictionary:
1291
+ # Builds the press/release timeline shared by step and step_until. start_ms
1292
+ # is game time from window start; returns {"error": ...} on an unknown action.
1293
+ var events: Array = []
1294
+ for input in inputs:
1295
+ var action_name: String = input.get("action_name", "")
1296
+ if action_name.is_empty():
1297
+ continue
1298
+ if not InputMap.has_action(action_name):
1299
+ return {"error": "Unknown action: %s" % action_name}
1300
+ var start_ms: int = int(input.get("start_ms", 0))
1301
+ var dur: int = int(input.get("duration_ms", 0))
1302
+ events.append({"time": start_ms, "action": action_name, "is_press": true})
1303
+ events.append({"time": start_ms + dur, "action": action_name, "is_press": false})
1304
+ events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
1305
+ return a.time < b.time
1306
+ )
1307
+ return {"events": events}
1308
+
1309
+
1310
+ func _build_predicate_context() -> Dictionary:
1311
+ # Exposes the running game to a step_until predicate: every autoload by its
1312
+ # own name (so `G.wave > 1` just works), plus `tree` (SceneTree) and `root`
1313
+ # (root Window) for tree queries like
1314
+ # `tree.get_nodes_in_group("enemies").size() >= 1`. Chained calls must run on
1315
+ # these input objects, not the Expression base instance, so they are inputs.
1316
+ var names: Array = []
1317
+ var inputs: Array = []
1318
+ var tree := get_tree()
1319
+ for prop in ProjectSettings.get_property_list():
1320
+ var key: String = prop.get("name", "")
1321
+ if not key.begins_with("autoload/"):
1322
+ continue
1323
+ var autoload_name := key.substr("autoload/".length())
1324
+ var node := tree.root.get_node_or_null(NodePath(autoload_name))
1325
+ if node == null or node == self:
1326
+ continue # skip the bridge's own autoload and any unresolved entry
1327
+ names.append(autoload_name)
1328
+ inputs.append(node)
1329
+ if not names.has("tree"):
1330
+ names.append("tree")
1331
+ inputs.append(tree)
1332
+ if not names.has("root"):
1333
+ names.append("root")
1334
+ inputs.append(tree.root)
1335
+ return {"names": PackedStringArray(names), "inputs": inputs}
1336
+
1337
+
1338
+ func _sanitize_value(v: Variant) -> Variant:
1339
+ # Report values ride back over the debugger channel. Pass primitives through;
1340
+ # never try to serialize Objects/containers — a short string stand-in is
1341
+ # enough for the agent to see what an expression evaluated to.
1342
+ match typeof(v):
1343
+ TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME:
1344
+ return v
1345
+ _:
1346
+ return str(v).substr(0, 200)
1347
+
1348
+
1349
+ func _compile_report(report: Array, names: PackedStringArray, inputs: Array) -> Dictionary:
1350
+ # Compile + validate each report expression in the predicate context. Returns
1351
+ # {"error": ...} if any fails up front, else {"report": [{src, expr}, ...]}.
1352
+ var compiled: Array = []
1353
+ for item in report:
1354
+ var s := str(item).strip_edges()
1355
+ if s.is_empty():
1356
+ continue
1357
+ var e := Expression.new()
1358
+ if e.parse(s, names) != OK:
1359
+ return {"error": "report expression parse error (%s): %s" % [s, e.get_error_text()]}
1360
+ e.execute(inputs, self)
1361
+ if e.has_execute_failed():
1362
+ return {"error": "report expression failed to evaluate (%s): %s" % [s, e.get_error_text()]}
1363
+ compiled.append({"src": s, "expr": e})
1364
+ return {"report": compiled}
1365
+
1366
+
1367
+ func _evaluate_report(report_exprs: Array, inputs: Array) -> Dictionary:
1368
+ # Evaluate the compiled report expressions at stop time into {src: value}.
1369
+ var out: Dictionary = {}
1370
+ for item in report_exprs:
1371
+ var e: Expression = item["expr"]
1372
+ var v: Variant = e.execute(inputs, self)
1373
+ if e.has_execute_failed():
1374
+ out[item["src"]] = "<error: %s>" % e.get_error_text()
1375
+ else:
1376
+ out[item["src"]] = _sanitize_value(v)
1377
+ return out
1378
+
1379
+
1380
+ func _handle_game_time_step_until(data: Array) -> void:
1381
+ var params: Dictionary = data[0] if data.size() > 0 and data[0] is Dictionary else {}
1382
+ if _step_active:
1383
+ _send_game_time_response("game_time_step_until", {"error": "Step already in progress"})
1384
+ return
1385
+
1386
+ var src: String = str(params.get("until", "")).strip_edges()
1387
+ if src.is_empty():
1388
+ _send_game_time_response("game_time_step_until", {"error": "step_until requires a non-empty `until` expression"})
1389
+ return
1390
+
1391
+ var max_ms: int = int(params.get("max_ms", STEP_MAX_MS))
1392
+ if max_ms <= 0:
1393
+ max_ms = STEP_MAX_MS
1394
+ max_ms = mini(max_ms, STEP_MAX_MS)
1395
+
1396
+ # Compile and validate the predicate against the live tree before committing
1397
+ # to a step. Expression.parse() is lenient (a malformed string can parse
1398
+ # clean), so a dry-run execute is what actually catches unknown identifiers
1399
+ # and bad member access.
1400
+ var ctx := _build_predicate_context()
1401
+ var ctx_names: PackedStringArray = ctx["names"]
1402
+ var ctx_inputs: Array = ctx["inputs"]
1403
+ var expr := Expression.new()
1404
+ if expr.parse(src, ctx_names) != OK:
1405
+ _send_game_time_response("game_time_step_until", {"error": "predicate parse error: %s" % expr.get_error_text()})
1406
+ return
1407
+ var first_value: Variant = expr.execute(ctx_inputs, self)
1408
+ if expr.has_execute_failed():
1409
+ _send_game_time_response("game_time_step_until", {"error": "predicate failed to evaluate: %s" % expr.get_error_text()})
1410
+ return
1411
+
1412
+ # Optional readings to return at stop time, validated up front in the same context.
1413
+ var report_result := _compile_report(params.get("report", []), ctx_names, ctx_inputs)
1414
+ if report_result.has("error"):
1415
+ _send_game_time_response("game_time_step_until", {"error": report_result["error"]})
1416
+ return
1417
+ var report_compiled: Array = report_result["report"]
1418
+
1419
+ var compiled := _compile_step_events(params.get("inputs", []))
1420
+ if compiled.has("error"):
1421
+ _send_game_time_response("game_time_step_until", {"error": compiled["error"]})
1422
+ return
1423
+
1424
+ _engage_freeze()
1425
+
1426
+ # Predicate already holds: advance nothing, stay frozen, report it.
1427
+ if bool(first_value):
1428
+ var sc_result: Dictionary = {
1429
+ "completed": true,
1430
+ "frozen": true,
1431
+ "elapsed_ms": 0,
1432
+ "gameplay_ms": 0,
1433
+ "frames": 0,
1434
+ "physics_ticks": 0,
1435
+ "game_paused": _game_paused,
1436
+ "predicate_met": true,
1437
+ }
1438
+ if not report_compiled.is_empty():
1439
+ sc_result["report"] = _evaluate_report(report_compiled, ctx_inputs)
1440
+ _send_game_time_response("game_time_step_until", sc_result)
1441
+ return
1442
+
1443
+ _step_target_ms = float(max_ms)
1444
+ _step_target_frames = 0
1445
+ _step_elapsed_ms = 0.0
1446
+ _step_gameplay_ms = 0.0
1447
+ _step_frames = 0
1448
+ _step_physics_ticks = 0
1449
+ _step_events = compiled["events"]
1450
+ _step_events_fired = 0
1451
+ _step_transitions = []
1452
+ _step_needs_settle = false
1453
+ _step_finish_pending = false
1454
+ _step_wall_exceeded = false
1455
+ _step_wall_start = Time.get_ticks_msec()
1456
+ _step_predicate = expr
1457
+ _step_predicate_inputs = ctx_inputs
1458
+ _step_predicate_met = false
1459
+ _step_predicate_error = ""
1460
+ _step_report = report_compiled
1461
+ _step_response_type = "game_time_step_until"
1462
+ _step_active = true
1463
+
1464
+ var tree := get_tree()
1465
+ tree.paused = _game_paused
1466
+ _step_last_tree_paused = tree.paused
1467
+ set_physics_process(true)
1468
+ _update_processing()
1469
+
1470
+
1471
+ func _step_process(delta: float) -> void:
1472
+ var tree := get_tree()
1473
+
1474
+ # The bridge processes before the scene, so a frame is counted here BEFORE
1475
+ # gameplay runs it. Ending the window therefore always defers one frame:
1476
+ # pausing in the same _process call would steal the frame just counted.
1477
+ if _step_finish_pending:
1478
+ _finish_step()
1479
+ return
1480
+
1481
+ # Game-layer pause flips during the window are the game's own doing (a
1482
+ # stepped input opened the menu, an auto-pausing cutscene). Track intent
1483
+ # and report; never fight it mid-window.
1484
+ if tree.paused != _step_last_tree_paused:
1485
+ _step_last_tree_paused = tree.paused
1486
+ _game_paused = tree.paused
1487
+ if _step_transitions.size() < STEP_MAX_TRANSITIONS:
1488
+ _step_transitions.append({"at_ms": roundi(_step_elapsed_ms), "paused": tree.paused})
1489
+
1490
+ _step_frames += 1
1491
+ _step_elapsed_ms += delta * 1000.0
1492
+ if not tree.paused:
1493
+ _step_gameplay_ms += delta * 1000.0
1494
+
1495
+ while _step_events.size() > 0 and _step_events[0].time <= _step_elapsed_ms:
1496
+ var ev: Dictionary = _step_events.pop_front()
1497
+ var input_event := InputEventAction.new()
1498
+ input_event.action = ev.action
1499
+ input_event.pressed = ev.is_press
1500
+ input_event.strength = 1.0 if ev.is_press else 0.0
1501
+ Input.parse_input_event(input_event)
1502
+ _step_events_fired += 1
1503
+ _step_needs_settle = true
1504
+ if ev.is_press:
1505
+ _held_actions[ev.action] = true
1506
+ else:
1507
+ _held_actions.erase(ev.action)
1508
+
1509
+ var done := false
1510
+ if _step_target_frames > 0:
1511
+ done = _step_frames >= _step_target_frames
1512
+ else:
1513
+ done = _step_elapsed_ms >= _step_target_ms
1514
+
1515
+ # step_until: re-evaluate the predicate each frame against the advancing
1516
+ # game. A truthy result stops the window early; a runtime failure (e.g. a
1517
+ # watched node was freed mid-window) ends it honestly with the error attached.
1518
+ if _step_predicate != null:
1519
+ var v: Variant = _step_predicate.execute(_step_predicate_inputs, self)
1520
+ if _step_predicate.has_execute_failed():
1521
+ _step_predicate_error = _step_predicate.get_error_text()
1522
+ done = true
1523
+ elif bool(v):
1524
+ _step_predicate_met = true
1525
+ done = true
1526
+
1527
+ if Time.get_ticks_msec() - _step_wall_start > STEP_WALL_BUDGET_MS:
1528
+ # Slow-mo, Engine.time_scale = 0, or a pause-held window can starve
1529
+ # the game-time clock; the wall budget guarantees the call returns
1530
+ # (partial, honestly reported) before the editor relay gives up.
1531
+ _step_wall_exceeded = true
1532
+ done = true
1533
+
1534
+ if done:
1535
+ if _step_needs_settle:
1536
+ # Injected events flush at the top of the NEXT frame; gameplay
1537
+ # needs that frame unpaused or the final just_pressed edge is
1538
+ # lost. Run exactly one settle frame, then finish.
1539
+ _step_needs_settle = false
1540
+ else:
1541
+ _step_finish_pending = true
1542
+
1543
+
1544
+ func _finish_step() -> void:
1545
+ # Releases are guaranteed cleanup, never queued steps: no holds survive
1546
+ # across the freeze boundary (cross-step holds are a deliberate non-goal).
1547
+ var forced := _held_actions.size()
1548
+ _release_held_actions()
1549
+ var dropped := _step_events.size()
1550
+ _step_events.clear()
1551
+
1552
+ get_tree().paused = true # the freeze layer re-engages
1553
+ _step_last_tree_paused = true
1554
+ _step_active = false
1555
+ _step_finish_pending = false
1556
+ set_physics_process(false)
1557
+ _update_processing()
1558
+
1559
+ var result: Dictionary = {
1560
+ "completed": true,
1561
+ "frozen": true,
1562
+ "elapsed_ms": roundi(_step_elapsed_ms),
1563
+ "gameplay_ms": roundi(_step_gameplay_ms),
1564
+ "frames": _step_frames,
1565
+ "physics_ticks": _step_physics_ticks,
1566
+ "game_paused": _game_paused,
1567
+ }
1568
+ if _step_events_fired > 0:
1569
+ result["events_fired"] = _step_events_fired
1570
+ if forced > 0:
1571
+ result["forced_releases"] = forced
1572
+ if dropped > 0:
1573
+ result["events_dropped"] = dropped
1574
+ if not _step_transitions.is_empty():
1575
+ result["pause_transitions"] = _step_transitions
1576
+ if _step_wall_exceeded:
1577
+ result["wall_budget_exceeded"] = true
1578
+ if _step_predicate != null:
1579
+ # step_until: predicate_met is the headline. report carries the readings the
1580
+ # agent asked for (the "what advanced" hint, so it need not re-observe). A
1581
+ # non-met return means the cap or wall budget ran out first.
1582
+ result["predicate_met"] = _step_predicate_met
1583
+ if not _step_report.is_empty():
1584
+ result["report"] = _evaluate_report(_step_report, _step_predicate_inputs)
1585
+ if not _step_predicate_error.is_empty():
1586
+ result["predicate_error"] = _step_predicate_error
1587
+
1588
+ # Route the reply to the originating command — the relay correlates by type.
1589
+ var response_type := _step_response_type
1590
+ _step_predicate = null
1591
+ _step_predicate_inputs = []
1592
+ _step_report = []
1593
+ _send_game_time_response(response_type, result)
@@ -0,0 +1 @@
1
+ uid://dj1awl7wmpf65
@@ -0,0 +1 @@
1
+ uid://1fhae7bg8bgf
@@ -0,0 +1 @@
1
+ uid://bn5y5te1ys8sa
package/addon/plugin.cfg CHANGED
@@ -3,6 +3,6 @@
3
3
  name="Godot MCP"
4
4
  description="Model Context Protocol server for AI assistant integration"
5
5
  author="godot-mcp"
6
- version="3.7.1"
6
+ version="3.9.0"
7
7
  script="plugin.gd"
8
8
  godot_version_min="4.5"
@@ -0,0 +1 @@
1
+ uid://ddy5vjisb0we
@@ -0,0 +1 @@
1
+ uid://ccs8jj6mvw2i7
@@ -0,0 +1 @@
1
+ uid://dcdsm34jvcwgv
@@ -53,6 +53,14 @@ describe('editor tool', () => {
53
53
  .toBe('Running scene: res://test.tscn');
54
54
  expect(await editor.execute({ action: 'stop' }, ctx)).toBe('Stopped project');
55
55
  });
56
+ it('passes frozen through to run_project and says so', async () => {
57
+ mock.mockResponse({});
58
+ const ctx = createToolContext(mock);
59
+ const result = await editor.execute({ action: 'run', frozen: true }, ctx);
60
+ expect(result).toContain('frozen from frame 0');
61
+ expect(mock.calls[0].command).toBe('run_project');
62
+ expect(mock.calls[0].params.frozen).toBe(true);
63
+ });
56
64
  });
57
65
  describe('get_log_messages', () => {
58
66
  it('returns "No log messages" when empty', async () => {