godot-cli-control 0.2.12__tar.gz → 0.2.13__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.
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/PKG-INFO +2 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/README.md +36 -3
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/pyproject.toml +23 -4
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/_version.py +2 -2
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/bridge.py +5 -2
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/cli.py +97 -15
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/client.py +55 -10
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/daemon.py +41 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/templates/skill/SKILL.md +14 -8
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_cli.py +19 -12
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_cli_helpers.py +0 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_client.py +5 -5
- godot_cli_control-0.2.13/python/tests/test_e2e_client_direct.py +217 -0
- godot_cli_control-0.2.13/python/tests/test_e2e_example.py +123 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_e2e_input.py +8 -5
- godot_cli_control-0.2.13/python/tests/test_e2e_record.py +198 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_e2e_screenshot_gui.py +8 -4
- godot_cli_control-0.2.13/python/tests/test_issue_fixes.py +292 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_pytest_plugin.py +15 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_registry.py +8 -4
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_runner.py +0 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_skills_install.py +0 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/.gitignore +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/LICENSE +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/CHANGELOG.md +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/LICENSE +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/plugin.cfg +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/plugin.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/plugin.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/run_gut.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/run_gut.sh +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/README.md +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/__main__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/_duration.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/init_cmd.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/pytest_plugin.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/registry.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/runner.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/skills_install.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/templates/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/godot_cli_control/templates/skill/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_bridge.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_daemon.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_duration.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.13}/python/tests/test_init.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godot-cli-control
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.13
|
|
4
4
|
Summary: WebSocket bridge for headless / scripted control of Godot scenes.
|
|
5
5
|
Author: kesar
|
|
6
6
|
License: MIT
|
|
@@ -13,6 +13,7 @@ Provides-Extra: test
|
|
|
13
13
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
|
|
14
14
|
Requires-Dist: pytest-cov>=4; extra == 'test'
|
|
15
15
|
Requires-Dist: pytest>=7; extra == 'test'
|
|
16
|
+
Requires-Dist: ruff>=0.6; extra == 'test'
|
|
16
17
|
Description-Content-Type: text/markdown
|
|
17
18
|
|
|
18
19
|
# godot-cli-control
|
|
@@ -104,6 +104,12 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
|
|
|
104
104
|
| `get_pressed()` | `await client.get_pressed()` |
|
|
105
105
|
| `list_input_actions(include_builtin=False)` | `await client.list_input_actions()` |
|
|
106
106
|
|
|
107
|
+
> **CLI note**: as a shell subcommand, `screenshot` **requires an output path** — `godot-cli-control screenshot /tmp/shot.png` — and writes the PNG to that file. Returning base64 over stdout is intentionally unsupported, to keep large binary payloads out of an automating agent's context window.
|
|
108
|
+
|
|
109
|
+
> **`--wait`**: `tap` / `hold` / `combo` return as soon as the input is armed, *before* the motion finishes. Add `--wait` (e.g. `hold run 1.5 --wait`) to block until the action's duration elapses (game-time) so the next read sees the settled state — equivalent to following the command with `wait-time <duration>` on the same connection.
|
|
110
|
+
|
|
111
|
+
> **No-arg `GameClient()` / `GameBridge()`**: with no `port` argument they auto-discover the daemon's port from `.cli_control/port` (falling back to `9877`), so a single-connection script connects to a running daemon without hardcoding a port.
|
|
112
|
+
|
|
107
113
|
### Error codes
|
|
108
114
|
|
|
109
115
|
Three numeric ranges share `error.code`; they never overlap, so a single field is unambiguous.
|
|
@@ -112,7 +118,7 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
|
|
|
112
118
|
|---|---|---|
|
|
113
119
|
| `1001` | server | Node not found at the given path |
|
|
114
120
|
| `1002` | server | Property not found / shape mismatch |
|
|
115
|
-
| `1003` | server | Method not found on the node (
|
|
121
|
+
| `1003` | server | 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; run `actions` (or `actions --all`) to list valid actions |
|
|
116
122
|
| `1004` | server | Combo already in progress (call `combo-cancel` to retry) |
|
|
117
123
|
| `1005` | server | Scene tree too large (lower `depth` or pass `--max-nodes`) |
|
|
118
124
|
| `1006` | server | Resource transiently unavailable (e.g. screenshot during scene transition). Rare under normal use — GameBridge waits for viewport first-frame before listening, and `screenshot` retries internally. Safe to retry if you do hit it. |
|
|
@@ -121,13 +127,40 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
|
|
|
121
127
|
| `-32602` | server | Invalid params (incl. blocked methods/properties from the security blacklist, `set` value-type mismatch — e.g. `Vector2` property given an array of wrong length / non-numeric elements, or `hold` given `duration ≤ 0` — use `press` for an indefinite hold) |
|
|
122
128
|
| `-1001` | client | Connection failure (daemon not running, port wrong, proxy hijacking localhost) |
|
|
123
129
|
| `-1002` | client | Timeout waiting for response |
|
|
124
|
-
| `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, …) |
|
|
130
|
+
| `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, a non-numeric `tap`/`wait-time` arg, a `set`/`call` value that fails JSON parsing, …). From an RPC subcommand this always exits **64** (#82); top-level `run`/`daemon` precondition failures also use `-1003` but exit 2. |
|
|
125
131
|
| `-1004` | client | Local file IO error (e.g. screenshot can't write the destination) |
|
|
126
132
|
| `-1005` | client | `run <script>` user script raised an uncaught exception — fix the script |
|
|
127
133
|
| `-1099` | client | Internal CLI bug — please file an issue |
|
|
128
134
|
|
|
129
135
|
For full retry guidance see the SKILL.md shipped by `godot-cli-control init` (`.claude/skills/godot-cli-control/SKILL.md` in the target project).
|
|
130
136
|
|
|
137
|
+
## Daemon commands
|
|
138
|
+
|
|
139
|
+
The daemon is the long-lived Godot process the CLI talks to (one per project root). The CLI auto-starts it where needed; manage it explicitly with:
|
|
140
|
+
|
|
141
|
+
| Command | What it does |
|
|
142
|
+
|---|---|
|
|
143
|
+
| `daemon start [--headless\|--gui] [--record]` | Launch Godot with the bridge listening. Headless vs GUI is auto-detected from the TTY; `--record` forces GUI (Movie Maker needs a real renderer) and is **rejected with `--headless`** (exit 2). |
|
|
144
|
+
| `daemon stop` | Stop this project's daemon. Exit 2 if it stopped cleanly but the `.avi`→`.mp4` ffmpeg transcode failed (raw `.avi` kept; see `.cli_control/ffmpeg.log`). |
|
|
145
|
+
| `daemon stop --all` | Stop every registered daemon across all projects. **Exit 3** if at least one failed; per-record `rc` (and an `error` field on failures) is in `result.stopped[]`. |
|
|
146
|
+
| `daemon stop --project <path>` | Stop the daemon for one specific project root. |
|
|
147
|
+
| `daemon status` | Exit 0 = running, 1 = stopped. When stopped, the payload may carry `last_log` / `last_exit_code` to diagnose a crash. |
|
|
148
|
+
| `daemon ls` | List every registered daemon (project root, pid, port). |
|
|
149
|
+
|
|
150
|
+
**Project-level defaults**: drop a `.cli_control/config.json` with `{"idle_timeout": "30m"}` to give `daemon start` / `run` a default idle-timeout without passing `--idle-timeout` each time. It's consulted only when the flag is omitted (default `0`); an explicit `--idle-timeout` always wins. A long-running `wait-time` / `combo` / recording is bounded client-side by a 600s wall-time fail-safe — override it for legitimately long operations with `GODOT_CLI_LONG_OP_TIMEOUT=<seconds>`.
|
|
151
|
+
|
|
152
|
+
## Exit codes
|
|
153
|
+
|
|
154
|
+
Semantic exit codes so `if godot-cli-control exists /root/Foo; then …` works in shell:
|
|
155
|
+
|
|
156
|
+
| Code | Meaning |
|
|
157
|
+
|---|---|
|
|
158
|
+
| 0 | Success (or boolean true for `exists` / `visible`, node found for `wait-node`) |
|
|
159
|
+
| 1 | RPC error; also `exists`/`visible`=false, `wait-node` timeout, `daemon status`=stopped |
|
|
160
|
+
| 2 | Connection / IO error, or `daemon stop` ffmpeg-transcode failure |
|
|
161
|
+
| 3 | `daemon stop --all` partial failure (≥1 daemon failed to stop) |
|
|
162
|
+
| 64 | Usage error on an RPC subcommand — bad argparse args, a pre-flight reject (`combo` with no steps / malformed `--steps-json`), a non-numeric `tap`/`wait-time` arg, or a `set`/`call` value that fails JSON parsing. These all carry client code `-1003` and now consistently exit 64 (#82; a few previously exited 2). |
|
|
163
|
+
|
|
131
164
|
## Activation Modes
|
|
132
165
|
|
|
133
166
|
Activation paths (any one triggers the bridge):
|
|
@@ -151,7 +184,7 @@ If none triggered, the plugin prints `[Godot CLI Control] inactive — ...` to c
|
|
|
151
184
|
## Known Limitations
|
|
152
185
|
|
|
153
186
|
1. **Headless + Movie Maker is broken** (Godot upstream): `--write-movie` produces empty MP4 in headless mode (no framebuffer). Use GUI mode for recording.
|
|
154
|
-
2. **Godot binary detection**
|
|
187
|
+
2. **Godot binary detection** order: `GODOT_BIN` env var → macOS `/Applications/Godot*.app` → `PATH` (`godot4`/`godot`) → Windows `Program Files\Godot*`. `init` writes the detected path to `.cli_control/godot_bin`; the daemon reads that file before re-scanning. The pytest e2e suite uses this same detection, so a `godot` on `PATH` is enough to run it.
|
|
155
188
|
3. **Python ≥ 3.10** required (websockets>=14 dependency).
|
|
156
189
|
4. **Single-client mode**: only one WebSocket client at a time; second connection rejected.
|
|
157
190
|
5. **Headless plugin auto-register**: in headless mode, `--editor --quit` may not trigger `plugin._enter_tree` to write the autoload. `init` handles this automatically by patching `project.godot` directly.
|
|
@@ -12,9 +12,9 @@ dependencies = [
|
|
|
12
12
|
|
|
13
13
|
[project.optional-dependencies]
|
|
14
14
|
pytest = ["pytest>=7"]
|
|
15
|
-
# 全套测试依赖 —— 仅 CI / 开发者本地测试用,包含 pytest-cov
|
|
16
|
-
#
|
|
17
|
-
test = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov>=4"]
|
|
15
|
+
# 全套测试依赖 —— 仅 CI / 开发者本地测试用,包含 pytest-cov、pytest-asyncio 与
|
|
16
|
+
# ruff(lint 门禁,issue #83)。普通用户用 `[pytest]` 已够;不要把这些拖到默认安装路径。
|
|
17
|
+
test = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov>=4", "ruff>=0.6"]
|
|
18
18
|
|
|
19
19
|
[project.scripts]
|
|
20
20
|
godot-cli-control = "godot_cli_control.cli:main"
|
|
@@ -23,14 +23,33 @@ godot-cli-control = "godot_cli_control.cli:main"
|
|
|
23
23
|
godot-cli-control = "godot_cli_control.pytest_plugin"
|
|
24
24
|
|
|
25
25
|
[tool.pytest.ini_options]
|
|
26
|
+
# pytest-asyncio:显式钉死 async fixture 的 loop scope 为 function(= 未来版本默认值),
|
|
27
|
+
# 消除 "asyncio_default_fixture_loop_scope is unset" 的 deprecation warning。改这行前
|
|
28
|
+
# 先确认没有 module/session-scope 的 async fixture 依赖跨用例共享 loop。
|
|
29
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
26
30
|
# gui:需要真实显示(开窗)才能跑的端到端测试。普通 unit / headless e2e 不带此
|
|
27
|
-
# marker。本地默认随
|
|
31
|
+
# marker。本地默认随 godot 检测 / GCC_GUI_E2E 未设而 skip;CI 专档(Linux 套
|
|
28
32
|
# xvfb 或 macOS runner)设 GCC_GUI_E2E=1 开启。注册在此避免 unknown-marker 警告,
|
|
29
33
|
# 也让 `pytest -m "not gui"` 能稳定剔除。
|
|
30
34
|
markers = [
|
|
31
35
|
"gui: 需要真实显示的 windowed e2e(screenshot frame_post_draw 路径,issue #64)",
|
|
32
36
|
]
|
|
33
37
|
|
|
38
|
+
# ── lint(ruff)─────────────────────────────────────────────────────────
|
|
39
|
+
# issue #83:把 lint 从「开发者本机全局 ruff」收编成 CI 门禁 + dev 依赖,趁全仓
|
|
40
|
+
# 基线为 0 锁定收益。规则集刻意只钉 ruff 默认的 pyflakes(F) + pycodestyle 子集
|
|
41
|
+
# (E4/E7/E9)——这正是当前 0-error 基线对应的集合;扩到 E501/I/UP/B 会一次性炸出
|
|
42
|
+
# 几十条历史报错,与「锁定基线、零噪音引入门禁」的目标相悖。要加规则请单开 PR、
|
|
43
|
+
# 连同 autofix 一起 land,别让门禁第一天就红。line-length 仅供 `ruff format`
|
|
44
|
+
# 参考(E501 未纳入 select,不构成门禁)。
|
|
45
|
+
[tool.ruff]
|
|
46
|
+
target-version = "py310"
|
|
47
|
+
line-length = 100
|
|
48
|
+
extend-exclude = ["python/godot_cli_control/_version.py"] # hatch-vcs 生成物
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
select = ["E4", "E7", "E9", "F"]
|
|
52
|
+
|
|
34
53
|
[build-system]
|
|
35
54
|
requires = ["hatchling", "hatch-vcs"]
|
|
36
55
|
build-backend = "hatchling.build"
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.13'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 13)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -15,13 +15,16 @@ import asyncio
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
|
-
from .client import
|
|
18
|
+
from .client import GameClient
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
class GameBridge:
|
|
22
22
|
"""同步接口,内部通过事件循环调用异步 GameClient。"""
|
|
23
23
|
|
|
24
|
-
def __init__(self, port: int =
|
|
24
|
+
def __init__(self, port: int | None = None) -> None:
|
|
25
|
+
# port=None → GameClient 从 .cli_control/port auto-discover(issue #91)。
|
|
26
|
+
# daemon 默认 OS 自动分配端口,所以 ``GameBridge()`` 无参数也能连上正在
|
|
27
|
+
# 跑的 daemon,与 README 的单连接脚本示例一致。
|
|
25
28
|
self._loop = asyncio.new_event_loop()
|
|
26
29
|
self._client = GameClient(port=port)
|
|
27
30
|
self._loop.run_until_complete(
|
|
@@ -243,6 +243,39 @@ def _read_combo_steps(ns: argparse.Namespace) -> list[dict]:
|
|
|
243
243
|
return parsed
|
|
244
244
|
|
|
245
245
|
|
|
246
|
+
def _combo_total_duration(steps: list[dict]) -> float:
|
|
247
|
+
"""combo 全部 step 的累计 game-time(给 ``--wait`` 算阻塞时长,issue #90)。
|
|
248
|
+
|
|
249
|
+
step schema 与服务端一致:``{"action": ..., "duration": <默认 0.1>}`` 串行
|
|
250
|
+
按下/释放,``{"wait": <秒>}`` 纯等待。非数字 / 缺字段按默认 0.1 兜底,绝不
|
|
251
|
+
抛错——``--wait`` 是体验增强,估时偏差远好过让命令崩掉。
|
|
252
|
+
"""
|
|
253
|
+
total = 0.0
|
|
254
|
+
for step in steps:
|
|
255
|
+
if not isinstance(step, dict):
|
|
256
|
+
continue
|
|
257
|
+
raw = step.get("wait", step.get("duration", 0.1))
|
|
258
|
+
try:
|
|
259
|
+
total += float(raw)
|
|
260
|
+
except (TypeError, ValueError):
|
|
261
|
+
total += 0.1
|
|
262
|
+
return total
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def _maybe_block_for_duration(
|
|
266
|
+
client: GameClient, ns: argparse.Namespace, duration: float
|
|
267
|
+
) -> None:
|
|
268
|
+
"""``--wait``:输入命令发出后,阻塞 ``duration`` 的 game-time 再返回(issue #90)。
|
|
269
|
+
|
|
270
|
+
把 SKILL.md 推荐的「读动作完成后状态前先 wait-time <时长>」折叠进同一条命令、
|
|
271
|
+
复用同一连接:需要同步的 agent 一步到位拿到结算后状态。默认(不传 ``--wait``)
|
|
272
|
+
保持异步立即返回,sticky + timer 模型不变。``wait_game_time`` 在 ≤0 时本就短路,
|
|
273
|
+
这里也提前挡掉,省一次 RPC 往返。
|
|
274
|
+
"""
|
|
275
|
+
if getattr(ns, "wait", False) and duration > 0:
|
|
276
|
+
await client.wait_game_time(duration)
|
|
277
|
+
|
|
278
|
+
|
|
246
279
|
# ── 现有 RPC 命令 handler(迁移到 ns 签名 + 返回数据) ──
|
|
247
280
|
|
|
248
281
|
|
|
@@ -279,11 +312,16 @@ async def cmd_release(client: GameClient, ns: argparse.Namespace) -> dict:
|
|
|
279
312
|
|
|
280
313
|
async def cmd_tap(client: GameClient, ns: argparse.Namespace) -> dict:
|
|
281
314
|
duration = float(ns.duration) if ns.duration else 0.1
|
|
282
|
-
|
|
315
|
+
result = await client.action_tap(ns.action, duration)
|
|
316
|
+
await _maybe_block_for_duration(client, ns, duration)
|
|
317
|
+
return result
|
|
283
318
|
|
|
284
319
|
|
|
285
320
|
async def cmd_hold(client: GameClient, ns: argparse.Namespace) -> dict:
|
|
286
|
-
|
|
321
|
+
duration = float(ns.duration)
|
|
322
|
+
result = await client.hold(ns.action, duration)
|
|
323
|
+
await _maybe_block_for_duration(client, ns, duration)
|
|
324
|
+
return result
|
|
287
325
|
|
|
288
326
|
|
|
289
327
|
async def cmd_combo(client: GameClient, ns: argparse.Namespace) -> dict:
|
|
@@ -291,7 +329,9 @@ async def cmd_combo(client: GameClient, ns: argparse.Namespace) -> dict:
|
|
|
291
329
|
steps = getattr(ns, "_combo_steps", None)
|
|
292
330
|
if steps is None:
|
|
293
331
|
steps = _read_combo_steps(ns)
|
|
294
|
-
|
|
332
|
+
result = await client.combo(steps)
|
|
333
|
+
await _maybe_block_for_duration(client, ns, _combo_total_duration(steps))
|
|
334
|
+
return result
|
|
295
335
|
|
|
296
336
|
|
|
297
337
|
async def cmd_release_all(client: GameClient, ns: argparse.Namespace) -> dict:
|
|
@@ -424,8 +464,18 @@ def _exit_from_wait_node(r: dict) -> int:
|
|
|
424
464
|
# ── extra_args registrators ──
|
|
425
465
|
|
|
426
466
|
|
|
467
|
+
def _register_wait_flag(p: argparse.ArgumentParser) -> None:
|
|
468
|
+
"""``--wait``:输入命令阻塞到动作时长(game-time)结束再返回(issue #90)。"""
|
|
469
|
+
p.add_argument(
|
|
470
|
+
"--wait",
|
|
471
|
+
action="store_true",
|
|
472
|
+
help="阻塞到动作时长(game-time)结束再返回,再读状态即结算后值;"
|
|
473
|
+
"默认异步立即返回。等价于命令后再跑一次 wait-time <时长>,但复用同一连接。",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
427
477
|
def _register_combo_args(p: argparse.ArgumentParser) -> None:
|
|
428
|
-
"""combo 的 args:``json_file`` 位置可选,加 ``--steps-json``。
|
|
478
|
+
"""combo 的 args:``json_file`` 位置可选,加 ``--steps-json`` / ``--wait``。
|
|
429
479
|
互斥校验在 ``_read_combo_steps`` 里做(argparse 里不能同时加 dest 冲突的
|
|
430
480
|
位置参数和可选参数到 mutually_exclusive_group)。"""
|
|
431
481
|
p.add_argument(
|
|
@@ -439,6 +489,7 @@ def _register_combo_args(p: argparse.ArgumentParser) -> None:
|
|
|
439
489
|
default=None,
|
|
440
490
|
help="直接传 JSON 字符串,不需要文件(与位置参数互斥)",
|
|
441
491
|
)
|
|
492
|
+
_register_wait_flag(p)
|
|
442
493
|
|
|
443
494
|
|
|
444
495
|
def _register_tree_args(p: argparse.ArgumentParser) -> None:
|
|
@@ -558,23 +609,25 @@ RPC_SPECS: tuple[RpcSpec, ...] = (
|
|
|
558
609
|
RpcSpec(
|
|
559
610
|
name="tap",
|
|
560
611
|
handler=cmd_tap,
|
|
561
|
-
description="短按动作(press → 等待 → release
|
|
612
|
+
description="短按动作(press → 等待 → release)。默认异步立即返回;加 --wait 阻塞到时长结束。",
|
|
562
613
|
positionals=(
|
|
563
614
|
Positional("action", None, "InputMap 动作名"),
|
|
564
615
|
Positional("duration", "?", "按下时长(秒),默认 0.1"),
|
|
565
616
|
),
|
|
566
617
|
example="tap jump 0.2",
|
|
618
|
+
extra_args=_register_wait_flag,
|
|
567
619
|
text_formatter=lambda r: f"tapped: {r}",
|
|
568
620
|
),
|
|
569
621
|
RpcSpec(
|
|
570
622
|
name="hold",
|
|
571
623
|
handler=cmd_hold,
|
|
572
|
-
description="
|
|
624
|
+
description="按住动作指定时长(秒),到点自动释放。默认命令立即返回(动作在游戏里持续该时长);要读动作完成后的状态请加 --wait(或命令后先 wait-time <时长>)。",
|
|
573
625
|
positionals=(
|
|
574
626
|
Positional("action", None, "InputMap 动作名"),
|
|
575
627
|
Positional("duration", None, "按住时长(秒,必须 > 0)"),
|
|
576
628
|
),
|
|
577
629
|
example="hold jump 1.5",
|
|
630
|
+
extra_args=_register_wait_flag,
|
|
578
631
|
preflight=_preflight_hold,
|
|
579
632
|
text_formatter=lambda r: f"holding: {r}",
|
|
580
633
|
),
|
|
@@ -762,12 +815,37 @@ RPC_BY_NAME: dict[str, RpcSpec] = {s.name: s for s in RPC_SPECS}
|
|
|
762
815
|
# ── Daemon / run / init 子命令 ──
|
|
763
816
|
|
|
764
817
|
|
|
818
|
+
def _resolve_idle_timeout(ns: argparse.Namespace) -> int:
|
|
819
|
+
"""解析 idle-timeout 秒数:显式 ``--idle-timeout`` > 项目级 config > 0(关闭)。
|
|
820
|
+
|
|
821
|
+
issue #44:默认值 ``"0"`` 时回退读 ``.cli_control/config.json`` 的 ``idle_timeout``,
|
|
822
|
+
省得喜欢自动收尾的用户每次手敲 ``--idle-timeout``。config 也没设则仍是 0(关闭)。
|
|
823
|
+
注意:显式 ``--idle-timeout 0`` 与默认 ``"0"`` 不可区分,两者都会回退 config——
|
|
824
|
+
config 设了又想单次强制关闭,临时 `unset` 配置即可(YAGNI,未做 opt-out flag)。
|
|
825
|
+
非法 duration(CLI 或 config 任一)抛 ``ValueError``,由调用方转 EXIT_USAGE。
|
|
826
|
+
"""
|
|
827
|
+
from ._duration import parse_duration
|
|
828
|
+
|
|
829
|
+
raw = getattr(ns, "idle_timeout", "0")
|
|
830
|
+
if raw == "0":
|
|
831
|
+
from .daemon import read_project_config
|
|
832
|
+
|
|
833
|
+
cfg_val = read_project_config().get("idle_timeout")
|
|
834
|
+
if cfg_val is not None:
|
|
835
|
+
try:
|
|
836
|
+
return parse_duration(str(cfg_val))
|
|
837
|
+
except ValueError as e:
|
|
838
|
+
raise ValueError(
|
|
839
|
+
f".cli_control/config.json 的 idle_timeout 非法:{e}"
|
|
840
|
+
) from e
|
|
841
|
+
return parse_duration(raw)
|
|
842
|
+
|
|
843
|
+
|
|
765
844
|
def cmd_daemon_start(ns: argparse.Namespace) -> int:
|
|
766
845
|
from .daemon import Daemon, DaemonError
|
|
767
|
-
from ._duration import parse_duration
|
|
768
846
|
|
|
769
847
|
try:
|
|
770
|
-
idle_seconds =
|
|
848
|
+
idle_seconds = _resolve_idle_timeout(ns)
|
|
771
849
|
except ValueError as e:
|
|
772
850
|
_emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
|
|
773
851
|
return EXIT_USAGE
|
|
@@ -965,7 +1043,6 @@ def cmd_run(ns: argparse.Namespace) -> int:
|
|
|
965
1043
|
64 argparse 用法错(idle_timeout 解析失败等"明显参数写错")
|
|
966
1044
|
"""
|
|
967
1045
|
from .daemon import Daemon, DaemonError
|
|
968
|
-
from ._duration import parse_duration
|
|
969
1046
|
|
|
970
1047
|
fmt = _output_format(ns)
|
|
971
1048
|
|
|
@@ -985,7 +1062,7 @@ def cmd_run(ns: argparse.Namespace) -> int:
|
|
|
985
1062
|
return EXIT_INFRA_ERROR
|
|
986
1063
|
|
|
987
1064
|
try:
|
|
988
|
-
idle_seconds =
|
|
1065
|
+
idle_seconds = _resolve_idle_timeout(ns)
|
|
989
1066
|
except ValueError as e:
|
|
990
1067
|
if fmt == OUTPUT_JSON:
|
|
991
1068
|
_emit_error_payload(CLIENT_CODE_USAGE, str(e))
|
|
@@ -1395,7 +1472,8 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
|
|
|
1395
1472
|
"--idle-timeout",
|
|
1396
1473
|
type=str,
|
|
1397
1474
|
default="0",
|
|
1398
|
-
help="空闲超时(如 30m / 2h / 90s / 0=关闭,默认关)。开启后 Godot 端 Timer 自动 quit。"
|
|
1475
|
+
help="空闲超时(如 30m / 2h / 90s / 0=关闭,默认关)。开启后 Godot 端 Timer 自动 quit。"
|
|
1476
|
+
"不传时回退读 .cli_control/config.json 的 idle_timeout(issue #44),省得每次手敲。",
|
|
1399
1477
|
)
|
|
1400
1478
|
|
|
1401
1479
|
|
|
@@ -1748,9 +1826,12 @@ async def _run_rpc(
|
|
|
1748
1826
|
_emit_envelope_error(fmt, code, msg)
|
|
1749
1827
|
return EXIT_INFRA_ERROR
|
|
1750
1828
|
except (ValueError, json.JSONDecodeError) as e:
|
|
1751
|
-
# 用法错误:
|
|
1829
|
+
# 用法错误:set/call 的 value JSON 解析失败等(这些命令无 preflight)。
|
|
1830
|
+
# 统一退 EXIT_USAGE(64),与 preflight 路径(combo/hold)一致 —— 同一客户端
|
|
1831
|
+
# 码 -1003 必须恒等于 exit 64,否则 agent 拿到 -1003 无法据此推断退出码
|
|
1832
|
+
# (issue #82,破坏「单 code 字段无歧义」契约)。
|
|
1752
1833
|
_emit_envelope_error(fmt, CLIENT_CODE_USAGE, str(e))
|
|
1753
|
-
return
|
|
1834
|
+
return EXIT_USAGE
|
|
1754
1835
|
except Exception as e: # noqa: BLE001
|
|
1755
1836
|
# 兜底:客户端内部 bug(AttributeError、KeyError、协议解析意外等)
|
|
1756
1837
|
# 也要落进信封,否则 ``--json`` 契约对 AI agent 不再可信。把异常类
|
|
@@ -1805,9 +1886,10 @@ def main() -> None:
|
|
|
1805
1886
|
|
|
1806
1887
|
port = ns.port
|
|
1807
1888
|
if port is None:
|
|
1808
|
-
|
|
1889
|
+
# 与 GameClient()/GameBridge() 共用同一发现入口(issue #91)。
|
|
1890
|
+
from .daemon import discover_port
|
|
1809
1891
|
|
|
1810
|
-
port =
|
|
1892
|
+
port = discover_port() or DEFAULT_PORT
|
|
1811
1893
|
|
|
1812
1894
|
rc = asyncio.run(_run_rpc(spec, ns, port, fmt))
|
|
1813
1895
|
sys.exit(rc)
|
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import base64
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import os
|
|
7
8
|
import uuid
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
@@ -23,7 +24,39 @@ DEFAULT_PORT: int = 9877
|
|
|
23
24
|
# 改用固定大值后:正常完成时 server 自然回包;server 真死循环时由 600s 兜底
|
|
24
25
|
# (而不是 55s 把还活着的录像炸掉);死连接由 websockets 库的 ping/pong 心跳
|
|
25
26
|
# 在 ~40s 内独立检测,与本上限无关。
|
|
26
|
-
|
|
27
|
+
LONG_OP_DEFAULT_TIMEOUT: float = 600.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_long_op_timeout() -> float:
|
|
31
|
+
"""长操作生死线,可被 ``GODOT_CLI_LONG_OP_TIMEOUT`` env 覆盖(issue #69)。
|
|
32
|
+
|
|
33
|
+
默认 ``LONG_OP_DEFAULT_TIMEOUT``(600s)。合法录像 > 10min 等少数长操作可
|
|
34
|
+
设环境变量调高(如 ``GODOT_CLI_LONG_OP_TIMEOUT=1800``)。非正数 / 非数字一律
|
|
35
|
+
忽略并回退默认——这是个兜底生死线,绝不能因 env 写错被设成 0 把正常操作秒杀。
|
|
36
|
+
"""
|
|
37
|
+
raw = os.environ.get("GODOT_CLI_LONG_OP_TIMEOUT")
|
|
38
|
+
if not raw:
|
|
39
|
+
return LONG_OP_DEFAULT_TIMEOUT
|
|
40
|
+
try:
|
|
41
|
+
value = float(raw)
|
|
42
|
+
except ValueError:
|
|
43
|
+
logger.warning(
|
|
44
|
+
"GODOT_CLI_LONG_OP_TIMEOUT=%r 不是数字,回退默认 %.0fs",
|
|
45
|
+
raw, LONG_OP_DEFAULT_TIMEOUT,
|
|
46
|
+
)
|
|
47
|
+
return LONG_OP_DEFAULT_TIMEOUT
|
|
48
|
+
if value <= 0:
|
|
49
|
+
logger.warning(
|
|
50
|
+
"GODOT_CLI_LONG_OP_TIMEOUT=%r 必须 > 0,回退默认 %.0fs",
|
|
51
|
+
raw, LONG_OP_DEFAULT_TIMEOUT,
|
|
52
|
+
)
|
|
53
|
+
return LONG_OP_DEFAULT_TIMEOUT
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# 模块级解析一次:进程生命周期内 env 不变;测试需要时改 env 后调
|
|
58
|
+
# ``_resolve_long_op_timeout()`` 拿新值,或直接给 request() 传 timeout。
|
|
59
|
+
LONG_OP_CLIENT_TIMEOUT: float = _resolve_long_op_timeout()
|
|
27
60
|
|
|
28
61
|
|
|
29
62
|
class RpcError(RuntimeError):
|
|
@@ -43,7 +76,17 @@ class RpcError(RuntimeError):
|
|
|
43
76
|
class GameClient:
|
|
44
77
|
"""WebSocket client that connects to Godot's GameBridge service."""
|
|
45
78
|
|
|
46
|
-
def __init__(self, port: int =
|
|
79
|
+
def __init__(self, port: int | None = None) -> None:
|
|
80
|
+
# port=None(无显式端口)→ 从 .cli_control/port auto-discover,找不到再回退
|
|
81
|
+
# DEFAULT_PORT。daemon 默认 OS 自动分配端口,所以照 README 写
|
|
82
|
+
# ``GameClient()`` 单连接脚本的用户/agent 必须经由这条发现路径才能连上
|
|
83
|
+
# (issue #91)。发现逻辑收敛在 daemon.discover_port,CLI 走同一入口。
|
|
84
|
+
# 延迟 import 避免 client 在 package import 时拉起 daemon→registry 这条
|
|
85
|
+
# 较重的依赖链。
|
|
86
|
+
if port is None:
|
|
87
|
+
from .daemon import discover_port
|
|
88
|
+
|
|
89
|
+
port = discover_port() or DEFAULT_PORT
|
|
47
90
|
self._port = port
|
|
48
91
|
self._ws: ClientConnection | None = None
|
|
49
92
|
self._pending: dict[str, asyncio.Future[dict]] = {}
|
|
@@ -279,17 +322,18 @@ class GameClient:
|
|
|
279
322
|
async def wait_game_time(self, seconds: float) -> dict:
|
|
280
323
|
"""按 Godot game time 等待 N 秒。
|
|
281
324
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
325
|
+
客户端用固定的长操作生死线(issue #45):game-time 与 wall-time 比值受
|
|
326
|
+
录像模式 / 分辨率 / 盘速 / fps 影响不可预测,任何 seconds-scaled 公式都会
|
|
327
|
+
在某个组合下假超时。死连接由 ws ping/pong 兜底。生死线默认 600s,可被
|
|
328
|
+
``GODOT_CLI_LONG_OP_TIMEOUT`` env 覆盖(issue #69)——每次调用现取,
|
|
329
|
+
让 env 改动即时生效。seconds <= 0 时客户端短路返回,不发 RPC。
|
|
286
330
|
"""
|
|
287
331
|
if seconds <= 0:
|
|
288
332
|
return {"success": True}
|
|
289
333
|
return await self.request(
|
|
290
334
|
"wait_game_time",
|
|
291
335
|
{"seconds": seconds},
|
|
292
|
-
timeout=
|
|
336
|
+
timeout=_resolve_long_op_timeout(),
|
|
293
337
|
)
|
|
294
338
|
|
|
295
339
|
# ---- Input simulation API ----
|
|
@@ -311,10 +355,11 @@ class GameClient:
|
|
|
311
355
|
)
|
|
312
356
|
|
|
313
357
|
async def combo(self, steps: list[dict]) -> dict:
|
|
314
|
-
#
|
|
315
|
-
#
|
|
358
|
+
# 长操作生死线(issue #45):见 wait_game_time 注释——同样的 game-time /
|
|
359
|
+
# wall-time 不对齐问题。默认 600s,可被 GODOT_CLI_LONG_OP_TIMEOUT 覆盖
|
|
360
|
+
# (issue #69),每次调用现取。
|
|
316
361
|
return await self.request(
|
|
317
|
-
"input_combo", {"steps": steps}, timeout=
|
|
362
|
+
"input_combo", {"steps": steps}, timeout=_resolve_long_op_timeout()
|
|
318
363
|
)
|
|
319
364
|
|
|
320
365
|
async def combo_cancel(self) -> dict:
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import json
|
|
8
9
|
import os
|
|
9
10
|
import shutil
|
|
10
11
|
import signal
|
|
@@ -363,6 +364,46 @@ class Daemon:
|
|
|
363
364
|
_reap_if_dead(pid) # 回收被 SIGKILL 的 zombie 子进程(若它是本进程的孩子)
|
|
364
365
|
|
|
365
366
|
|
|
367
|
+
# ── 项目级配置 ──
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def read_project_config(project_root: Path | None = None) -> dict:
|
|
371
|
+
"""读取项目级 ``<project_root>/.cli_control/config.json``(issue #44)。
|
|
372
|
+
|
|
373
|
+
存放无需每次手敲的默认值,目前支持 ``idle_timeout``(如 ``{"idle_timeout":"30m"}``)。
|
|
374
|
+
``project_root`` 默认 ``Path.cwd()``。缺文件 / 非法 JSON / 顶层非对象一律返回
|
|
375
|
+
``{}``——配置坏掉绝不能让 daemon 起不来,缺省即回到「无配置」行为。
|
|
376
|
+
"""
|
|
377
|
+
root = Path(project_root) if project_root is not None else Path.cwd()
|
|
378
|
+
path = root / ".cli_control" / "config.json"
|
|
379
|
+
if not path.exists():
|
|
380
|
+
return {}
|
|
381
|
+
try:
|
|
382
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
383
|
+
except (ValueError, OSError):
|
|
384
|
+
return {}
|
|
385
|
+
return data if isinstance(data, dict) else {}
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ── 端口发现 ──
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def discover_port(project_root: Path | None = None) -> int | None:
|
|
392
|
+
"""从 ``<project_root>/.cli_control/port`` 读取 daemon 当前监听端口。
|
|
393
|
+
|
|
394
|
+
daemon 默认以 OS 自动分配端口启动(写入 ``.cli_control/port``),所以"无显式
|
|
395
|
+
端口"的连接方都得先来这里发现实际端口,再回退 ``DEFAULT_PORT``。这是 CLI RPC
|
|
396
|
+
子命令、``GameClient()`` 与 ``GameBridge()`` 三处无显式 port 时的**唯一共用**
|
|
397
|
+
发现入口(issue #91)——与 README 承诺的 "auto-discover from ``.cli_control/port``"
|
|
398
|
+
对齐。
|
|
399
|
+
|
|
400
|
+
``project_root`` 默认 ``Path.cwd()``(与 CLI 的工作目录约定一致)。无 port 文件
|
|
401
|
+
或解析失败时返回 ``None``,由调用方决定回退值。
|
|
402
|
+
"""
|
|
403
|
+
root = Path(project_root) if project_root is not None else Path.cwd()
|
|
404
|
+
return Daemon(root).current_port()
|
|
405
|
+
|
|
406
|
+
|
|
366
407
|
# ── Godot 二进制发现 ──
|
|
367
408
|
|
|
368
409
|
|
|
@@ -14,7 +14,7 @@ WebSocket bridge for headless / scripted control of Godot 4 scenes. A daemon pro
|
|
|
14
14
|
**Output is JSON by default.** Every RPC subcommand prints a single-line envelope on stdout:
|
|
15
15
|
|
|
16
16
|
- success: `{"ok": true, "result": <data>}` — exit 0 (or per-command exit code, see *Exit codes* below)
|
|
17
|
-
- error: `{"ok": false, "error": {"code": <int>, "message": "..."}}` — exit 1 (RPC error) / 2 (connection, timeout
|
|
17
|
+
- error: `{"ok": false, "error": {"code": <int>, "message": "..."}}` — exit 1 (RPC error) / 2 (connection, timeout) / 64 (usage)
|
|
18
18
|
|
|
19
19
|
Pipe straight into `jq` or `json.loads`. Add `--text` (or `--no-json`) to switch back to the legacy human-readable strings if you really want them.
|
|
20
20
|
|
|
@@ -44,9 +44,9 @@ godot-cli-control daemon stop
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| 0 | Success (or, for `exists` / `visible` / `wait-node`, the boolean was true / found) |
|
|
46
46
|
| 1 | RPC error (server returned `{"error":...}`); also `exists`/`visible`=false, `wait-node`=timeout, `daemon status`=stopped |
|
|
47
|
-
| 2 | Connection / IO
|
|
47
|
+
| 2 | Connection / IO error (daemon not running, 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
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[]`. |
|
|
49
|
-
| 64 |
|
|
49
|
+
| 64 | Usage error on an **RPC subcommand** — argparse, a pre-flight reject caught before connecting (`combo` with no steps / malformed `--steps-json` / `combo -` from a TTY, `hold` with a non-positive duration), **and** a bad runtime argument (`tap` / `wait-time` given a non-number, a `set`/`call` value that fails JSON parsing). For RPC subcommands these all carry client code `-1003` and now consistently exit 64 (a few previously exited 2 — fixed in #82). |
|
|
50
50
|
|
|
51
51
|
Shell-`if` works:
|
|
52
52
|
|
|
@@ -128,7 +128,7 @@ Three numeric ranges cohabit in `error.code`. Knowing which is which lets you de
|
|
|
128
128
|
|---|---|
|
|
129
129
|
| `-1001` | Connection failure (daemon not running, port wrong, proxy hijacking localhost). Run `daemon status`. |
|
|
130
130
|
| `-1002` | Timeout waiting for a response. Daemon may be hung mid-frame; check Godot stderr. |
|
|
131
|
-
| `-1003` | Usage error (
|
|
131
|
+
| `-1003` | Usage error (`combo` got no steps, malformed `--steps-json`, `combo -` from a TTY, a non-numeric `tap`/`wait-time` arg, **or** a `set`/`call` value that fails JSON parsing). From an RPC subcommand this always exits **64**. (Top-level `run`/`daemon` precondition failures — e.g. script path not found, daemon failed to start — also surface `-1003` but exit **2**.) Fix the invocation. |
|
|
132
132
|
| `-1004` | Local file IO error (e.g. `screenshot` can't write the destination — bad path, no write permission). **Not** a daemon problem. |
|
|
133
133
|
| `-1005` | `run <script>` user script raised an uncaught exception. The error message has the exception type + last-line summary; full traceback is on stderr. Fix the script, not the CLI. |
|
|
134
134
|
| `-1099` | Internal client error (unforeseen exception). Bug in this CLI; please file an issue. Stderr has the full traceback. |
|
|
@@ -154,12 +154,14 @@ Server vs client ranges never overlap, so a single `code` field is unambiguous.
|
|
|
154
154
|
|
|
155
155
|
**Input simulation:**
|
|
156
156
|
- `press <action>` / `release <action>` — sticky press
|
|
157
|
-
- `tap <action> [duration]` — press → wait → release
|
|
158
|
-
- `hold <action> <duration
|
|
159
|
-
- `combo --steps-json '[...]'` (or `combo file.json` / `combo -` for stdin) — sequence
|
|
157
|
+
- `tap <action> [duration] [--wait]` — press → wait → release
|
|
158
|
+
- `hold <action> <duration> [--wait]` — auto-release after N seconds (`duration` must be `> 0`; for an indefinite hold use `press`)
|
|
159
|
+
- `combo --steps-json '[...]' [--wait]` (or `combo file.json` / `combo -` for stdin) — sequence
|
|
160
160
|
- `combo-cancel` — abort running combo
|
|
161
161
|
- `release-all` — release everything
|
|
162
162
|
|
|
163
|
+
`tap` / `hold` / `combo` are **async by default** — they return as soon as the input is armed, *before* the in-game motion finishes (see *Common pitfalls*). Add **`--wait`** to block until the action's duration elapses (game-time) so the next `get` reads the settled state — it folds an implicit `wait-time <duration>` into the same command/connection.
|
|
164
|
+
|
|
163
165
|
**Wait:**
|
|
164
166
|
- `wait-node <path> [timeout]` — block until node appears (exit 0=found, 1=timeout)
|
|
165
167
|
- `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.
|
|
@@ -288,6 +290,9 @@ Key constraints:
|
|
|
288
290
|
- `ffmpeg` must be on `PATH` for transcoding. If transcoding fails, the raw `.avi` is kept and `daemon stop` exits with code `2` (transcode log at `.cli_control/ffmpeg.log`).
|
|
289
291
|
- `--fps` controls the **fixed simulation framerate** Godot runs at while recording — set it to your target video framerate.
|
|
290
292
|
- Output path is relative to cwd; use absolute paths if your script changes cwd.
|
|
293
|
+
- Long `wait-time` / `combo` / recording ops are bounded client-side by a fixed **600s** wall-time fail-safe (not a per-call timeout — game-time vs wall-time can't be predicted). A genuinely long operation (e.g. a > 10-minute recording) that trips it can raise the ceiling via `GODOT_CLI_LONG_OP_TIMEOUT=<seconds>` (e.g. `1800`); a non-positive / non-numeric value is ignored and falls back to 600s.
|
|
294
|
+
|
|
295
|
+
**Project-level defaults** (`.cli_control/config.json`, optional): set `{"idle_timeout": "30m"}` so `daemon start` / `run` auto-quit after idle without re-typing `--idle-timeout` every time. It's read only when you don't pass `--idle-timeout` (default `0`); an explicit flag wins. Bad JSON / a malformed duration there surfaces as a `-1003` usage error (exit 64).
|
|
291
296
|
|
|
292
297
|
## CLI reference
|
|
293
298
|
|
|
@@ -299,7 +304,7 @@ Key constraints:
|
|
|
299
304
|
|
|
300
305
|
## Python `GameClient` API
|
|
301
306
|
|
|
302
|
-
`from godot_cli_control.client import GameClient` — async WebSocket client; use as `async with GameClient(
|
|
307
|
+
`from godot_cli_control.client import GameClient` — async WebSocket client; use as `async with GameClient() as client:`. **With no `port` argument it auto-discovers from `.cli_control/port`** (the same file the daemon writes and the CLI reads), falling back to `9877` if absent — so a no-arg `GameClient()` connects to a running daemon out of the box. Pass `GameClient(port=N)` only to override. (`GameBridge()` in `run` scripts auto-discovers identically.) **Every method below has a 1-line CLI equivalent above; only reach for Python when you need to keep a client open across many steps without the connection-per-call overhead.**
|
|
303
308
|
|
|
304
309
|
Errors raise `RpcError(code, message)` (a `RuntimeError` subclass) that preserves the server's error code — useful for retrying `1004 "combo in progress"`.
|
|
305
310
|
|
|
@@ -394,6 +399,7 @@ pytest_plugins = ["godot_cli_control.pytest_plugin"]
|
|
|
394
399
|
- `daemon start --port N`: the port the daemon itself listens on. This is a local flag of `start`, so — like any other `daemon` flag — its position doesn't matter.
|
|
395
400
|
- **`combo` rejects everything with `1004`** — a combo is already running. Call `combo-cancel` (or `release-all`) to abort.
|
|
396
401
|
- **`hold` / `press` persist after the command returns** — by design. Each CLI command is its own short-lived connection that closes *cleanly*, and a clean close does **not** release inputs. `hold <action> <dur>` auto-releases after `<dur>` seconds (its timer keeps running in the daemon); a sticky `press <action>` stays held until you call `release <action>` / `release-all` (or the daemon's idle-timeout shuts it down). If a character looks stuck moving, you probably left a `press` dangling — run `release-all`. (An *abnormal* drop — your client crashing or being killed mid-session — does trigger a safety `release-all`, so stuck keys can't outlive a dead client.)
|
|
402
|
+
- **`hold` / `tap` / `combo` return *before* the motion finishes — use `--wait` (or `wait-time`) before reading state.** These input commands are asynchronous: `hold move_right 1.0` returns in ~0.4s (it just arms a release-timer in the daemon), but the character keeps moving for the full `1.0` in-game second. If you `get position` immediately you read a *mid-motion* value (e.g. `x=415` instead of the settled `x=540`). Two fixes: ① pass **`--wait`** (`hold move_right 1.0 --wait` blocks until the duration elapses, then `get … position` reads the settled value) — one command, one connection; ② or do it explicitly: `hold move_right 1.0` → `wait-time 1.0` → `get … position`. `--wait` works on `tap` (default `0.1`s) and `combo` (waits the summed step durations) too. Either way, also account for any physics/animation that plays out over extra frames after the input lands.
|
|
397
403
|
- **`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.
|
|
398
404
|
- **`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.
|
|
399
405
|
- **`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.
|