godot-cli-control 0.1.0__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.
@@ -0,0 +1,26 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ venv/
10
+ .pytest_cache/
11
+
12
+ # Godot
13
+ .godot/
14
+ *.import
15
+ *.translation
16
+ export.cfg
17
+ export_presets.cfg
18
+
19
+ # OS / editor
20
+ .DS_Store
21
+ Thumbs.db
22
+ .idea/
23
+ .vscode/
24
+
25
+ # CLI control runtime
26
+ .cli_control/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kesar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: godot-cli-control
3
+ Version: 0.1.0
4
+ Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
+ Author: kesar
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: websockets<16,>=14
10
+ Description-Content-Type: text/markdown
11
+
12
+ # godot-cli-control
13
+
14
+ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python client.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install godot-cli-control
20
+ ```
21
+
22
+ Requires Python ≥ 3.10. Companion Godot plugin must be installed and enabled in your Godot project (see [the plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for setup).
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ import asyncio
28
+ from godot_cli_control import GameClient
29
+
30
+ async def main():
31
+ async with GameClient(port=9877) as client:
32
+ # Inspect scene
33
+ tree = await client.get_scene_tree(depth=3)
34
+ print(tree)
35
+
36
+ # Interact
37
+ await client.click("/root/MyScene/Button")
38
+ await client.action_press("jump")
39
+ await client.wait_game_time(0.5)
40
+ await client.action_release("jump")
41
+
42
+ # Capture
43
+ png_bytes = await client.screenshot()
44
+ open("frame.png", "wb").write(png_bytes)
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ ```bash
52
+ python -m godot_cli_control tree 3
53
+ python -m godot_cli_control click /root/MyScene/Button
54
+ python -m godot_cli_control screenshot /tmp/frame.png
55
+ ```
56
+
57
+ ## Documentation
58
+
59
+ See the [Godot plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for the full RPC reference, activation modes, security model, and known limitations.
@@ -0,0 +1,48 @@
1
+ # godot-cli-control
2
+
3
+ WebSocket bridge for headless / scripted control of Godot 4 scenes — Python client.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install godot-cli-control
9
+ ```
10
+
11
+ Requires Python ≥ 3.10. Companion Godot plugin must be installed and enabled in your Godot project (see [the plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for setup).
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ import asyncio
17
+ from godot_cli_control import GameClient
18
+
19
+ async def main():
20
+ async with GameClient(port=9877) as client:
21
+ # Inspect scene
22
+ tree = await client.get_scene_tree(depth=3)
23
+ print(tree)
24
+
25
+ # Interact
26
+ await client.click("/root/MyScene/Button")
27
+ await client.action_press("jump")
28
+ await client.wait_game_time(0.5)
29
+ await client.action_release("jump")
30
+
31
+ # Capture
32
+ png_bytes = await client.screenshot()
33
+ open("frame.png", "wb").write(png_bytes)
34
+
35
+ asyncio.run(main())
36
+ ```
37
+
38
+ ## CLI
39
+
40
+ ```bash
41
+ python -m godot_cli_control tree 3
42
+ python -m godot_cli_control click /root/MyScene/Button
43
+ python -m godot_cli_control screenshot /tmp/frame.png
44
+ ```
45
+
46
+ ## Documentation
47
+
48
+ See the [Godot plugin README](https://github.com/kesar/godot-2d-skeleton/blob/main/addons/godot_cli_control/README.md) for the full RPC reference, activation modes, security model, and known limitations.
@@ -0,0 +1,6 @@
1
+ """Godot CLI Control — WebSocket bridge for headless / scripted control of Godot scenes."""
2
+
3
+ from godot_cli_control.client import GameClient
4
+
5
+ __all__ = ["GameClient"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python3 -m godot_cli_control <command> [args...]"""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,129 @@
1
+ """同步 Bridge —— 封装 GameClient,让 Python 脚本只需写操作逻辑。
2
+
3
+ 用法:脚本只需定义 run(bridge) 函数即可。
4
+
5
+ def run(bridge):
6
+ bridge.click("/root/MainMenu/.../NewGameButton")
7
+ bridge.wait(2)
8
+ bridge.hold("move_right", 1.5)
9
+ bridge.press("attack_melee")
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .client import GameClient
19
+
20
+
21
+ class GameBridge:
22
+ """同步接口,内部通过事件循环调用异步 GameClient。"""
23
+
24
+ def __init__(self, port: int = 9877) -> None:
25
+ self._loop = asyncio.new_event_loop()
26
+ self._client = GameClient(port=port)
27
+ self._loop.run_until_complete(
28
+ self._client.connect(retries=15, backoff=1.0)
29
+ )
30
+
31
+ def close(self) -> None:
32
+ self._loop.run_until_complete(self._client.disconnect())
33
+ self._loop.close()
34
+
35
+ def _run(self, coro: Any) -> Any:
36
+ return self._loop.run_until_complete(coro)
37
+
38
+ # ── 等待 ──
39
+
40
+ def wait(self, seconds: float) -> None:
41
+ """按 Godot game time 等待指定秒数。
42
+
43
+ 在 --write-movie 模式下,此方法等待的是 game time(录像帧数基准),
44
+ 而非 wall time,确保录像内容与脚本期望对齐。
45
+ """
46
+ self._run(self._client.wait_game_time(seconds))
47
+
48
+ # ── 场景树 ──
49
+
50
+ def tree(self, depth: int = 3) -> dict:
51
+ """获取场景树。"""
52
+ return self._run(self._client.get_scene_tree(depth=depth))
53
+
54
+ def node_exists(self, path: str) -> bool:
55
+ """检查节点是否存在。"""
56
+ return self._run(self._client.node_exists(path))
57
+
58
+ def wait_for_node(self, path: str, timeout: float = 5.0) -> bool:
59
+ """等待节点出现。"""
60
+ return self._run(self._client.wait_for_node(path, timeout=timeout))
61
+
62
+ # ── UI 交互 ──
63
+
64
+ def click(self, path: str) -> dict:
65
+ """点击 UI 节点。"""
66
+ return self._run(self._client.click(path))
67
+
68
+ # ── 输入模拟 ──
69
+
70
+ def press(self, action: str) -> None:
71
+ """按下并释放一个动作。"""
72
+ self._run(self._client.action_tap(action, 0.1))
73
+
74
+ def hold(self, action: str, duration: float) -> None:
75
+ """按住一个动作指定时长。"""
76
+ self._run(self._client.hold(action, duration))
77
+
78
+ def tap(self, action: str, duration: float = 0.1) -> None:
79
+ """快速点按。"""
80
+ self._run(self._client.action_tap(action, duration))
81
+
82
+ def move(self, x: float, y: float, duration: float) -> None:
83
+ """方向移动。"""
84
+ self._run(self._client.move(x, y, duration))
85
+
86
+ def action_press(self, action: str) -> None:
87
+ """按下动作(不释放,需手动 release)。"""
88
+ self._run(self._client.action_press(action))
89
+
90
+ def action_release(self, action: str) -> None:
91
+ """释放动作。"""
92
+ self._run(self._client.action_release(action))
93
+
94
+ def release_all(self) -> None:
95
+ """释放所有按住的动作。"""
96
+ self._run(self._client.release_all())
97
+
98
+ def combo(self, steps: list[dict]) -> dict:
99
+ """执行连续动作序列。"""
100
+ return self._run(self._client.combo(steps))
101
+
102
+ # ── 截图 ──
103
+
104
+ def screenshot(self, path: str | None = None) -> bytes:
105
+ """截图,返回 PNG 字节。可选保存到文件。"""
106
+ data = self._run(self._client.screenshot())
107
+ if path:
108
+ p = Path(path)
109
+ p.parent.mkdir(parents=True, exist_ok=True)
110
+ p.write_bytes(data)
111
+ return data
112
+
113
+ # ── 属性读写 ──
114
+
115
+ def get_property(self, path: str, prop: str) -> Any:
116
+ """获取节点属性。"""
117
+ return self._run(self._client.get_property(path, prop))
118
+
119
+ def set_property(self, path: str, prop: str, value: Any) -> None:
120
+ """设置节点属性。"""
121
+ self._run(self._client.set_property(path, prop, value))
122
+
123
+ def call_method(self, path: str, method: str, args: list | None = None) -> Any:
124
+ """调用节点方法。"""
125
+ return self._run(self._client.call_method(path, method, args))
126
+
127
+ def get_children(self, path: str, type_filter: str = "") -> list[dict]:
128
+ """获取子节点列表。"""
129
+ return self._run(self._client.get_children(path, type_filter=type_filter))
@@ -0,0 +1,158 @@
1
+ """CLI commands for one-off GameClient operations.
2
+
3
+ Usage:
4
+ python3 -m godot_cli_control click <node_path>
5
+ python3 -m godot_cli_control screenshot [output.png]
6
+ python3 -m godot_cli_control tree [depth]
7
+ python3 -m godot_cli_control press <action_name>
8
+ python3 -m godot_cli_control hold <action_name> <duration>
9
+ python3 -m godot_cli_control combo <sequence.json>
10
+
11
+ <action_name> 依赖目标工程的 InputMap;<node_path> 形如
12
+ /root/<Scene>/... 由 `tree` 查出。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import base64
19
+ import json
20
+ import sys
21
+ from collections.abc import Callable, Coroutine
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ from .client import GameClient
26
+
27
+ DEFAULT_PORT: int = 9877
28
+
29
+
30
+ async def cmd_click(client: GameClient, args: list[str]) -> None:
31
+ if not args:
32
+ print("Usage: click <node_path>", file=sys.stderr)
33
+ sys.exit(1)
34
+ result = await client.click(args[0])
35
+ print(f"clicked: {result}")
36
+
37
+
38
+ async def cmd_screenshot(client: GameClient, args: list[str]) -> None:
39
+ data = await client.screenshot()
40
+ if args:
41
+ output = Path(args[0])
42
+ output.parent.mkdir(parents=True, exist_ok=True)
43
+ output.write_bytes(data)
44
+ print(f"screenshot saved: {output} ({len(data)} bytes)")
45
+ else:
46
+ print(base64.b64encode(data).decode())
47
+
48
+
49
+ async def cmd_tree(client: GameClient, args: list[str]) -> None:
50
+ depth = int(args[0]) if args else 3
51
+ tree = await client.get_scene_tree(depth=depth)
52
+ print(json.dumps(tree, indent=2, ensure_ascii=False))
53
+
54
+
55
+ async def cmd_press(client: GameClient, args: list[str]) -> None:
56
+ if not args:
57
+ print("Usage: press <action>", file=sys.stderr)
58
+ sys.exit(1)
59
+ result = await client.action_press(args[0])
60
+ print(f"pressed: {result}")
61
+
62
+
63
+ async def cmd_release(client: GameClient, args: list[str]) -> None:
64
+ if not args:
65
+ print("Usage: release <action>", file=sys.stderr)
66
+ sys.exit(1)
67
+ result = await client.action_release(args[0])
68
+ print(f"released: {result}")
69
+
70
+
71
+ async def cmd_tap(client: GameClient, args: list[str]) -> None:
72
+ if not args:
73
+ print("Usage: tap <action> [duration]", file=sys.stderr)
74
+ sys.exit(1)
75
+ duration = float(args[1]) if len(args) > 1 else 0.1
76
+ result = await client.action_tap(args[0], duration)
77
+ print(f"tapped: {result}")
78
+
79
+
80
+ async def cmd_hold(client: GameClient, args: list[str]) -> None:
81
+ if len(args) < 2:
82
+ print("Usage: hold <action> <duration>", file=sys.stderr)
83
+ sys.exit(1)
84
+ result = await client.hold(args[0], float(args[1]))
85
+ print(f"holding: {result}")
86
+
87
+
88
+ async def cmd_move(client: GameClient, args: list[str]) -> None:
89
+ if len(args) < 3:
90
+ print("Usage: move <x> <y> <duration>", file=sys.stderr)
91
+ sys.exit(1)
92
+ result = await client.move(float(args[0]), float(args[1]), float(args[2]))
93
+ print(f"moving: {result}")
94
+
95
+
96
+ async def cmd_combo(client: GameClient, args: list[str]) -> None:
97
+ if not args:
98
+ print("Usage: combo <json_file>", file=sys.stderr)
99
+ sys.exit(1)
100
+ steps = json.loads(Path(args[0]).read_text())
101
+ if isinstance(steps, dict):
102
+ steps = steps.get("steps", [])
103
+ result = await client.combo(steps)
104
+ print(f"combo done: {result}")
105
+
106
+
107
+ async def cmd_release_all(client: GameClient, _args: list[str]) -> None:
108
+ result = await client.release_all()
109
+ print(f"released all: {result}")
110
+
111
+
112
+ COMMANDS: dict[
113
+ str, Callable[[GameClient, list[str]], Coroutine[Any, Any, None]]
114
+ ] = {
115
+ "click": cmd_click,
116
+ "screenshot": cmd_screenshot,
117
+ "tree": cmd_tree,
118
+ "press": cmd_press,
119
+ "release": cmd_release,
120
+ "tap": cmd_tap,
121
+ "hold": cmd_hold,
122
+ "move": cmd_move,
123
+ "combo": cmd_combo,
124
+ "release-all": cmd_release_all,
125
+ }
126
+
127
+
128
+ def main() -> None:
129
+ args: list[str] = sys.argv[1:]
130
+ port: int = DEFAULT_PORT
131
+
132
+ if "--port" in args:
133
+ idx = args.index("--port")
134
+ if idx + 1 >= len(args):
135
+ print("Error: --port requires a value", file=sys.stderr)
136
+ sys.exit(1)
137
+ port = int(args[idx + 1])
138
+ args = args[:idx] + args[idx + 2 :]
139
+
140
+ if not args or args[0] not in COMMANDS:
141
+ cmds = ", ".join(COMMANDS.keys())
142
+ print(
143
+ f"Usage: python3 -m godot_cli_control [--port N] <{cmds}> [args...]",
144
+ file=sys.stderr,
145
+ )
146
+ sys.exit(1)
147
+
148
+ cmd = args[0]
149
+
150
+ async def run() -> None:
151
+ async with GameClient(port=port) as client:
152
+ await COMMANDS[cmd](client, args[1:])
153
+
154
+ asyncio.run(run())
155
+
156
+
157
+ if __name__ == "__main__":
158
+ main()
@@ -0,0 +1,246 @@
1
+ """GameClient - WebSocket client for connecting to Godot GameBridge."""
2
+
3
+ import asyncio
4
+ import base64
5
+ import json
6
+ import logging
7
+ import uuid
8
+ from typing import Any
9
+
10
+ import websockets
11
+ from websockets.asyncio.client import ClientConnection
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class GameClient:
17
+ """WebSocket client that connects to Godot's GameBridge service."""
18
+
19
+ def __init__(self, port: int = 9877) -> None:
20
+ self._port = port
21
+ self._ws: ClientConnection | None = None
22
+ self._pending: dict[str, asyncio.Future[dict]] = {}
23
+ self._listen_task: asyncio.Task | None = None
24
+
25
+ async def __aenter__(self) -> "GameClient":
26
+ await self.connect()
27
+ return self
28
+
29
+ async def __aexit__(self, *_: object) -> None:
30
+ await self.disconnect()
31
+
32
+ async def connect(
33
+ self, retries: int = 10, backoff: float = 1.0, max_wait: float = 3.0
34
+ ) -> None:
35
+ """Connect to GameBridge with exponential backoff retry.
36
+
37
+ 显式 ``proxy=None``:GameBridge 永远跑在 localhost,而 websockets>=14
38
+ 会自动读取 ``all_proxy``/``http_proxy``/``https_proxy`` 环境变量并把
39
+ localhost 连接也塞进 SOCKS/HTTP 代理,导致 TCP 被代理立刻 EOF、
40
+ 抛 ``InvalidMessage: did not receive a valid HTTP response``。用户
41
+ shell 里的 ``all_proxy=socks5://127.0.0.1:7897`` 就是典型触发点;
42
+ 显式传 ``proxy=None`` 比依赖 ``no_proxy`` 顺序更稳(no_proxy 对
43
+ localhost 的匹配规则各库不一致)。
44
+ """
45
+ for attempt in range(retries):
46
+ try:
47
+ self._ws = await websockets.connect(
48
+ f"ws://localhost:{self._port}",
49
+ proxy=None,
50
+ )
51
+ self._listen_task = asyncio.create_task(self._listen())
52
+ logger.info("Connected to GameBridge on port %d", self._port)
53
+ return
54
+ except (
55
+ ConnectionRefusedError,
56
+ OSError,
57
+ websockets.exceptions.InvalidMessage,
58
+ ) as e:
59
+ if attempt < retries - 1:
60
+ wait = min(backoff * (2**attempt), max_wait)
61
+ logger.warning(
62
+ "Connection attempt %d failed: %s. Retrying in %.1fs...",
63
+ attempt + 1, e, wait,
64
+ )
65
+ await asyncio.sleep(wait)
66
+ else:
67
+ raise ConnectionError(
68
+ f"Failed to connect after {retries} attempts"
69
+ ) from e
70
+
71
+ async def disconnect(self) -> None:
72
+ """Disconnect from GameBridge."""
73
+ if self._listen_task:
74
+ self._listen_task.cancel()
75
+ try:
76
+ await self._listen_task
77
+ except asyncio.CancelledError:
78
+ pass
79
+ if self._ws:
80
+ await self._ws.close()
81
+ self._ws = None
82
+ logger.info("Disconnected from GameBridge")
83
+
84
+ async def _listen(self) -> None:
85
+ """Background task that listens for messages from GameBridge.
86
+
87
+ finally 里清空所有 pending future —— 涵盖 ConnectionClosed、
88
+ CancelledError 以及任何其它退出路径,防止调用方的 await 在
89
+ connection 消失后挂死到超时。
90
+ """
91
+ assert self._ws is not None
92
+ close_reason: str | None = None
93
+ try:
94
+ async for raw in self._ws:
95
+ msg = json.loads(raw)
96
+ if "id" in msg:
97
+ req_id = msg["id"]
98
+ if req_id in self._pending:
99
+ self._pending[req_id].set_result(msg)
100
+ del self._pending[req_id]
101
+ except websockets.ConnectionClosed:
102
+ close_reason = "Connection closed by server"
103
+ logger.info(close_reason)
104
+ finally:
105
+ reason = close_reason or "listen task stopped"
106
+ for future in self._pending.values():
107
+ if not future.done():
108
+ future.set_exception(ConnectionError(reason))
109
+ self._pending.clear()
110
+
111
+ async def request(
112
+ self, method: str, params: dict | None = None, timeout: float = 30.0
113
+ ) -> dict:
114
+ """Send a JSON-RPC request and wait for the response."""
115
+ assert self._ws is not None, "Not connected"
116
+ req_id = str(uuid.uuid4())[:8]
117
+ msg = {"id": req_id, "method": method, "params": params or {}}
118
+ future: asyncio.Future[dict] = asyncio.get_running_loop().create_future()
119
+ self._pending[req_id] = future
120
+ await self._ws.send(json.dumps(msg))
121
+ try:
122
+ response = await asyncio.wait_for(future, timeout=timeout)
123
+ except asyncio.TimeoutError:
124
+ self._pending.pop(req_id, None)
125
+ raise
126
+ if "error" in response:
127
+ raise RuntimeError(
128
+ f"GameBridge error: {response['error']['message']}"
129
+ )
130
+ return response.get("result", {})
131
+
132
+ # ---- Low-level API ----
133
+
134
+ async def click(self, path: str) -> dict:
135
+ return await self.request("click", {"path": path})
136
+
137
+ async def get_property(self, path: str, prop: str) -> Any:
138
+ result = await self.request(
139
+ "get_property", {"path": path, "property": prop}
140
+ )
141
+ return result.get("value")
142
+
143
+ async def set_property(self, path: str, prop: str, value: Any) -> dict:
144
+ return await self.request(
145
+ "set_property", {"path": path, "property": prop, "value": value}
146
+ )
147
+
148
+ async def call_method(
149
+ self, path: str, method: str, args: list | None = None
150
+ ) -> Any:
151
+ result = await self.request(
152
+ "call_method",
153
+ {"path": path, "method": method, "args": args or []},
154
+ )
155
+ return result.get("result")
156
+
157
+ async def get_text(self, path: str) -> str:
158
+ result = await self.request("get_text", {"path": path})
159
+ return result.get("text", "")
160
+
161
+ async def node_exists(self, path: str) -> bool:
162
+ result = await self.request("node_exists", {"path": path})
163
+ return result.get("exists", False)
164
+
165
+ async def is_visible(self, path: str) -> bool:
166
+ result = await self.request("is_visible", {"path": path})
167
+ return result.get("visible", False)
168
+
169
+ async def get_children(
170
+ self, path: str, type_filter: str = ""
171
+ ) -> list[dict]:
172
+ result = await self.request(
173
+ "get_children", {"path": path, "type_filter": type_filter}
174
+ )
175
+ return result.get("children", [])
176
+
177
+ async def screenshot(self) -> bytes:
178
+ """Take a screenshot and return PNG bytes."""
179
+ result = await self.request("screenshot")
180
+ return base64.b64decode(result.get("image", ""))
181
+
182
+ async def get_scene_tree(self, depth: int = 5) -> dict:
183
+ return await self.request("get_scene_tree", {"depth": depth})
184
+
185
+ async def wait_for_node(self, path: str, timeout: float = 5.0) -> bool:
186
+ result = await self.request(
187
+ "wait_for_node",
188
+ {"path": path, "timeout": timeout},
189
+ timeout=timeout + 5.0,
190
+ )
191
+ return result.get("found", False)
192
+
193
+ async def wait_game_time(self, seconds: float) -> dict:
194
+ """按 Godot game time 等待 N 秒。
195
+
196
+ Movie Maker (--write-movie) 模式下 wall time 比 game time 慢约 2-3×,
197
+ 客户端 timeout 用 seconds * 3 + 10 安全系数,与 combo() 一致。
198
+ seconds <= 0 时客户端短路返回,不发 RPC。
199
+ """
200
+ if seconds <= 0:
201
+ return {"success": True}
202
+ return await self.request(
203
+ "wait_game_time",
204
+ {"seconds": seconds},
205
+ timeout=seconds * 3.0 + 10.0,
206
+ )
207
+
208
+ # ---- Input simulation API ----
209
+
210
+ async def action_press(self, action: str) -> dict:
211
+ return await self.request("input_action_press", {"action": action})
212
+
213
+ async def action_release(self, action: str) -> dict:
214
+ return await self.request("input_action_release", {"action": action})
215
+
216
+ async def action_tap(self, action: str, duration: float = 0.1) -> dict:
217
+ return await self.request(
218
+ "input_action_tap", {"action": action, "duration": duration}
219
+ )
220
+
221
+ async def hold(self, action: str, duration: float) -> dict:
222
+ return await self.request(
223
+ "input_hold", {"action": action, "duration": duration}
224
+ )
225
+
226
+ async def move(self, x: float, y: float, duration: float) -> dict:
227
+ return await self.request(
228
+ "input_move", {"direction": {"x": x, "y": y}, "duration": duration}
229
+ )
230
+
231
+ async def combo(self, steps: list[dict]) -> dict:
232
+ total = sum(
233
+ s.get("duration", 0) or s.get("wait", 0) for s in steps
234
+ )
235
+ # movie maker (--write-movie) 模式下 Godot 渲染比实时慢(大分辨率 +
236
+ # MJPEG 编码开销),客户端墙钟等待需要 3× 安全系数 + 10s base,
237
+ # 避免 combo 还没在游戏时间跑完就被 TimeoutError 打断。
238
+ return await self.request(
239
+ "input_combo", {"steps": steps}, timeout=total * 3.0 + 10.0
240
+ )
241
+
242
+ async def combo_cancel(self) -> dict:
243
+ return await self.request("input_combo_cancel")
244
+
245
+ async def release_all(self) -> dict:
246
+ return await self.request("input_release_all")
@@ -0,0 +1,61 @@
1
+ """脚本运行器 —— 加载用户脚本并注入 GameBridge。
2
+
3
+ 用法: python3 -m godot_cli_control.runner <script.py> [args...]
4
+
5
+ 用户脚本只需定义 run(bridge) 函数:
6
+
7
+ def run(bridge):
8
+ bridge.click("/root/MainMenu/.../NewGameButton")
9
+ bridge.wait(2)
10
+ bridge.hold("move_right", 1.5)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from .bridge import GameBridge
20
+
21
+
22
+ def main() -> None:
23
+ if len(sys.argv) < 2:
24
+ print("用法: python3 -m godot_cli_control.runner <script.py> [args...]", file=sys.stderr)
25
+ sys.exit(1)
26
+
27
+ script_path = Path(sys.argv[1])
28
+ if not script_path.exists():
29
+ print(f"错误:找不到脚本: {script_path}", file=sys.stderr)
30
+ sys.exit(1)
31
+
32
+ # 解析 --port 参数
33
+ port = 9877
34
+ args = sys.argv[2:]
35
+ if "--port" in args:
36
+ idx = args.index("--port")
37
+ port = int(args[idx + 1])
38
+ args = args[:idx] + args[idx + 2:]
39
+
40
+ # 动态加载用户脚本
41
+ spec = importlib.util.spec_from_file_location("user_script", script_path)
42
+ if spec is None or spec.loader is None:
43
+ print(f"错误:无法加载脚本: {script_path}", file=sys.stderr)
44
+ sys.exit(1)
45
+ module = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(module)
47
+
48
+ if not hasattr(module, "run"):
49
+ print(f"错误:脚本 {script_path} 中缺少 run(bridge) 函数", file=sys.stderr)
50
+ sys.exit(1)
51
+
52
+ # 创建 bridge 并执行
53
+ bridge = GameBridge(port=port)
54
+ try:
55
+ module.run(bridge)
56
+ finally:
57
+ bridge.close()
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
@@ -0,0 +1,21 @@
1
+ [project]
2
+ name = "godot-cli-control"
3
+ version = "0.1.0"
4
+ description = "WebSocket bridge for headless / scripted control of Godot scenes."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ license = { text = "MIT" }
8
+ authors = [{ name = "kesar" }]
9
+ dependencies = [
10
+ "websockets>=14,<16",
11
+ ]
12
+
13
+ [project.scripts]
14
+ godot-cli-control = "godot_cli_control.cli:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["godot_cli_control"]
File without changes
@@ -0,0 +1,162 @@
1
+ """L2 单元测试:覆盖 GameClient 的网络异常路径(L1 dogfooding 测不到的)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from unittest.mock import AsyncMock, patch
8
+
9
+ import pytest
10
+ import websockets
11
+
12
+ from godot_cli_control.client import GameClient
13
+
14
+
15
+ # ---- Test 1: proxy=None 显式传给 websockets.connect ----
16
+
17
+ @pytest.mark.asyncio
18
+ async def test_connect_passes_proxy_none_explicitly() -> None:
19
+ """SOCKS 代理防御:connect() 必须显式传 proxy=None。
20
+
21
+ 防 regression:未来 maintainer 改回 default 会让 all_proxy=socks5://...
22
+ 用户的 localhost 连接被代理拦截,client.py docstring 写过这个坑。
23
+ """
24
+ fake_ws = AsyncMock()
25
+ with patch(
26
+ "godot_cli_control.client.websockets.connect",
27
+ new=AsyncMock(return_value=fake_ws),
28
+ ) as mock_connect:
29
+ client = GameClient(port=9999)
30
+ try:
31
+ await client.connect(retries=1)
32
+ finally:
33
+ # 防 listen task 泄漏
34
+ if client._listen_task:
35
+ client._listen_task.cancel()
36
+ # 断言 connect 被调用且 proxy=None 在 kwargs 中
37
+ assert mock_connect.called
38
+ _, kwargs = mock_connect.call_args
39
+ assert kwargs.get("proxy") is None, \
40
+ "GameClient.connect() must pass proxy=None to websockets.connect"
41
+
42
+
43
+ # ---- Test 2: connect retry 行为 ----
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_connect_retries_on_connection_refused() -> None:
47
+ """前 N 次 ConnectionRefused 后第 N+1 次成功 → connect() 应该返回成功。"""
48
+ fake_ws = AsyncMock()
49
+ side_effects = [ConnectionRefusedError("nope"), ConnectionRefusedError("nope"), fake_ws]
50
+ with patch(
51
+ "godot_cli_control.client.websockets.connect",
52
+ new=AsyncMock(side_effect=side_effects),
53
+ ) as mock_connect:
54
+ client = GameClient(port=9999)
55
+ try:
56
+ await client.connect(retries=5, backoff=0.01, max_wait=0.01)
57
+ finally:
58
+ if client._listen_task:
59
+ client._listen_task.cancel()
60
+ assert mock_connect.call_count == 3
61
+
62
+
63
+ # ---- Test 3: connect retry 全失败 → ConnectionError ----
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_connect_raises_after_retries_exhausted() -> None:
67
+ """所有 retry 失败应该抛 ConnectionError(带原异常 from clause)。"""
68
+ with patch(
69
+ "godot_cli_control.client.websockets.connect",
70
+ new=AsyncMock(side_effect=ConnectionRefusedError("always nope")),
71
+ ):
72
+ client = GameClient(port=9999)
73
+ with pytest.raises(ConnectionError, match="Failed to connect after"):
74
+ await client.connect(retries=2, backoff=0.01, max_wait=0.01)
75
+
76
+
77
+ # ---- Test 4: _listen 退出时清空 pending futures(避免 await 挂死) ----
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_listen_clears_pending_on_disconnect() -> None:
81
+ """_listen 退出(连接关闭)时所有 pending future 必须被 set_exception,
82
+ 否则调用方的 await client.request() 会永久挂死到 timeout。"""
83
+ fake_ws = AsyncMock()
84
+
85
+ async def fake_iter():
86
+ # iterator 立即结束(模拟连接关闭)
87
+ return
88
+ yield # noqa: unreachable
89
+
90
+ fake_ws.__aiter__ = lambda self: fake_iter()
91
+ fake_ws.close = AsyncMock()
92
+
93
+ with patch(
94
+ "godot_cli_control.client.websockets.connect",
95
+ new=AsyncMock(return_value=fake_ws),
96
+ ):
97
+ client = GameClient(port=9999)
98
+ await client.connect(retries=1)
99
+
100
+ # 注入一个 pending future
101
+ loop = asyncio.get_running_loop()
102
+ future: asyncio.Future = loop.create_future()
103
+ client._pending["fake_id"] = future
104
+
105
+ # 等 listen task 自然退出(iterator 已空)
106
+ if client._listen_task:
107
+ await client._listen_task
108
+
109
+ # pending 应该被清空 + future 应该被 set_exception
110
+ assert "fake_id" not in client._pending
111
+ assert future.done()
112
+ with pytest.raises(ConnectionError):
113
+ future.result()
114
+
115
+
116
+ # ---- Test 5: request() timeout 时清理 _pending ----
117
+
118
+ @pytest.mark.asyncio
119
+ async def test_request_timeout_cleans_pending() -> None:
120
+ """request() 超时不应该在 _pending 里留垃圾 entry。"""
121
+ fake_ws = AsyncMock()
122
+
123
+ async def hang_iter():
124
+ # async iterator 永久挂起:不会 yield 也不会结束,
125
+ # 模拟"连接活着但不回响应"——request() 必定走 timeout 路径。
126
+ await asyncio.Event().wait()
127
+ yield # noqa: unreachable
128
+
129
+ fake_ws.__aiter__ = lambda self: hang_iter()
130
+ fake_ws.send = AsyncMock()
131
+ fake_ws.close = AsyncMock()
132
+
133
+ with patch(
134
+ "godot_cli_control.client.websockets.connect",
135
+ new=AsyncMock(return_value=fake_ws),
136
+ ):
137
+ client = GameClient(port=9999)
138
+ await client.connect(retries=1)
139
+ try:
140
+ with pytest.raises(asyncio.TimeoutError):
141
+ await client.request("nonexistent_method", timeout=0.05)
142
+ # 超时后 _pending 应该不再含这次 request 的 id
143
+ assert len(client._pending) == 0
144
+ finally:
145
+ if client._listen_task:
146
+ client._listen_task.cancel()
147
+ try:
148
+ await client._listen_task
149
+ except asyncio.CancelledError:
150
+ pass
151
+
152
+
153
+ # ---- Test 6(bonus): websockets.connect 有 proxy kwarg(防版本退化) ----
154
+
155
+ def test_websockets_connect_supports_proxy_kwarg() -> None:
156
+ """websockets>=14 才有 proxy= kwarg;如果版本退化测试就 fail。
157
+
158
+ spec §7.3 风险表 grounding:mock 写法依赖此 kwarg 存在。
159
+ """
160
+ sig = inspect.signature(websockets.connect)
161
+ assert "proxy" in sig.parameters, \
162
+ f"websockets.connect missing 'proxy' kwarg; version {websockets.__version__} too old"