godot-cli-control 0.2.6__tar.gz → 0.2.8__tar.gz

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 (54) hide show
  1. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/PKG-INFO +1 -1
  2. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/README.md +2 -1
  3. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/_version.py +2 -2
  4. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/cli.py +46 -4
  5. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/client.py +23 -10
  6. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/templates/skill/SKILL.md +32 -6
  7. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_cli.py +185 -0
  8. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_client.py +83 -1
  9. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/.gitignore +0 -0
  10. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/LICENSE +0 -0
  11. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/CHANGELOG.md +0 -0
  12. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/LICENSE +0 -0
  13. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  14. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  15. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  16. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  17. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  18. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  19. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  20. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  21. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  22. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/plugin.cfg +0 -0
  23. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/plugin.gd +0 -0
  24. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/plugin.gd.uid +0 -0
  25. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  26. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  27. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  28. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  29. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  30. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  31. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/pyproject.toml +0 -0
  32. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/README.md +0 -0
  33. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/__init__.py +0 -0
  34. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/__main__.py +0 -0
  35. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/_duration.py +0 -0
  36. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/bridge.py +0 -0
  37. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/daemon.py +0 -0
  38. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/init_cmd.py +0 -0
  39. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/pytest_plugin.py +0 -0
  40. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/registry.py +0 -0
  41. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/runner.py +0 -0
  42. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/skills_install.py +0 -0
  43. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/templates/__init__.py +0 -0
  44. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  45. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/__init__.py +0 -0
  46. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_bridge.py +0 -0
  47. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_cli_helpers.py +0 -0
  48. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_daemon.py +0 -0
  49. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_duration.py +0 -0
  50. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_init.py +0 -0
  51. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_pytest_plugin.py +0 -0
  52. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_registry.py +0 -0
  53. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_runner.py +0 -0
  54. {godot_cli_control-0.2.6 → godot_cli_control-0.2.8}/python/tests/test_skills_install.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -93,11 +93,12 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
93
93
  | `action_press(action)` | `await client.action_press("jump")` |
94
94
  | `action_release(action)` | `await client.action_release("jump")` |
95
95
  | `action_tap(action, duration)` | `await client.action_tap("attack", 0.1)` |
96
- | `input_get_pressed` (raw RPC) | `await client.request("input_get_pressed")` |
97
96
  | `hold(action, duration)` | `await client.hold("run", 1.5)` |
98
97
  | `combo(steps)` | `await client.combo([{"action": "jump", "duration": 0.1}])` |
99
98
  | `combo_cancel()` | `await client.combo_cancel()` |
100
99
  | `release_all()` | `await client.release_all()` |
100
+ | `get_pressed()` | `await client.get_pressed()` |
101
+ | `list_input_actions(include_builtin=False)` | `await client.list_input_actions()` |
101
102
 
102
103
  ### Error codes
103
104
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.6'
22
- __version_tuple__ = version_tuple = (0, 2, 6)
21
+ __version__ = version = '0.2.8'
22
+ __version_tuple__ = version_tuple = (0, 2, 8)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -60,17 +60,26 @@ CLIENT_CODE_SCRIPT_ERROR = -1005 # `run <script>` 用户脚本抛出未捕获
60
60
  CLIENT_CODE_INTERNAL = -1099 # 兜底:客户端内部异常(理论上不该到这里,但兜住契约)
61
61
 
62
62
 
63
- def _resolve_headless(ns: argparse.Namespace) -> bool:
63
+ def _resolve_headless(
64
+ ns: argparse.Namespace, *, force_gui_hint: bool = False
65
+ ) -> bool:
64
66
  """决定本次 daemon start 是否走 --headless。
65
67
 
66
- 优先级:显式 --headless > 显式 --gui > stdout.isatty() 自动判。
68
+ 优先级:显式 --headless > 显式 --gui > force_gui_hint > stdout.isatty() 自动判。
67
69
  isatty=False(pipe / redirect / 非 TTY agent shell)默认 headless;
68
70
  isatty=True(开发者交互终端)默认开窗。
71
+
72
+ ``force_gui_hint``:调用方(``cmd_run``)静态检测脚本含 ``screenshot`` 时传
73
+ True,让没显式指定 ``--headless`` / ``--gui`` 的非 TTY 场景(subagent / pipe)
74
+ 自动改走 GUI —— headless 下 dummy renderer 拿不到 viewport texture,screenshot
75
+ 永远 1006 fail(issue #65)。
69
76
  """
70
77
  if getattr(ns, "headless", False):
71
78
  return True
72
79
  if getattr(ns, "gui", False):
73
80
  return False
81
+ if force_gui_hint:
82
+ return False
74
83
  try:
75
84
  return not sys.stdout.isatty()
76
85
  except (OSError, ValueError):
