godot-cli-control 0.2.3__tar.gz → 0.2.4__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.3 → godot_cli_control-0.2.4}/.gitignore +2 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/PKG-INFO +7 -5
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/game_bridge.gd +71 -8
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +61 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/README.md +6 -4
- godot_cli_control-0.2.4/python/godot_cli_control/_duration.py +18 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/_version.py +2 -2
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/cli.py +159 -9
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/daemon.py +216 -13
- godot_cli_control-0.2.4/python/godot_cli_control/registry.py +135 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/templates/skill/SKILL.md +2 -2
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_cli.py +291 -13
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_daemon.py +535 -1
- godot_cli_control-0.2.4/python/tests/test_duration.py +36 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_pytest_plugin.py +5 -1
- godot_cli_control-0.2.4/python/tests/test_registry.py +74 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/LICENSE +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/CHANGELOG.md +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/LICENSE +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/README.md +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/plugin.cfg +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/plugin.gd +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/plugin.gd.uid +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/run_gut.sh +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/pyproject.toml +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/__init__.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/__main__.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/bridge.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/client.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/init_cmd.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/pytest_plugin.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/runner.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/skills_install.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/templates/__init__.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/templates/skill/__init__.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/__init__.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_bridge.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_cli_helpers.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_client.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_init.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_runner.py +0 -0
- {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_skills_install.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: godot-cli-control
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: WebSocket bridge for headless / scripted control of Godot scenes.
|
|
5
5
|
Author: kesar
|
|
6
6
|
License: MIT
|
|
@@ -53,7 +53,8 @@ import asyncio
|
|
|
53
53
|
from godot_cli_control import GameClient
|
|
54
54
|
|
|
55
55
|
async def main():
|
|
56
|
-
|
|
56
|
+
# Omitting port lets GameClient auto-discover from .cli_control/port (written by daemon start)
|
|
57
|
+
async with GameClient() as client:
|
|
57
58
|
tree = await client.get_scene_tree(depth=3)
|
|
58
59
|
await client.click("/root/MyScene/Button")
|
|
59
60
|
await client.action_press("jump")
|
|
@@ -101,7 +102,7 @@ def test_jump(godot_daemon, bridge):
|
|
|
101
102
|
CLI options:
|
|
102
103
|
|
|
103
104
|
```
|
|
104
|
-
--godot-cli-port=N # GameBridge port (default
|
|
105
|
+
--godot-cli-port=N # GameBridge port (default: read from .cli_control/port)
|
|
105
106
|
--godot-cli-no-headless # open a real Godot window
|
|
106
107
|
--godot-cli-project-root=DIR # default: pytest rootdir
|
|
107
108
|
```
|
|
@@ -113,9 +114,10 @@ The CLI is the canonical surface — every `GameClient` method has a one-line eq
|
|
|
113
114
|
```bash
|
|
114
115
|
# Lifecycle
|
|
115
116
|
godot-cli-control init [--path DIR] [--force]
|
|
116
|
-
godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N]
|
|
117
|
-
godot-cli-control daemon stop
|
|
117
|
+
godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N --idle-timeout 30m]
|
|
118
|
+
godot-cli-control daemon stop [--all | --project PATH]
|
|
118
119
|
godot-cli-control daemon status
|
|
120
|
+
godot-cli-control daemon ls # list running daemons across all projects
|
|
119
121
|
godot-cli-control run <script.py> [--headless ...]
|
|
120
122
|
|
|
121
123
|
# Read
|
{godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/game_bridge.gd
RENAMED
|
@@ -11,6 +11,14 @@ var _tcp_server: TCPServer = TCPServer.new()
|
|
|
11
11
|
var _active_peer: WebSocketPeer = null
|
|
12
12
|
var _active_stream: StreamPeerTCP = null
|
|
13
13
|
var _port: int = DEFAULT_PORT
|
|
14
|
+
var _idle_timeout_secs: int = 0
|
|
15
|
+
var _last_activity_ms: int = 0
|
|
16
|
+
# 正在处理中的请求数。> 0 表示 daemon 没闲着,idle 检查必须放过 —— 否则
|
|
17
|
+
# 一条 wait_game_time(3600) 会被 30m idle-timeout 半路打断,客户端拿不到响应。
|
|
18
|
+
# 入:消息通过参数校验、即将派发到 handler 时 +1。
|
|
19
|
+
# 出:_dispatch_result(sync/async/async_with_id 三条路径的最终响应点)-1。
|
|
20
|
+
# 校验失败的 _send_error 不计数(没进过 handler)。
|
|
21
|
+
var _in_flight: int = 0
|
|
14
22
|
var _outbound_buffer_size: int = DEFAULT_OUTBOUND_BUFFER_MB * 1024 * 1024
|
|
15
23
|
var _low_level_api: LowLevelApi = null
|
|
16
24
|
var _input_sim_api: InputSimulationApi = null
|
|
@@ -23,7 +31,12 @@ var _methods: Dictionary = {}
|
|
|
23
31
|
|
|
24
32
|
func _ready() -> void:
|
|
25
33
|
if not _should_activate():
|
|
26
|
-
print(
|
|
34
|
+
print(
|
|
35
|
+
(
|
|
36
|
+
"[Godot CLI Control] inactive — pass --cli-control, set GODOT_CLI_CONTROL=1, or enable %s in Project Settings (debug build only)"
|
|
37
|
+
% SETTING_AUTO_ENABLE
|
|
38
|
+
)
|
|
39
|
+
)
|
|
27
40
|
queue_free()
|
|
28
41
|
return
|
|
29
42
|
# 即使 SceneTree 暂停也要继续运行
|
|
@@ -39,7 +52,9 @@ func _ready() -> void:
|
|
|
39
52
|
# 构建统一方法注册表
|
|
40
53
|
_register_methods()
|
|
41
54
|
# 缓存 outbound buffer 大小(ProjectSettings 可覆盖默认 10MB,至少 1MB)
|
|
42
|
-
var mb: int = int(
|
|
55
|
+
var mb: int = int(
|
|
56
|
+
ProjectSettings.get_setting(SETTING_OUTBOUND_BUFFER_MB, DEFAULT_OUTBOUND_BUFFER_MB)
|
|
57
|
+
)
|
|
43
58
|
_outbound_buffer_size = max(1, mb) * 1024 * 1024
|
|
44
59
|
# 启动 TCP 服务器
|
|
45
60
|
_port = _parse_port_from_args()
|
|
@@ -54,6 +69,16 @@ func _ready() -> void:
|
|
|
54
69
|
printerr(msg)
|
|
55
70
|
return
|
|
56
71
|
print("GameBridge: Listening on ws://127.0.0.1:%d" % _port)
|
|
72
|
+
_idle_timeout_secs = _parse_idle_timeout_from_args()
|
|
73
|
+
_last_activity_ms = Time.get_ticks_msec()
|
|
74
|
+
if _idle_timeout_secs > 0:
|
|
75
|
+
var t: Timer = Timer.new()
|
|
76
|
+
t.wait_time = 1.0
|
|
77
|
+
t.autostart = true
|
|
78
|
+
t.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
79
|
+
t.timeout.connect(_check_idle)
|
|
80
|
+
add_child(t)
|
|
81
|
+
print("GameBridge: idle-timeout %ds enabled" % _idle_timeout_secs)
|
|
57
82
|
|
|
58
83
|
|
|
59
84
|
func _process(_delta: float) -> void:
|
|
@@ -118,14 +143,22 @@ func _register_methods() -> void:
|
|
|
118
143
|
_methods["wait_game_time"] = {"callable": _low_level_api.wait_game_time_async, "kind": "async"}
|
|
119
144
|
_methods["screenshot"] = {"callable": _wrap_screenshot, "kind": "async"}
|
|
120
145
|
# 输入模拟(同步)
|
|
121
|
-
_methods["input_action_press"] = {
|
|
122
|
-
|
|
146
|
+
_methods["input_action_press"] = {
|
|
147
|
+
"callable": _input_sim_api.handle_action_press, "kind": "sync"
|
|
148
|
+
}
|
|
149
|
+
_methods["input_action_release"] = {
|
|
150
|
+
"callable": _input_sim_api.handle_action_release, "kind": "sync"
|
|
151
|
+
}
|
|
123
152
|
_methods["input_action_tap"] = {"callable": _input_sim_api.handle_action_tap, "kind": "sync"}
|
|
124
153
|
_methods["input_get_pressed"] = {"callable": _input_sim_api.handle_get_pressed, "kind": "sync"}
|
|
125
|
-
_methods["list_input_actions"] = {
|
|
154
|
+
_methods["list_input_actions"] = {
|
|
155
|
+
"callable": _input_sim_api.handle_list_input_actions, "kind": "sync"
|
|
156
|
+
}
|
|
126
157
|
_methods["input_hold"] = {"callable": _input_sim_api.handle_hold, "kind": "sync"}
|
|
127
158
|
_methods["input_release_all"] = {"callable": _input_sim_api.handle_release_all, "kind": "sync"}
|
|
128
|
-
_methods["input_combo_cancel"] = {
|
|
159
|
+
_methods["input_combo_cancel"] = {
|
|
160
|
+
"callable": _input_sim_api.handle_combo_cancel, "kind": "sync"
|
|
161
|
+
}
|
|
129
162
|
# 输入模拟(async_with_id:handler 自行通过 _on_async_response 回响)
|
|
130
163
|
_methods["input_combo"] = {"callable": _input_sim_api.handle_combo, "kind": "async_with_id"}
|
|
131
164
|
|
|
@@ -136,6 +169,7 @@ func _wrap_screenshot(_params: Dictionary) -> Dictionary:
|
|
|
136
169
|
|
|
137
170
|
|
|
138
171
|
func _handle_message(raw: String) -> void:
|
|
172
|
+
_last_activity_ms = Time.get_ticks_msec()
|
|
139
173
|
var parsed: Variant = JSON.parse_string(raw)
|
|
140
174
|
if parsed == null or not parsed is Dictionary:
|
|
141
175
|
_send_error("", -32600, "Invalid JSON")
|
|
@@ -170,6 +204,7 @@ func _handle_message(raw: String) -> void:
|
|
|
170
204
|
var entry: Dictionary = _methods[method] as Dictionary
|
|
171
205
|
var handler: Callable = entry["callable"] as Callable
|
|
172
206
|
var kind: String = entry["kind"] as String
|
|
207
|
+
_in_flight += 1
|
|
173
208
|
match kind:
|
|
174
209
|
"sync":
|
|
175
210
|
var result: Dictionary = handler.call(params)
|
|
@@ -186,12 +221,14 @@ func _run_async(id: String, handler: Callable, params: Dictionary) -> void:
|
|
|
186
221
|
# 客户端 await 挂死到 timeout。先收原 Variant 自己 type-check。
|
|
187
222
|
var raw: Variant = await handler.call(params)
|
|
188
223
|
if not raw is Dictionary:
|
|
224
|
+
_in_flight = max(0, _in_flight - 1)
|
|
189
225
|
_send_error(id, -32603, "internal: async handler returned non-dict")
|
|
190
226
|
return
|
|
191
227
|
_dispatch_result(id, raw as Dictionary)
|
|
192
228
|
|
|
193
229
|
|
|
194
230
|
func _dispatch_result(id: String, result: Dictionary) -> void:
|
|
231
|
+
_in_flight = max(0, _in_flight - 1)
|
|
195
232
|
if result.has("error"):
|
|
196
233
|
var err: Dictionary = result["error"] as Dictionary
|
|
197
234
|
_send_error(id, err["code"] as int, err["message"] as String)
|
|
@@ -253,9 +290,35 @@ func _parse_port_from_args() -> int:
|
|
|
253
290
|
var port: int = parts[1].to_int()
|
|
254
291
|
if port < 1 or port > 65535:
|
|
255
292
|
push_warning(
|
|
256
|
-
|
|
257
|
-
|
|
293
|
+
(
|
|
294
|
+
"GameBridge: Port %d out of range [1, 65535], falling back to %d"
|
|
295
|
+
% [port, DEFAULT_PORT]
|
|
296
|
+
)
|
|
258
297
|
)
|
|
259
298
|
return DEFAULT_PORT
|
|
260
299
|
return port
|
|
261
300
|
return DEFAULT_PORT
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
func _parse_idle_timeout_from_args() -> int:
|
|
304
|
+
for arg: String in OS.get_cmdline_args():
|
|
305
|
+
if arg.begins_with("--game-bridge-idle-timeout="):
|
|
306
|
+
var parts: PackedStringArray = arg.split("=", false, 1)
|
|
307
|
+
if parts.size() != 2 or not parts[1].is_valid_int():
|
|
308
|
+
push_warning("GameBridge: Invalid idle-timeout %s, disabling" % arg)
|
|
309
|
+
return 0
|
|
310
|
+
var secs: int = parts[1].to_int()
|
|
311
|
+
return secs if secs > 0 else 0
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
func _check_idle() -> void:
|
|
316
|
+
# 有正在处理的请求 → daemon 没闲着;把活动戳推到现在,等所有请求落地后
|
|
317
|
+
# 再开始计时。否则一个 wait_game_time(idle_timeout+1) 就会被半路 quit。
|
|
318
|
+
if _in_flight > 0:
|
|
319
|
+
_last_activity_ms = Time.get_ticks_msec()
|
|
320
|
+
return
|
|
321
|
+
var idle_ms: int = Time.get_ticks_msec() - _last_activity_ms
|
|
322
|
+
if idle_ms / 1000 >= _idle_timeout_secs:
|
|
323
|
+
print("GameBridge: idle for %ds, shutting down" % (idle_ms / 1000))
|
|
324
|
+
get_tree().quit()
|
|
@@ -344,3 +344,64 @@ func test_sync_input_action_press_routes_to_input_sim_api() -> void:
|
|
|
344
344
|
assert_does_not_have(f, "error")
|
|
345
345
|
assert_eq(_input.press_calls.size(), 1)
|
|
346
346
|
assert_eq(str(_input.press_calls[0].action), "jump")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ── idle-timeout / in-flight 计数 ─────────────────────────────────
|
|
350
|
+
# 防止回归:长操作(>idle_timeout 的 wait_game_time / combo)期间 _check_idle
|
|
351
|
+
# 不能 quit,否则客户端拿不到响应。
|
|
352
|
+
|
|
353
|
+
func test_in_flight_starts_at_zero() -> void:
|
|
354
|
+
assert_eq(_bridge._in_flight, 0)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
func test_sync_dispatch_returns_in_flight_to_zero() -> void:
|
|
358
|
+
_send('{"id": "s1", "method": "click", "params": {}}')
|
|
359
|
+
assert_eq(_bridge._in_flight, 0, "sync 派发完成后 _in_flight 必须归零")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
func test_async_dispatch_returns_in_flight_to_zero() -> void:
|
|
363
|
+
_send('{"id": "a1", "method": "wait_for_node", "params": {"path": "/X", "timeout": 1.0}}')
|
|
364
|
+
# stub 在 await get_tree().process_frame 期间 _in_flight 应保持 1
|
|
365
|
+
assert_eq(_bridge._in_flight, 1, "async 等待期间 _in_flight 应为 1")
|
|
366
|
+
await get_tree().process_frame
|
|
367
|
+
await get_tree().process_frame
|
|
368
|
+
assert_eq(_bridge._in_flight, 0, "async 派发完成后 _in_flight 必须归零")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
func test_async_with_id_keeps_in_flight_until_callback() -> void:
|
|
372
|
+
# 关键场景:input_combo 长动作(实际可能 5s+)期间,daemon 不能因 idle quit
|
|
373
|
+
_send('{"id": "c1", "method": "input_combo", "params": {"steps": []}}')
|
|
374
|
+
assert_eq(_bridge._in_flight, 1, "async_with_id handler 未回响前 _in_flight 应为 1")
|
|
375
|
+
_input.finish_combo(0, {"success": true})
|
|
376
|
+
assert_eq(_bridge._in_flight, 0, "回调后 _in_flight 必须归零")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
func test_async_handler_returning_non_dict_decrements_in_flight() -> void:
|
|
380
|
+
# type-guard 路径也必须减计数,否则 handler bug 会让 daemon 永远不 idle
|
|
381
|
+
_bridge._methods["wait_for_node"] = {
|
|
382
|
+
"callable": _async_returning_null,
|
|
383
|
+
"kind": "async",
|
|
384
|
+
}
|
|
385
|
+
_send('{"id": "wn", "method": "wait_for_node", "params": {}}')
|
|
386
|
+
await get_tree().process_frame
|
|
387
|
+
await get_tree().process_frame
|
|
388
|
+
assert_eq(_bridge._in_flight, 0, "非 dict 错误路径也必须把 _in_flight 减回 0")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
func test_validation_failure_does_not_increment_in_flight() -> void:
|
|
392
|
+
# Invalid JSON / 协议错没进过 handler,不应碰计数
|
|
393
|
+
_send("not json")
|
|
394
|
+
_send('{"id": 42, "method": "click"}') # id 非串
|
|
395
|
+
_send('{"id": "x", "method": "no_such"}') # 未知方法
|
|
396
|
+
assert_eq(_bridge._in_flight, 0, "校验失败路径不应增 _in_flight")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
func test_check_idle_resets_activity_when_busy() -> void:
|
|
400
|
+
# _check_idle 在 _in_flight > 0 时必须把活动戳推到现在 —— 否则一个跨越
|
|
401
|
+
# 整个 idle_timeout 的长操作会被 quit。
|
|
402
|
+
_bridge._idle_timeout_secs = 1
|
|
403
|
+
_bridge._in_flight = 1
|
|
404
|
+
_bridge._last_activity_ms = Time.get_ticks_msec() - 60_000 # 装作 60s 前
|
|
405
|
+
_bridge._check_idle()
|
|
406
|
+
var now: int = Time.get_ticks_msec()
|
|
407
|
+
assert_almost_eq(_bridge._last_activity_ms, now, 200, "busy 时 _check_idle 必须把活动戳推到 ~now")
|
|
@@ -36,7 +36,8 @@ import asyncio
|
|
|
36
36
|
from godot_cli_control import GameClient
|
|
37
37
|
|
|
38
38
|
async def main():
|
|
39
|
-
|
|
39
|
+
# Omitting port lets GameClient auto-discover from .cli_control/port (written by daemon start)
|
|
40
|
+
async with GameClient() as client:
|
|
40
41
|
tree = await client.get_scene_tree(depth=3)
|
|
41
42
|
await client.click("/root/MyScene/Button")
|
|
42
43
|
await client.action_press("jump")
|
|
@@ -84,7 +85,7 @@ def test_jump(godot_daemon, bridge):
|
|
|
84
85
|
CLI options:
|
|
85
86
|
|
|
86
87
|
```
|
|
87
|
-
--godot-cli-port=N # GameBridge port (default
|
|
88
|
+
--godot-cli-port=N # GameBridge port (default: read from .cli_control/port)
|
|
88
89
|
--godot-cli-no-headless # open a real Godot window
|
|
89
90
|
--godot-cli-project-root=DIR # default: pytest rootdir
|
|
90
91
|
```
|
|
@@ -96,9 +97,10 @@ The CLI is the canonical surface — every `GameClient` method has a one-line eq
|
|
|
96
97
|
```bash
|
|
97
98
|
# Lifecycle
|
|
98
99
|
godot-cli-control init [--path DIR] [--force]
|
|
99
|
-
godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N]
|
|
100
|
-
godot-cli-control daemon stop
|
|
100
|
+
godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N --idle-timeout 30m]
|
|
101
|
+
godot-cli-control daemon stop [--all | --project PATH]
|
|
101
102
|
godot-cli-control daemon status
|
|
103
|
+
godot-cli-control daemon ls # list running daemons across all projects
|
|
102
104
|
godot-cli-control run <script.py> [--headless ...]
|
|
103
105
|
|
|
104
106
|
# Read
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Parse human-friendly duration strings (e.g. "30m", "2h", "90s") to seconds."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
# 不允许数字与单位之间留空格 —— 与帮助文本「30m / 2h / 90s」保持一致,避免
|
|
7
|
+
# 既文档教 30m 又静默接受 "30 m" 这种半通不通的输入。两端 \s* 仍保留,方便
|
|
8
|
+
# 用户从 shell 复制带尾换行的字串。
|
|
9
|
+
_PATTERN = re.compile(r"^\s*(\d+)([smh]?)\s*$")
|
|
10
|
+
_UNITS = {"": 1, "s": 1, "m": 60, "h": 3600}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_duration(text: str) -> int:
|
|
14
|
+
"""Return total seconds. Bare integer = seconds. 0 means disabled."""
|
|
15
|
+
m = _PATTERN.match(text)
|
|
16
|
+
if not m:
|
|
17
|
+
raise ValueError(f"invalid duration {text!r} (use 30s / 30m / 2h / 0)")
|
|
18
|
+
return int(m.group(1)) * _UNITS[m.group(2)]
|
|
@@ -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.4'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 4)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -42,6 +42,10 @@ OUTPUT_TEXT = "text"
|
|
|
42
42
|
EXIT_OK = 0
|
|
43
43
|
EXIT_RPC_ERROR = 1
|
|
44
44
|
EXIT_INFRA_ERROR = 2 # 连接 / 超时 / 用户输入解析失败
|
|
45
|
+
# `daemon stop --all` 专用:至少一条 stop 失败。不复用 EXIT_INFRA_ERROR(=2) —— 单个项目
|
|
46
|
+
# stop rc=2 是「daemon 已停但 ffmpeg 转码失败」的合法成功旁路;--all 聚合若也用 2,
|
|
47
|
+
# 调用方分不清「全停成功只是某个 transcode 失败」与「真有 daemon 没停掉」。
|
|
48
|
+
EXIT_PARTIAL = 3
|
|
45
49
|
EXIT_USAGE = 64 # 命令组合无效(如 combo 既无文件又无 --steps-json)
|
|
46
50
|
|
|
47
51
|
# RPC 错误统一信封(无论 --json 还是 --text)的连接/超时占位 code。GD 端
|
|
@@ -641,6 +645,13 @@ RPC_BY_NAME: dict[str, RpcSpec] = {s.name: s for s in RPC_SPECS}
|
|
|
641
645
|
|
|
642
646
|
def cmd_daemon_start(ns: argparse.Namespace) -> int:
|
|
643
647
|
from .daemon import Daemon, DaemonError
|
|
648
|
+
from ._duration import parse_duration
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
idle_seconds = parse_duration(getattr(ns, "idle_timeout", "0"))
|
|
652
|
+
except ValueError as e:
|
|
653
|
+
_emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
|
|
654
|
+
return EXIT_USAGE
|
|
644
655
|
|
|
645
656
|
daemon = Daemon(Path.cwd())
|
|
646
657
|
try:
|
|
@@ -650,6 +661,7 @@ def cmd_daemon_start(ns: argparse.Namespace) -> int:
|
|
|
650
661
|
headless=ns.headless,
|
|
651
662
|
fps=ns.fps,
|
|
652
663
|
port=ns.port,
|
|
664
|
+
idle_timeout=idle_seconds,
|
|
653
665
|
)
|
|
654
666
|
except DaemonError as e:
|
|
655
667
|
_emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
|
|
@@ -670,17 +682,64 @@ def cmd_daemon_start(ns: argparse.Namespace) -> int:
|
|
|
670
682
|
|
|
671
683
|
def cmd_daemon_stop(ns: argparse.Namespace) -> int:
|
|
672
684
|
from .daemon import Daemon, DaemonError
|
|
685
|
+
from . import registry
|
|
673
686
|
|
|
674
|
-
|
|
687
|
+
fmt = _output_format(ns)
|
|
688
|
+
|
|
689
|
+
if getattr(ns, "all", False):
|
|
690
|
+
records = registry.list_all()
|
|
691
|
+
if not records:
|
|
692
|
+
if fmt == OUTPUT_JSON:
|
|
693
|
+
_emit_success_payload({"stopped": [], "rc": 0})
|
|
694
|
+
else:
|
|
695
|
+
print("(no running daemons)")
|
|
696
|
+
return EXIT_OK
|
|
697
|
+
results: list[dict[str, Any]] = []
|
|
698
|
+
had_failure = False
|
|
699
|
+
for r in records:
|
|
700
|
+
entry: dict[str, Any] = {
|
|
701
|
+
"project_root": r.project_root,
|
|
702
|
+
"pid": r.pid,
|
|
703
|
+
"port": r.port,
|
|
704
|
+
}
|
|
705
|
+
try:
|
|
706
|
+
rc = Daemon(Path(r.project_root)).stop()
|
|
707
|
+
entry["rc"] = rc
|
|
708
|
+
if fmt != OUTPUT_JSON:
|
|
709
|
+
suffix = f" (rc={rc})" if rc != 0 else ""
|
|
710
|
+
print(f"stopped pid={r.pid} port={r.port} {r.project_root}{suffix}")
|
|
711
|
+
except DaemonError as e:
|
|
712
|
+
entry["rc"] = EXIT_INFRA_ERROR
|
|
713
|
+
entry["error"] = str(e)
|
|
714
|
+
had_failure = True
|
|
715
|
+
# 单条失败不能阻止其余 daemon 收尾
|
|
716
|
+
print(f"[{r.project_root}] {e}", file=sys.stderr)
|
|
717
|
+
results.append(entry)
|
|
718
|
+
# rc 含义:0 = 全部成功;EXIT_PARTIAL = 至少一条 DaemonError。注意单条 stop
|
|
719
|
+
# 返回 2(ffmpeg 转码失败但 daemon 已停)不算"失败",按成功汇总;调用方
|
|
720
|
+
# 想要逐条状态请看 JSON 输出 / 文本里的每行 rc=N 标记。
|
|
721
|
+
rc_total = EXIT_PARTIAL if had_failure else EXIT_OK
|
|
722
|
+
if fmt == OUTPUT_JSON:
|
|
723
|
+
_emit_success_payload({"stopped": results, "rc": rc_total})
|
|
724
|
+
else:
|
|
725
|
+
failed = sum(1 for x in results if "error" in x)
|
|
726
|
+
print(
|
|
727
|
+
f"summary: {len(results) - failed}/{len(results)} stopped"
|
|
728
|
+
+ (f", {failed} failed" if failed else "")
|
|
729
|
+
)
|
|
730
|
+
return rc_total
|
|
731
|
+
|
|
732
|
+
target = (ns.project.resolve() if getattr(ns, "project", None) else Path.cwd())
|
|
733
|
+
daemon = Daemon(target)
|
|
675
734
|
try:
|
|
676
735
|
rc = daemon.stop()
|
|
677
736
|
except DaemonError as e:
|
|
678
737
|
_emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
|
|
679
738
|
return EXIT_INFRA_ERROR
|
|
680
|
-
if
|
|
739
|
+
if fmt == OUTPUT_JSON:
|
|
681
740
|
# rc 0=正常停 / 2=ffmpeg 转码失败但 daemon 已停。两种都算"stopped",
|
|
682
741
|
# 把 rc 透出让 agent 决定要不要 retry transcode。
|
|
683
|
-
_emit_success_payload({"stopped": True, "rc": rc})
|
|
742
|
+
_emit_success_payload({"stopped": True, "rc": rc, "project_root": str(target)})
|
|
684
743
|
return rc
|
|
685
744
|
|
|
686
745
|
|
|
@@ -712,22 +771,82 @@ def cmd_daemon_status(ns: argparse.Namespace) -> int:
|
|
|
712
771
|
f"running pid={pid} port={port if port is not None else '?'}"
|
|
713
772
|
)
|
|
714
773
|
return EXIT_OK
|
|
774
|
+
# Stopped:若上一轮启动留下了 godot.log / last_exit_code,把诊断信息透出来。
|
|
775
|
+
# issue #38 要求 daemon 已退出时直接告诉用户「last exit: <code>, see ...log」,
|
|
776
|
+
# 不让用户再手摸 .cli_control/ 翻文件。
|
|
777
|
+
stopped_payload: dict[str, Any] = {"state": "stopped"}
|
|
778
|
+
if daemon.log_file.exists():
|
|
779
|
+
stopped_payload["last_log"] = str(daemon.log_file)
|
|
780
|
+
last_rc = daemon.read_last_exit_code()
|
|
781
|
+
if last_rc is not None:
|
|
782
|
+
stopped_payload["last_exit_code"] = last_rc
|
|
715
783
|
if fmt == OUTPUT_JSON:
|
|
716
|
-
_emit_success_payload(
|
|
784
|
+
_emit_success_payload(stopped_payload)
|
|
717
785
|
else:
|
|
718
|
-
|
|
786
|
+
hints: list[str] = []
|
|
787
|
+
if "last_exit_code" in stopped_payload:
|
|
788
|
+
hints.append(f"last exit: {stopped_payload['last_exit_code']}")
|
|
789
|
+
if "last_log" in stopped_payload:
|
|
790
|
+
hints.append(f"see {stopped_payload['last_log']}")
|
|
791
|
+
if hints:
|
|
792
|
+
print(f"stopped ({', '.join(hints)})")
|
|
793
|
+
else:
|
|
794
|
+
print("stopped")
|
|
719
795
|
return EXIT_RPC_ERROR
|
|
720
796
|
|
|
721
797
|
|
|
798
|
+
def cmd_daemon_ls(ns: argparse.Namespace) -> int:
|
|
799
|
+
"""跨项目列出运行中的 daemon。
|
|
800
|
+
|
|
801
|
+
扫全局注册表 ~/.local/state/godot-cli-control/daemons/,对每条记录探活。
|
|
802
|
+
死记录会被 list_all 自动清理(连同对应项目的 .cli_control/godot.pid 与 port)。
|
|
803
|
+
JSON 模式:{"ok": true, "result": {"daemons": [...]}}(信封一致)。
|
|
804
|
+
Text 模式:每条一行 `<pid>\t<port>\t<project_root>\t<started_at>`;空时 (no running daemons)。
|
|
805
|
+
"""
|
|
806
|
+
from . import registry
|
|
807
|
+
|
|
808
|
+
fmt = _output_format(ns)
|
|
809
|
+
records = registry.list_all()
|
|
810
|
+
payload = {
|
|
811
|
+
"daemons": [
|
|
812
|
+
{
|
|
813
|
+
"project_root": r.project_root,
|
|
814
|
+
"pid": r.pid,
|
|
815
|
+
"port": r.port,
|
|
816
|
+
"started_at": r.started_at,
|
|
817
|
+
"godot_bin": r.godot_bin,
|
|
818
|
+
"log_path": r.log_path,
|
|
819
|
+
}
|
|
820
|
+
for r in records
|
|
821
|
+
]
|
|
822
|
+
}
|
|
823
|
+
if fmt == OUTPUT_JSON:
|
|
824
|
+
_emit_success_payload(payload)
|
|
825
|
+
else:
|
|
826
|
+
if not records:
|
|
827
|
+
print("(no running daemons)")
|
|
828
|
+
else:
|
|
829
|
+
for r in records:
|
|
830
|
+
print(f"{r.pid}\t{r.port}\t{r.project_root}\t{r.started_at}")
|
|
831
|
+
return EXIT_OK
|
|
832
|
+
|
|
833
|
+
|
|
722
834
|
def cmd_run(ns: argparse.Namespace) -> int:
|
|
723
835
|
"""加载用户脚本(要求定义 ``run(bridge)``),自动启停 daemon。"""
|
|
724
836
|
from .daemon import Daemon, DaemonError
|
|
837
|
+
from ._duration import parse_duration
|
|
725
838
|
|
|
726
839
|
script_path = Path(ns.script)
|
|
727
840
|
if not script_path.exists():
|
|
728
841
|
print(f"错误:找不到脚本: {script_path}", file=sys.stderr)
|
|
729
842
|
return 1
|
|
730
843
|
|
|
844
|
+
try:
|
|
845
|
+
idle_seconds = parse_duration(getattr(ns, "idle_timeout", "0"))
|
|
846
|
+
except ValueError as e:
|
|
847
|
+
print(f"错误:{e}", file=sys.stderr)
|
|
848
|
+
return EXIT_USAGE
|
|
849
|
+
|
|
731
850
|
daemon = Daemon(Path.cwd())
|
|
732
851
|
auto_started = False
|
|
733
852
|
if not daemon.is_running():
|
|
@@ -738,6 +857,7 @@ def cmd_run(ns: argparse.Namespace) -> int:
|
|
|
738
857
|
headless=ns.headless,
|
|
739
858
|
fps=ns.fps,
|
|
740
859
|
port=ns.port,
|
|
860
|
+
idle_timeout=idle_seconds,
|
|
741
861
|
)
|
|
742
862
|
except DaemonError as e:
|
|
743
863
|
print(f"错误:{e}", file=sys.stderr)
|
|
@@ -935,8 +1055,14 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
|
|
|
935
1055
|
p.add_argument(
|
|
936
1056
|
"--port",
|
|
937
1057
|
type=int,
|
|
938
|
-
default=
|
|
939
|
-
help=
|
|
1058
|
+
default=0,
|
|
1059
|
+
help="GameBridge 监听端口(默认 0 = OS 自动分配;写入 .cli_control/port)",
|
|
1060
|
+
)
|
|
1061
|
+
p.add_argument(
|
|
1062
|
+
"--idle-timeout",
|
|
1063
|
+
type=str,
|
|
1064
|
+
default="0",
|
|
1065
|
+
help="空闲超时(如 30m / 2h / 90s / 0=关闭,默认关)。开启后 Godot 端 Timer 自动 quit。",
|
|
940
1066
|
)
|
|
941
1067
|
|
|
942
1068
|
|
|
@@ -1015,10 +1141,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1015
1141
|
)
|
|
1016
1142
|
_add_daemon_flags(start_p)
|
|
1017
1143
|
|
|
1018
|
-
daemon_subs.add_parser(
|
|
1144
|
+
stop_p = daemon_subs.add_parser(
|
|
1019
1145
|
"stop",
|
|
1020
1146
|
help="停止 daemon",
|
|
1021
|
-
description=
|
|
1147
|
+
description=(
|
|
1148
|
+
"停止 daemon。无 flag 时停 cwd 项目;--all 停所有注册的 daemon;"
|
|
1149
|
+
"--project <path> 停指定项目。"
|
|
1150
|
+
),
|
|
1151
|
+
)
|
|
1152
|
+
stop_grp = stop_p.add_mutually_exclusive_group()
|
|
1153
|
+
stop_grp.add_argument(
|
|
1154
|
+
"--all", action="store_true",
|
|
1155
|
+
help="停止注册表中所有运行中的 daemon"
|
|
1156
|
+
)
|
|
1157
|
+
stop_grp.add_argument(
|
|
1158
|
+
"--project", type=Path, default=None,
|
|
1159
|
+
help="停止指定项目根的 daemon(绝对/相对路径均可)"
|
|
1022
1160
|
)
|
|
1023
1161
|
|
|
1024
1162
|
daemon_subs.add_parser(
|
|
@@ -1032,6 +1170,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1032
1170
|
),
|
|
1033
1171
|
)
|
|
1034
1172
|
|
|
1173
|
+
ls_p = daemon_subs.add_parser(
|
|
1174
|
+
"ls",
|
|
1175
|
+
help="列出所有正在运行的 daemon(跨项目)",
|
|
1176
|
+
description=(
|
|
1177
|
+
"扫描全局注册表 ~/.local/state/godot-cli-control/daemons/,"
|
|
1178
|
+
"列出所有探活通过的 daemon。死记录会被自动清理。"
|
|
1179
|
+
),
|
|
1180
|
+
)
|
|
1181
|
+
_add_output_format_flags(ls_p)
|
|
1182
|
+
|
|
1035
1183
|
# run:自动启停 + 跑用户脚本
|
|
1036
1184
|
run_p = subs.add_parser(
|
|
1037
1185
|
"run",
|
|
@@ -1261,6 +1409,8 @@ def main() -> None:
|
|
|
1261
1409
|
sys.exit(cmd_daemon_stop(ns))
|
|
1262
1410
|
if ns.action == "status":
|
|
1263
1411
|
sys.exit(cmd_daemon_status(ns))
|
|
1412
|
+
if ns.action == "ls":
|
|
1413
|
+
sys.exit(cmd_daemon_ls(ns))
|
|
1264
1414
|
parser.error(f"unknown daemon action: {ns.action}")
|
|
1265
1415
|
if ns.cmd == "run":
|
|
1266
1416
|
sys.exit(cmd_run(ns))
|