godot-cli-control 0.2.12__tar.gz → 0.2.14__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.14}/PKG-INFO +22 -2
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/README.md +36 -3
- godot_cli_control-0.2.14/pyproject.toml +151 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/README.md +1 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/_version.py +2 -2
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/bridge.py +5 -2
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/cli.py +97 -15
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/client.py +55 -10
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/daemon.py +41 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/templates/skill/SKILL.md +14 -8
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_cli.py +19 -12
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_cli_helpers.py +0 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_client.py +5 -5
- godot_cli_control-0.2.14/python/tests/test_e2e_client_direct.py +217 -0
- godot_cli_control-0.2.14/python/tests/test_e2e_example.py +123 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_e2e_input.py +8 -5
- godot_cli_control-0.2.14/python/tests/test_e2e_record.py +198 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_e2e_screenshot_gui.py +8 -4
- godot_cli_control-0.2.14/python/tests/test_issue_fixes.py +292 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_pytest_plugin.py +15 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_registry.py +8 -4
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_runner.py +0 -1
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_skills_install.py +0 -1
- godot_cli_control-0.2.12/pyproject.toml +0 -86
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/.gitignore +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/LICENSE +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/CHANGELOG.md +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/LICENSE +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/plugin.cfg +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/plugin.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/plugin.gd.uid +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/run_gut.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/run_gut.sh +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/__main__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/_duration.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/init_cmd.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/pytest_plugin.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/registry.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/runner.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/skills_install.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/templates/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/godot_cli_control/templates/skill/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/__init__.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_bridge.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_daemon.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_duration.py +0 -0
- {godot_cli_control-0.2.12 → godot_cli_control-0.2.14}/python/tests/test_init.py +0 -0
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godot-cli-control
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.14
|
|
4
4
|
Summary: WebSocket bridge for headless / scripted control of Godot scenes.
|
|
5
|
+
Project-URL: Homepage, https://github.com/ClaymanTwinkle/godot-cli-control
|
|
6
|
+
Project-URL: Repository, https://github.com/ClaymanTwinkle/godot-cli-control
|
|
7
|
+
Project-URL: Documentation, https://github.com/ClaymanTwinkle/godot-cli-control#readme
|
|
8
|
+
Project-URL: Changelog, https://github.com/ClaymanTwinkle/godot-cli-control/blob/main/addons/godot_cli_control/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/ClaymanTwinkle/godot-cli-control/issues
|
|
5
10
|
Author: kesar
|
|
6
11
|
License: MIT
|
|
7
12
|
License-File: LICENSE
|
|
13
|
+
Keywords: ai-agent,automation,cli,game-testing,gdscript,godot,godot-plugin,godot4,headless,json-rpc,llm,pytest,screenshot,websocket
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Classifier: Environment :: Console
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Games/Entertainment
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
8
27
|
Requires-Python: >=3.10
|
|
9
28
|
Requires-Dist: websockets<16,>=14
|
|
10
29
|
Provides-Extra: pytest
|
|
@@ -13,6 +32,7 @@ Provides-Extra: test
|
|
|
13
32
|
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
|
|
14
33
|
Requires-Dist: pytest-cov>=4; extra == 'test'
|
|
15
34
|
Requires-Dist: pytest>=7; extra == 'test'
|
|
35
|
+
Requires-Dist: ruff>=0.6; extra == 'test'
|
|
16
36
|
Description-Content-Type: text/markdown
|
|
17
37
|
|
|
18
38
|
# godot-cli-control
|
|
@@ -25,7 +45,7 @@ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python cl
|
|
|
25
45
|
pipx install godot-cli-control
|
|
26
46
|
|
|
27
47
|
# or, for unreleased main:
|
|
28
|
-
pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git
|
|
48
|
+
pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git"
|
|
29
49
|
```
|
|
30
50
|
|
|
31
51
|
The wheel ships the Godot plugin source so the `init` command can drop it into your project.
|
|
@@ -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.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "godot-cli-control"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "WebSocket bridge for headless / scripted control of Godot scenes."
|
|
5
|
+
readme = "python/README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "kesar" }]
|
|
9
|
+
# keywords / classifiers 仅供 PyPI 搜索与分类筛选;与 GitHub repo topics 对齐。
|
|
10
|
+
# 改这里时顺手对一眼 README 顶部徽章 + repo About 的 topics,别让三处漂移。
|
|
11
|
+
keywords = [
|
|
12
|
+
"godot",
|
|
13
|
+
"godot4",
|
|
14
|
+
"godot-plugin",
|
|
15
|
+
"gdscript",
|
|
16
|
+
"automation",
|
|
17
|
+
"game-testing",
|
|
18
|
+
"pytest",
|
|
19
|
+
"websocket",
|
|
20
|
+
"json-rpc",
|
|
21
|
+
"ai-agent",
|
|
22
|
+
"llm",
|
|
23
|
+
"screenshot",
|
|
24
|
+
"headless",
|
|
25
|
+
"cli",
|
|
26
|
+
]
|
|
27
|
+
classifiers = [
|
|
28
|
+
# README 自称 Alpha;升 Beta/Stable 时记得同步这里与 README 的 Status 段。
|
|
29
|
+
"Development Status :: 3 - Alpha",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"License :: OSI Approved :: MIT License",
|
|
32
|
+
"Operating System :: OS Independent",
|
|
33
|
+
"Environment :: Console",
|
|
34
|
+
# Python 版本与 CI matrix(.github/workflows/ci.yml)保持一致:实测 3.10–3.13。
|
|
35
|
+
"Programming Language :: Python :: 3",
|
|
36
|
+
"Programming Language :: Python :: 3.10",
|
|
37
|
+
"Programming Language :: Python :: 3.11",
|
|
38
|
+
"Programming Language :: Python :: 3.12",
|
|
39
|
+
"Programming Language :: Python :: 3.13",
|
|
40
|
+
"Topic :: Software Development :: Testing",
|
|
41
|
+
"Topic :: Games/Entertainment",
|
|
42
|
+
# pytest11 entry-point 让它成为一个 pytest 插件 —— 这条 classifier 能把包
|
|
43
|
+
# 收进 PyPI 的 pytest-plugin 分类列表,是面向 pytest 用户的关键发现入口。
|
|
44
|
+
"Framework :: Pytest",
|
|
45
|
+
]
|
|
46
|
+
dependencies = [
|
|
47
|
+
"websockets>=14,<16",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[project.optional-dependencies]
|
|
51
|
+
pytest = ["pytest>=7"]
|
|
52
|
+
# 全套测试依赖 —— 仅 CI / 开发者本地测试用,包含 pytest-cov、pytest-asyncio 与
|
|
53
|
+
# ruff(lint 门禁,issue #83)。普通用户用 `[pytest]` 已够;不要把这些拖到默认安装路径。
|
|
54
|
+
test = ["pytest>=7", "pytest-asyncio>=0.21", "pytest-cov>=4", "ruff>=0.6"]
|
|
55
|
+
|
|
56
|
+
[project.scripts]
|
|
57
|
+
godot-cli-control = "godot_cli_control.cli:main"
|
|
58
|
+
|
|
59
|
+
[project.entry-points.pytest11]
|
|
60
|
+
godot-cli-control = "godot_cli_control.pytest_plugin"
|
|
61
|
+
|
|
62
|
+
# PyPI 项目页侧栏的链接全靠这张表(现在为空 = 侧栏只有一个 PyPI 自身链接)。
|
|
63
|
+
# Changelog 指向 addon 内的 CHANGELOG(README「Status」段也引这份),别改成根目录。
|
|
64
|
+
[project.urls]
|
|
65
|
+
Homepage = "https://github.com/ClaymanTwinkle/godot-cli-control"
|
|
66
|
+
Repository = "https://github.com/ClaymanTwinkle/godot-cli-control"
|
|
67
|
+
Documentation = "https://github.com/ClaymanTwinkle/godot-cli-control#readme"
|
|
68
|
+
Changelog = "https://github.com/ClaymanTwinkle/godot-cli-control/blob/main/addons/godot_cli_control/CHANGELOG.md"
|
|
69
|
+
Issues = "https://github.com/ClaymanTwinkle/godot-cli-control/issues"
|
|
70
|
+
|
|
71
|
+
[tool.pytest.ini_options]
|
|
72
|
+
# pytest-asyncio:显式钉死 async fixture 的 loop scope 为 function(= 未来版本默认值),
|
|
73
|
+
# 消除 "asyncio_default_fixture_loop_scope is unset" 的 deprecation warning。改这行前
|
|
74
|
+
# 先确认没有 module/session-scope 的 async fixture 依赖跨用例共享 loop。
|
|
75
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
76
|
+
# gui:需要真实显示(开窗)才能跑的端到端测试。普通 unit / headless e2e 不带此
|
|
77
|
+
# marker。本地默认随 godot 检测 / GCC_GUI_E2E 未设而 skip;CI 专档(Linux 套
|
|
78
|
+
# xvfb 或 macOS runner)设 GCC_GUI_E2E=1 开启。注册在此避免 unknown-marker 警告,
|
|
79
|
+
# 也让 `pytest -m "not gui"` 能稳定剔除。
|
|
80
|
+
markers = [
|
|
81
|
+
"gui: 需要真实显示的 windowed e2e(screenshot frame_post_draw 路径,issue #64)",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# ── lint(ruff)─────────────────────────────────────────────────────────
|
|
85
|
+
# issue #83:把 lint 从「开发者本机全局 ruff」收编成 CI 门禁 + dev 依赖,趁全仓
|
|
86
|
+
# 基线为 0 锁定收益。规则集刻意只钉 ruff 默认的 pyflakes(F) + pycodestyle 子集
|
|
87
|
+
# (E4/E7/E9)——这正是当前 0-error 基线对应的集合;扩到 E501/I/UP/B 会一次性炸出
|
|
88
|
+
# 几十条历史报错,与「锁定基线、零噪音引入门禁」的目标相悖。要加规则请单开 PR、
|
|
89
|
+
# 连同 autofix 一起 land,别让门禁第一天就红。line-length 仅供 `ruff format`
|
|
90
|
+
# 参考(E501 未纳入 select,不构成门禁)。
|
|
91
|
+
[tool.ruff]
|
|
92
|
+
target-version = "py310"
|
|
93
|
+
line-length = 100
|
|
94
|
+
extend-exclude = ["python/godot_cli_control/_version.py"] # hatch-vcs 生成物
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint]
|
|
97
|
+
select = ["E4", "E7", "E9", "F"]
|
|
98
|
+
|
|
99
|
+
[build-system]
|
|
100
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
101
|
+
build-backend = "hatchling.build"
|
|
102
|
+
|
|
103
|
+
[tool.hatch.version]
|
|
104
|
+
source = "vcs"
|
|
105
|
+
|
|
106
|
+
[tool.hatch.build.hooks.vcs]
|
|
107
|
+
version-file = "python/godot_cli_control/_version.py"
|
|
108
|
+
|
|
109
|
+
[tool.hatch.build.targets.wheel]
|
|
110
|
+
packages = ["python/godot_cli_control"]
|
|
111
|
+
|
|
112
|
+
# 把 GDScript 插件嵌进 wheel,让 `godot-cli-control init` 能从安装包里把 plugin
|
|
113
|
+
# 复制到目标 Godot 项目。位置 _plugin/ 与 init_cmd.locate_plugin_source() 的查找逻辑约定。
|
|
114
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
115
|
+
"addons/godot_cli_control" = "godot_cli_control/_plugin"
|
|
116
|
+
|
|
117
|
+
# sdist 只装构建 wheel 必需的物料;docs/、仓库根 README 不进 sdist。
|
|
118
|
+
[tool.hatch.build.targets.sdist]
|
|
119
|
+
include = [
|
|
120
|
+
"/python/godot_cli_control",
|
|
121
|
+
"/python/tests",
|
|
122
|
+
"/python/README.md",
|
|
123
|
+
"/addons/godot_cli_control",
|
|
124
|
+
"/LICENSE",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
# ── 覆盖率 ──────────────────────────────────────────────────────────────
|
|
128
|
+
# 用 `coverage run -m pytest` 启动(不是 `pytest --cov`)—— pytest 启动时
|
|
129
|
+
# pytest11 entry-point 会自动加载 godot_cli_control.pytest_plugin,进而 import
|
|
130
|
+
# bridge/daemon/client;如果用 pytest-cov 那是先 pytest 后 cov,import-time 语句
|
|
131
|
+
# 全部 miss。`coverage run` 在 Python 启动时就开 trace,避开这个时序坑。
|
|
132
|
+
[tool.coverage.run]
|
|
133
|
+
source = ["godot_cli_control"]
|
|
134
|
+
branch = true
|
|
135
|
+
omit = [
|
|
136
|
+
"*/_version.py", # hatch-vcs 生成的版本字符串,无逻辑
|
|
137
|
+
"*/__main__.py", # `python -m godot_cli_control` 入口,靠 walkthrough 覆盖
|
|
138
|
+
"*/templates/*", # init 命令复制出去的模板,由 walkthrough 测
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
[tool.coverage.report]
|
|
142
|
+
show_missing = true
|
|
143
|
+
skip_covered = false
|
|
144
|
+
exclude_lines = [
|
|
145
|
+
"pragma: no cover",
|
|
146
|
+
"if __name__ == .__main__.:",
|
|
147
|
+
"raise NotImplementedError",
|
|
148
|
+
]
|
|
149
|
+
# 80% 是当前 Python 源码大致能稳定到的水准(cli.py 很大但既有测试良好)。
|
|
150
|
+
# 调高的话先在本地 `coverage report --fail-under=N` 验过再改。
|
|
151
|
+
fail_under = 80
|
|
@@ -8,7 +8,7 @@ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python cl
|
|
|
8
8
|
pipx install godot-cli-control
|
|
9
9
|
|
|
10
10
|
# or, for unreleased main:
|
|
11
|
-
pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git
|
|
11
|
+
pipx install "git+https://github.com/ClaymanTwinkle/godot-cli-control.git"
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
The wheel ships the Godot plugin source so the `init` command can drop it into your project.
|
|
@@ -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.14'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 14)
|
|
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:
|