@@ -79,6 +88,24 @@ def _resolve_headless(ns: argparse.Namespace) -> bool:
79
88
  return True # 安全默认
80
89
 
81
90
 
91
+ def _script_likely_uses_screenshot(script_path: Path) -> bool:
92
+ """启发式:脚本源码是否含 ``screenshot`` 子串。
93
+
94
+ 保守策略 —— 误报(多开一次窗)成本远低于漏报(截图静默 1006 fail)。
95
+ 简单子串足以覆盖 ``bridge.screenshot(...)`` / ``client.screenshot()`` /
96
+ 间接 ``getattr(bridge, "screenshot")``;comment / docstring 命中也无所谓,
97
+ 最坏只是 subagent 多看到一个窗口。
98
+
99
+ 读不到(OSError / decode 失败)时返回 False —— 让 cmd_run 走原来的 isatty
100
+ 默认,至少不会把"脚本不存在"的报错被本检测吞掉。
101
+ """
102
+ try:
103
+ text = script_path.read_text(encoding="utf-8", errors="replace")
104
+ except OSError:
105
+ return False
106
+ return "screenshot" in text
107
+
108
+
82
109
  # ── RpcSpec:声明一个 RPC 子命令 ──
83
110
 
84
111
 
@@ -670,7 +697,7 @@ RPC_SPECS: tuple[RpcSpec, ...] = (
670
697
  handler=cmd_wait_time,
671
698
  description="按 game time 等待 N 秒(在 --write-movie 模式下与录像帧对齐)。",
672
699
  positionals=(
673
- Positional("seconds", None, "等待秒数(>0"),
700
+ Positional("seconds", None, "等待秒数(服务端范围 0 ≤ seconds ≤ 3600;client 在 ≤0 时短路返回成功)"),
674
701
  ),
675
702
  example="wait-time 0.5",
676
703
  text_formatter=_fmt_wait_time_text,
@@ -944,11 +971,17 @@ def cmd_run(ns: argparse.Namespace) -> int:
944
971
  daemon = Daemon(Path.cwd())
945
972
  auto_started = False
946
973
  if not daemon.is_running():
974
+ # 静态检测脚本含 screenshot 时,非 TTY 默认从 headless 翻转到 GUI
975
+ # (issue #65)。显式 --headless / --gui / --no-gui-auto 都能 opt-out。
976
+ force_gui_hint = (
977
+ not getattr(ns, "no_gui_auto", False)
978
+ and _script_likely_uses_screenshot(script_path)
979
+ )
947
980
  try:
948
981
  daemon.start(
949
982
  record=ns.record,
950
983
  movie_path=ns.movie_path,
951
- headless=_resolve_headless(ns),
984
+ headless=_resolve_headless(ns, force_gui_hint=force_gui_hint),
952
985
  fps=ns.fps,
953
986
  port=ns.port,
954
987
  idle_timeout=idle_seconds,
@@ -1491,6 +1524,15 @@ def build_parser() -> argparse.ArgumentParser:
1491
1524
  )
1492
1525
  run_p.add_argument("script", help="用户脚本路径,需定义 run(bridge)")
1493
1526
  _add_daemon_flags(run_p)
1527
+ run_p.add_argument(
1528
+ "--no-gui-auto",
1529
+ action="store_true",
1530
+ help=(
1531
+ "禁用脚本静态检测自动 GUI。默认含 screenshot 调用的脚本在非 TTY "
1532
+ "(subagent / pipe / CI)下也强制开窗 —— headless dummy renderer "
1533
+ "拿不到 viewport texture,截图会 1006 fail。"
1534
+ ),
1535
+ )
1494
1536
  _add_output_format_flags(run_p)
1495
1537
 
1496
1538
  # init:一键接入
@@ -14,6 +14,17 @@ logger = logging.getLogger(__name__)
14
14
 
15
15
  DEFAULT_PORT: int = 9877
16
16
 
17
+ # 长操作(wait_game_time / combo)的客户端 wall-time 生死线。
18
+ #
19
+ # 这类操作的「完成」是 game-time 维度(Godot 帧数推进),与 wall time 关系不可
20
+ # 预测:Movie Maker (--write-movie) 模式下 wall ≈ 4-5× game,且随分辨率/fps/盘速
21
+ # 漂移。任何 seconds-scaled 公式都会在某个组合下假超时(issue #45)。
22
+ #
23
+ # 改用固定大值后:正常完成时 server 自然回包;server 真死循环时由 600s 兜底
24
+ # (而不是 55s 把还活着的录像炸掉);死连接由 websockets 库的 ping/pong 心跳
25
+ # 在 ~40s 内独立检测,与本上限无关。
26
+ LONG_OP_CLIENT_TIMEOUT: float = 600.0
27
+
17
28
 
18
29
  class RpcError(RuntimeError):
19
30
  """Raised when GameBridge returns a JSON-RPC error response.
@@ -100,6 +111,11 @@ class GameClient:
100
111
  f"ws://127.0.0.1:{self._port}",
101
112
  proxy=None,
102
113
  open_timeout=open_timeout,
114
+ # 显式锁住 ws 心跳——issue #45 治本依赖:long ops 去掉
115
+ # seconds-scaled wall 上限后,死连接靠这层独立检测。
116
+ # 与 websockets 当前默认对齐,防库升级悄悄改默认。
117
+ ping_interval=20, # 每 20s 无流量发一次 ping
118
+ ping_timeout=20, # pong 20s 未回 → 关连接(≈40s 内可发现死链)
103
119
  )
104
120
  self._listen_task = asyncio.create_task(self._listen())
105
121
  logger.info("Connected to GameBridge on port %d", self._port)
@@ -263,8 +279,9 @@ class GameClient:
263
279
  async def wait_game_time(self, seconds: float) -> dict:
264
280
  """按 Godot game time 等待 N 秒。
265
281
 
266
- Movie Maker (--write-movie) 模式下 wall time 比 game time 慢约 2-3×,
267
- 客户端 timeout seconds * 3 + 10 安全系数,与 combo() 一致。
282
+ 客户端用固定 ``LONG_OP_CLIENT_TIMEOUT`` 生死线(issue #45):game-time
283
+ wall-time 比值受录像模式 / 分辨率 / 盘速 / fps 影响不可预测,任何
284
+ seconds-scaled 公式都会在某个组合下假超时。死连接由 ws ping/pong 兜底。
268
285
  seconds <= 0 时客户端短路返回,不发 RPC。
269
286
  """
270
287
  if seconds <= 0:
@@ -272,7 +289,7 @@ class GameClient:
272
289
  return await self.request(
273
290
  "wait_game_time",
274
291
  {"seconds": seconds},
275
- timeout=seconds * 3.0 + 10.0,
292
+ timeout=LONG_OP_CLIENT_TIMEOUT,
276
293
  )
277
294
 
278
295
  # ---- Input simulation API ----
@@ -294,14 +311,10 @@ class GameClient:
294
311
  )
295
312
 
296
313
  async def combo(self, steps: list[dict]) -> dict:
297
- total = sum(
298
- s.get("duration", 0) or s.get("wait", 0) for s in steps
299
- )
300
- # movie maker (--write-movie) 模式下 Godot 渲染比实时慢(大分辨率 +
301
- # MJPEG 编码开销),客户端墙钟等待需要 3× 安全系数 + 10s base,
302
- # 避免 combo 还没在游戏时间跑完就被 TimeoutError 打断。
314
+ # 客户端用固定 LONG_OP_CLIENT_TIMEOUT 生死线(issue #45):见
315
+ # wait_game_time 注释——同样的 game-time / wall-time 不对齐问题。
303
316
  return await self.request(
304
- "input_combo", {"steps": steps}, timeout=total * 3.0 + 10.0
317
+ "input_combo", {"steps": steps}, timeout=LONG_OP_CLIENT_TIMEOUT
305
318
  )
306
319
 
307
320
  async def combo_cancel(self) -> dict:
@@ -35,6 +35,8 @@ godot-cli-control daemon stop
35
35
  ```
36
36
 
37
37
  > As of this version, `daemon start` autodetects headless mode by checking `stdout.isatty()`. Pipes, CI, and agent shell-outs run headless by default; an interactive terminal still gets a window. The explicit flags below are only needed to override: `--headless` forces headless even in a TTY; `--gui` forces a window even when stdout is piped.
38
+ >
39
+ > **`run <script>` adds one more layer**: it grep's the script source for `screenshot`. If found, headless is force-flipped to GUI even on non-TTY shells — headless dummy renderer can't read viewport texture, so `bridge.screenshot(...)` would otherwise hard-fail with code `1006`. Pass `--no-gui-auto` to disable this detection; explicit `--headless` / `--gui` still win.
38
40
 
39
41
  ## Exit codes
40
42
 
@@ -42,7 +44,8 @@ godot-cli-control daemon stop
42
44
  |---|---|
43
45
  | 0 | Success (or, for `exists` / `visible` / `wait-node`, the boolean was true / found) |
44
46
  | 1 | RPC error (server returned `{"error":...}`); also `exists`/`visible`=false, `wait-node`=timeout, `daemon status`=stopped |
45
- | 2 | Connection / IO / usage error (daemon not running, malformed `combo` input, etc.) |
47
+ | 2 | Connection / IO / usage error (daemon not running, malformed `combo` input, script path not found). Also: **`daemon stop` returns 2** when the daemon stopped cleanly but `ffmpeg` transcode of the recorded `.avi`→`.mp4` failed — the raw `.avi` is kept and `.cli_control/ffmpeg.log` has the details. `run <script>` propagates this: a successful script + failed transcode still exits 2. |
48
+ | 3 | `daemon stop --all` partial failure: at least one daemon in the registry failed to stop. Per-record `rc` is in the JSON `result.stopped[]`. |
46
49
  | 64 | Argparse usage error |
47
50
 
48
51
  Shell-`if` works:
@@ -53,6 +56,22 @@ if godot-cli-control exists /root/Main/Boss; then
53
56
  fi
54
57
  ```
55
58
 
59
+ ## Daemon management
60
+
61
+ ```bash
62
+ godot-cli-control daemon start # boot daemon for cwd project
63
+ godot-cli-control daemon status # exit 0 = running, 1 = stopped
64
+ godot-cli-control daemon stop # stop cwd-project daemon (rc 0; rc 2 = ffmpeg transcode failed)
65
+ godot-cli-control daemon stop --project /path/to/other/godot/project
66
+ godot-cli-control daemon stop --all # stop every registered daemon; exit 3 if any failed
67
+ godot-cli-control daemon ls # list all running daemons (cross-project, walks the registry)
68
+ ```
69
+
70
+ - **`daemon status` payload when running**: `{"state": "running", "pid": N, "port": M}`.
71
+ - **`daemon status` payload when stopped**: `{"state": "stopped"}`. If the previous launch wrote `.cli_control/godot.log` or recorded an exit code, the envelope also includes `"last_log": "<path>"` and/or `"last_exit_code": <int>` — use these to diagnose why the daemon died without manually grepping under `.cli_control/`.
72
+ - **`daemon ls` payload**: `{"daemons": [{"project_root", "pid", "port", "started_at", "godot_bin", "log_path"}, ...]}`. Dead records (PID gone) are auto-pruned on each call, so this is the canonical list of *actually-alive* daemons across all projects on the machine.
73
+ - **`daemon stop --all` payload**: `{"stopped": [{"project_root","pid","port","rc"[, "error"]}, ...], "rc": 0|3}`. Each entry's `rc` is the per-project stop result; the top-level `rc` is the aggregate exit code.
74
+
56
75
  ## JSON envelope examples
57
76
 
58
77
  ```bash
@@ -90,10 +109,10 @@ Three numeric ranges cohabit in `error.code`. Knowing which is which lets you de
90
109
  |---|---|
91
110
  | `1001` | Node not found at the given path. Most common — usually the agent passed a wrong / not-yet-loaded path. Retry after `wait-node`. |
92
111
  | `1002` | Property not found on the node, or shape mismatch (e.g. `text` on a node that doesn't have it). Don't retry; inspect with `tree`. |
93
- | `1003` | Method not found on the node. Schema error — don't retry, inspect with `tree`. |
112
+ | `1003` | Method not found on the node, **or** unknown InputMap action passed to `press`/`release`/`tap`/`hold`/`combo` (`"Unknown action: <name>"`). Schema error — don't retry. For node methods inspect with `tree`; for missing actions run `actions` (or `actions --all`). |
94
113
  | `1004` | Combo already in progress. Call `combo-cancel` (or `release-all`) and re-issue. Safe to retry after that. |
95
114
  | `1005` | Scene tree too large to serialize (default safety limit). Pass `--max-nodes` or query a subtree with `children` / `tree <subpath>`. Don't retry as-is. |
96
- | `1006` | Resource transiently unavailable (e.g. screenshot during scene transition / window resize). Rare under normal use: GameBridge waits for viewport first-frame before accepting connections, and `screenshot` retries internally up to ~500ms. If you still see this, retry after `wait-time 0.05` or similar. |
115
+ | `1006` | Resource transiently unavailable (e.g. screenshot during scene transition / window resize). Rare under normal use: GameBridge waits for viewport first-frame before accepting connections, and `screenshot` retries internally up to ~30 frames (~500ms at 60 fps, ~1s at 30 fps, longer when `--write-movie` lowers the fixed fps). If you still see this, retry after `wait-time 0.05` or similar. |
97
116
 
98
117
  **JSON-RPC standard — negative integers `-32xxx`:**
99
118
 
@@ -143,7 +162,7 @@ Server vs client ranges never overlap, so a single `code` field is unambiguous.
143
162
 
144
163
  **Wait:**
145
164
  - `wait-node <path> [timeout]` — block until node appears (exit 0=found, 1=timeout)
146
- - `wait-time <seconds>` — wait N in-game seconds (matters for `--write-movie`)
165
+ - `wait-time <seconds>` — wait N in-game seconds (matters for `--write-movie`). Server bounds: `0 ≤ seconds ≤ 3600`; passing out-of-range gets `-32602 "seconds must be ..."`. Client short-circuits `seconds <= 0` without an RPC.
147
166
 
148
167
  **Render:**
149
168
  - `screenshot <path>` — write PNG (path is **required** as of 0.2.0)
@@ -190,7 +209,7 @@ godot-cli-control call /root/Game start_game 1 '"easy"' # int 1, string "easy
190
209
 
191
210
  So `position '[100, 200]'` → `Vector2(100, 200)`, `transform '[1,0,0, 0,1,0, 0,0,1, 10,20,30]'` → `Transform3D(IDENTITY, (10,20,30))`. Wrong length or non-numeric elements fail loud with `-32602 "value type mismatch ..."` instead of silently setting `(0, 0)` like pre-0.2.5 versions did.
192
211
 
193
- **Sub-path + Array also fails loud.** `set <node> transform:origin '[10, 20, 30]'` is rejected with `-32602 "sub-path + Array is not supported"`: Godot's `Object.set("transform:origin", Array)` silently drops the Array (origin stays at `(0,0,0)`) — same footgun class as #52, so the server pre-empts it. Sub-paths are scalar-only (`set <node> position:x 1.8`); to write a whole compound Variant use the top-level Array form above.
212
+ **Sub-path + Array also fails loud.** `set <node> transform:origin '[10, 20, 30]'` is rejected with `-32602 "sub-path + Array is not supported"`: Godot's `Object.set("transform:origin", Array)` silently drops the Array (origin stays at `(0,0,0)`) — same class of footgun as the strict Variant checks above, so the server pre-empts it. Sub-paths are scalar-only (`set <node> position:x 1.8`); to write a whole compound Variant use the top-level Array form above.
194
213
 
195
214
  **Footgun**: bare `null` / `true` / `false` / numeric strings parse as JSON literals first, **not** as strings. If you actually mean the string `"null"`, wrap it explicitly:
196
215
 
@@ -294,7 +313,7 @@ Errors raise `RpcError(code, message)` (a `RuntimeError` subclass) that preserve
294
313
  | `await client.is_visible(path)` | `visible <path>` |
295
314
  | `await client.get_children(path)` | `children <path>` |
296
315
  | `await client.screenshot()` | `screenshot <path>` |
297
- | `await client.get_scene_tree(depth)` | `tree [depth]` |
316
+ | `await client.get_scene_tree(depth, max_nodes=None)` | `tree [depth] [--max-nodes N]` |
298
317
  | `await client.wait_for_node(path, timeout)` | `wait-node <path> [timeout]` |
299
318
  | `await client.wait_game_time(seconds)` | `wait-time <seconds>` |
300
319
  | `await client.action_press(action)` | `press <action>` |
@@ -321,6 +340,12 @@ def run(bridge):
321
340
 
322
341
  `bridge` is a synchronous wrapper around `GameClient` — same method names, no `await`. Sibling-imports work (the script's directory is on `sys.path`).
323
342
 
343
+ **Exit code (when `run` started the daemon itself):**
344
+ - `0` — script succeeded and daemon stopped cleanly.
345
+ - `1` — script raised (envelope carries `code: -1005` with the exception summary; full traceback on stderr).
346
+ - `2` — script-path / daemon-start failed, **or** the script succeeded but the auto-`daemon stop` afterwards hit an ffmpeg transcode failure (success envelope still emits, with `daemon_stop_warning` populated; raw `.avi` is preserved).
347
+ - `64` — argparse usage error (e.g. malformed `--idle-timeout`).
348
+
324
349
  ## pytest plugin (preferred for end-to-end test suites)
325
350
 
326
351
  `pip install godot-cli-control[pytest]` registers a `pytest11` entry-point that exposes two fixtures, so a Godot e2e test is a one-liner:
@@ -367,6 +392,7 @@ pytest_plugins = ["godot_cli_control.pytest_plugin"]
367
392
  - **`tree` returns `1005 "scene tree too large"`** — your scene has more than 5000 visible nodes (a Grid / spawned-bullets situation). Pass `--max-nodes 200` to cap, or `children <path>` for one specific subtree.
368
393
  - **`set` with a string that *looks* like JSON** — value parser parses JSON first. To force a literal `"42"` string, pass `'"42"'`; to set a literal hash sign or array text, JSON-encode it.
369
394
  - **`daemon start` opens a window when I expected headless** — your stdout is a TTY (interactive terminal). Pass `--headless` explicitly, or shell out from a context where stdout is piped.
395
+ - **`run <script>` opens a window even though stdout is piped** — by design. `run` grep's the script for `screenshot` and force-flips to GUI when found, so `bridge.screenshot(...)` doesn't 1006-fail under the dummy renderer. Pass `--no-gui-auto` to disable detection; explicit `--headless` always wins. See issue #65.
370
396
  - **`screenshot` used to fail with `1006` on the first call** — fixed. GameBridge now waits for the viewport's first frame before opening the port, so `connect succeeded` implies `viewport has rendered ≥ once`. The magic `bridge.wait(1.5)` before the first screenshot in older example scripts is no longer needed.
371
397
 
372
398
  ---
@@ -1733,6 +1733,191 @@ class TestDaemonHeadlessAutodetect:
1733
1733
  with pytest.raises(SystemExit):
1734
1734
  parser.parse_args(["daemon", "start", "--headless", "--gui"])
1735
1735
 
1736
+ def test_force_gui_hint_flips_pipe_to_gui(
1737
+ self, monkeypatch: pytest.MonkeyPatch
1738
+ ) -> None:
1739
+ """脚本含 screenshot 时 cmd_run 传 force_gui_hint=True,
1740
+ 非 TTY 默认从 headless 翻成 GUI(issue #65)。"""
1741
+ from godot_cli_control.cli import _resolve_headless
1742
+
1743
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
1744
+ ns = type("NS", (), {"headless": False, "gui": False})()
1745
+ assert _resolve_headless(ns, force_gui_hint=True) is False
1746
+
1747
+ def test_explicit_headless_still_wins_over_force_gui_hint(
1748
+ self, monkeypatch: pytest.MonkeyPatch
1749
+ ) -> None:
1750
+ """用户显式 --headless 永远赢 —— 即使脚本含 screenshot,
1751
+ 用户也可能就是想跑 headless(CI 不需要截图的子集等)。"""
1752
+ from godot_cli_control.cli import _resolve_headless
1753
+
1754
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
1755
+ ns = type("NS", (), {"headless": True, "gui": False})()
1756
+ assert _resolve_headless(ns, force_gui_hint=True) is True
1757
+
1758
+
1759
+ class TestScriptLikelyUsesScreenshot:
1760
+ def test_detects_method_call(self, tmp_path: Path) -> None:
1761
+ from godot_cli_control.cli import _script_likely_uses_screenshot
1762
+
1763
+ script = tmp_path / "s.py"
1764
+ script.write_text(
1765
+ "def run(bridge):\n bridge.screenshot('/tmp/x.png')\n",
1766
+ encoding="utf-8",
1767
+ )
1768
+ assert _script_likely_uses_screenshot(script) is True
1769
+
1770
+ def test_no_match_when_script_clean(self, tmp_path: Path) -> None:
1771
+ from godot_cli_control.cli import _script_likely_uses_screenshot
1772
+
1773
+ script = tmp_path / "s.py"
1774
+ script.write_text("def run(bridge):\n bridge.click('/root/A')\n", "utf-8")
1775
+ assert _script_likely_uses_screenshot(script) is False
1776
+
1777
+ def test_missing_file_returns_false_not_raises(self, tmp_path: Path) -> None:
1778
+ """读不到不抛 —— 让 cmd_run 走原 isatty 默认,"脚本不存在"
1779
+ 由后续 script_path.exists() 检查统一报错。"""
1780
+ from godot_cli_control.cli import _script_likely_uses_screenshot
1781
+
1782
+ assert _script_likely_uses_screenshot(tmp_path / "missing.py") is False
1783
+
1784
+
1785
+ class TestCmdRunGuiAutoDetect:
1786
+ """issue #65:cli run 静态检测脚本含 screenshot 时,非 TTY 也强制开窗。"""
1787
+
1788
+ @staticmethod
1789
+ def _mock_run_pipeline(
1790
+ monkeypatch: pytest.MonkeyPatch,
1791
+ ) -> dict[str, Any]:
1792
+ """拦截 daemon.start / is_running / current_port / stop +
1793
+ _exec_user_script,让 cmd_run 跑完整路径但不真起进程。
1794
+ 返回 dict 记录 daemon.start kwargs,测试断言用。"""
1795
+ import godot_cli_control.cli as cli_mod
1796
+ import godot_cli_control.daemon as daemon_mod
1797
+
1798
+ captured: dict[str, Any] = {}
1799
+
1800
+ def _is_running(self: Any) -> bool:
1801
+ return False
1802
+
1803
+ def _start(self: Any, **kw: Any) -> None:
1804
+ captured["start_kwargs"] = kw
1805
+
1806
+ def _current_port(self: Any) -> int:
1807
+ return 12345
1808
+
1809
+ def _stop(self: Any) -> int:
1810
+ return 0
1811
+
1812
+ monkeypatch.setattr(daemon_mod.Daemon, "is_running", _is_running)
1813
+ monkeypatch.setattr(daemon_mod.Daemon, "start", _start)
1814
+ monkeypatch.setattr(daemon_mod.Daemon, "current_port", _current_port)
1815
+ monkeypatch.setattr(daemon_mod.Daemon, "stop", _stop)
1816
+ monkeypatch.setattr(
1817
+ cli_mod,
1818
+ "_exec_user_script",
1819
+ lambda *a, **kw: 0,
1820
+ )
1821
+ # 非 TTY ── 模拟 subagent / pipe / CI
1822
+ monkeypatch.setattr("sys.stdout.isatty", lambda: False)
1823
+ return captured
1824
+
1825
+ def test_script_with_screenshot_forces_gui_under_pipe(
1826
+ self,
1827
+ tmp_path: Path,
1828
+ monkeypatch: pytest.MonkeyPatch,
1829
+ ) -> None:
1830
+ import argparse
1831
+
1832
+ from godot_cli_control.cli import OUTPUT_JSON, cmd_run
1833
+
1834
+ captured = self._mock_run_pipeline(monkeypatch)
1835
+ script = tmp_path / "s.py"
1836
+ script.write_text(
1837
+ "def run(bridge):\n bridge.screenshot('/tmp/x.png')\n",
1838
+ encoding="utf-8",
1839
+ )
1840
+ ns = argparse.Namespace(
1841
+ script=str(script),
1842
+ record=False,
1843
+ movie_path=None,
1844
+ headless=False,
1845
+ gui=False,
1846
+ no_gui_auto=False,
1847
+ fps=30,
1848
+ port=0,
1849
+ idle_timeout="0",
1850
+ output_format=OUTPUT_JSON,
1851
+ )
1852
+ rc = cmd_run(ns)
1853
+ assert rc == 0
1854
+ # 非 TTY 默认 headless=True,但脚本含 screenshot → 翻转到 False
1855
+ assert captured["start_kwargs"]["headless"] is False
1856
+
1857
+ def test_no_gui_auto_disables_detection(
1858
+ self,
1859
+ tmp_path: Path,
1860
+ monkeypatch: pytest.MonkeyPatch,
1861
+ ) -> None:
1862
+ import argparse
1863
+
1864
+ from godot_cli_control.cli import OUTPUT_JSON, cmd_run
1865
+
1866
+ captured = self._mock_run_pipeline(monkeypatch)
1867
+ script = tmp_path / "s.py"
1868
+ script.write_text(
1869
+ "def run(bridge):\n bridge.screenshot('/tmp/x.png')\n",
1870
+ encoding="utf-8",
1871
+ )
1872
+ ns = argparse.Namespace(
1873
+ script=str(script),
1874
+ record=False,
1875
+ movie_path=None,
1876
+ headless=False,
1877
+ gui=False,
1878
+ no_gui_auto=True, # ← opt-out
1879
+ fps=30,
1880
+ port=0,
1881
+ idle_timeout="0",
1882
+ output_format=OUTPUT_JSON,
1883
+ )
1884
+ rc = cmd_run(ns)
1885
+ assert rc == 0
1886
+ # opt-out → 回到 isatty 默认(非 TTY = headless)
1887
+ assert captured["start_kwargs"]["headless"] is True
1888
+
1889
+ def test_script_without_screenshot_keeps_headless_default(
1890
+ self,
1891
+ tmp_path: Path,
1892
+ monkeypatch: pytest.MonkeyPatch,
1893
+ ) -> None:
1894
+ import argparse
1895
+
1896
+ from godot_cli_control.cli import OUTPUT_JSON, cmd_run
1897
+
1898
+ captured = self._mock_run_pipeline(monkeypatch)
1899
+ script = tmp_path / "s.py"
1900
+ script.write_text(
1901
+ "def run(bridge):\n bridge.click('/root/A')\n",
1902
+ encoding="utf-8",
1903
+ )
1904
+ ns = argparse.Namespace(
1905
+ script=str(script),
1906
+ record=False,
1907
+ movie_path=None,
1908
+ headless=False,
1909
+ gui=False,
1910
+ no_gui_auto=False,
1911
+ fps=30,
1912
+ port=0,
1913
+ idle_timeout="0",
1914
+ output_format=OUTPUT_JSON,
1915
+ )
1916
+ rc = cmd_run(ns)
1917
+ assert rc == 0
1918
+ # 脚本无 screenshot → 不触发翻转,按 isatty=False 走 headless
1919
+ assert captured["start_kwargs"]["headless"] is True
1920
+
1736
1921
 
1737
1922
  def test_run_rpc_tree_truncated_envelope(
1738
1923
  capsys: pytest.CaptureFixture[str],
@@ -9,7 +9,7 @@ from unittest.mock import AsyncMock, patch
9
9
  import pytest
10
10
  import websockets
11
11
 
12
- from godot_cli_control.client import GameClient
12
+ from godot_cli_control.client import LONG_OP_CLIENT_TIMEOUT, GameClient
13
13
 
14
14
 
15
15
  # ---- Test 1: proxy=None 显式传给 websockets.connect ----
@@ -465,3 +465,85 @@ async def test_get_scene_tree_omits_max_nodes_when_none() -> None:
465
465
  client_mod.GameClient.request = monkeypatch_target
466
466
  assert captured["params"] == {"depth": 2}
467
467
  assert "max_nodes" not in captured["params"]
468
+
469
+
470
+ # ---- issue #45: wait_game_time / combo 不能给 game-time 操作设 wall-time 上限 ----
471
+ #
472
+ # 旧公式 seconds*3+10 假设 wall ≤ 3× game,在 Movie Maker (--write-movie) 模式下
473
+ # 实测 wall ≈ 4-5× game,必假超时(bridge.wait(15) → 55s timeout < 60s+ wall)。
474
+ # 治本:去掉 seconds-scaled wall 上限,固定一个生死线,死连接靠 ws ping/pong。
475
+ #
476
+ # 测试用「两次差距大的 seconds 拿到同一 timeout」直接证明 decoupling,
477
+ # 比单点等值断言更稳——后者跟「巧合 == 常量」的假阳性形态无法区分。
478
+
479
+
480
+ @pytest.mark.asyncio
481
+ async def test_wait_game_time_client_timeout_decoupled_from_seconds() -> None:
482
+ """issue #45: wait_game_time 客户端 timeout 必须与 seconds 解耦。"""
483
+ import godot_cli_control.client as client_mod
484
+
485
+ captured: list = []
486
+
487
+ async def fake_request(self, method, params=None, timeout=30.0):
488
+ captured.append(timeout)
489
+ return {"success": True}
490
+
491
+ client = client_mod.GameClient(port=1)
492
+ monkeypatch_target = client_mod.GameClient.request
493
+ client_mod.GameClient.request = fake_request # type: ignore
494
+ try:
495
+ await client.wait_game_time(1.0)
496
+ await client.wait_game_time(120.0)
497
+ finally:
498
+ client_mod.GameClient.request = monkeypatch_target
499
+ assert captured == [LONG_OP_CLIENT_TIMEOUT, LONG_OP_CLIENT_TIMEOUT], (
500
+ f"wait_game_time 应固定使用 {LONG_OP_CLIENT_TIMEOUT}s 生死线、与 seconds 无关,"
501
+ f"实际 {captured}"
502
+ )
503
+
504
+
505
+ @pytest.mark.asyncio
506
+ async def test_combo_client_timeout_decoupled_from_steps_total() -> None:
507
+ """issue #45: combo() 与 wait_game_time 同源问题,client timeout 同样与 total 解耦。"""
508
+ import godot_cli_control.client as client_mod
509
+
510
+ captured: list = []
511
+
512
+ async def fake_request(self, method, params=None, timeout=30.0):
513
+ captured.append(timeout)
514
+ return {"success": True}
515
+
516
+ client = client_mod.GameClient(port=1)
517
+ monkeypatch_target = client_mod.GameClient.request
518
+ client_mod.GameClient.request = fake_request # type: ignore
519
+ try:
520
+ await client.combo([{"action": "x", "duration": 0.5}])
521
+ await client.combo([{"action": "x", "duration": 60.0}])
522
+ finally:
523
+ client_mod.GameClient.request = monkeypatch_target
524
+ assert captured == [LONG_OP_CLIENT_TIMEOUT, LONG_OP_CLIENT_TIMEOUT]
525
+
526
+
527
+ @pytest.mark.asyncio
528
+ async def test_connect_locks_ws_ping_keepalive() -> None:
529
+ """issue #45 治本依赖:去掉 wall 上限后,死连接靠 ws ping/pong 检测。
530
+
531
+ 显式锁住 ping_interval / ping_timeout(与 websockets 默认对齐),
532
+ 防库升级把默认改了导致死连接卡 600s。
533
+ """
534
+ fake_ws = AsyncMock()
535
+ with patch(
536
+ "godot_cli_control.client.websockets.connect",
537
+ new=AsyncMock(return_value=fake_ws),
538
+ ) as mock_connect:
539
+ client = GameClient(port=9999)
540
+ try:
541
+ await client.connect(retries=1)
542
+ finally:
543
+ if client._listen_task:
544
+ client._listen_task.cancel()
545
+ _, kwargs = mock_connect.call_args
546
+ assert kwargs.get("ping_interval") == 20, \
547
+ "GameClient.connect() must lock ping_interval (ws keepalive)"
548
+ assert kwargs.get("ping_timeout") == 20, \
549
+ "GameClient.connect() must lock ping_timeout (ws keepalive)"