godot-cli-control 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/PKG-INFO +1 -1
  2. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/CHANGELOG.md +11 -0
  3. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/README.md +2 -2
  4. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/low_level_api.gd +179 -46
  5. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/plugin.cfg +1 -1
  6. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +232 -0
  7. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/_version.py +2 -2
  8. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/templates/skill/SKILL.md +27 -1
  9. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/.gitignore +0 -0
  10. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/LICENSE +0 -0
  11. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/LICENSE +0 -0
  12. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  13. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  14. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/capture_logger.gd +0 -0
  15. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/diagnostics_api.gd +0 -0
  16. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/error_codes.gd +0 -0
  17. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/game_bridge.gd +0 -0
  18. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  19. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  20. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  21. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  22. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/render_api.gd +0 -0
  23. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/scene_api.gd +0 -0
  24. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/time_api.gd +0 -0
  25. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/variant_codec.gd +0 -0
  26. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/bridge/wait_api.gd +0 -0
  27. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/plugin.gd +0 -0
  28. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/plugin.gd.uid +0 -0
  29. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_diagnostics_api.gd +0 -0
  30. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +0 -0
  31. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  32. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_render_api.gd +0 -0
  33. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_scene_api.gd +0 -0
  34. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_time_api.gd +0 -0
  35. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_variant_codec.gd +0 -0
  36. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/gut/test_wait_api.gd +0 -0
  37. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/run_gut.py +0 -0
  38. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  39. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  40. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  41. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/pyproject.toml +0 -0
  42. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/README.md +0 -0
  43. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/__init__.py +0 -0
  44. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/__main__.py +0 -0
  45. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/_duration.py +0 -0
  46. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/bridge.py +0 -0
  47. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/cli.py +0 -0
  48. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/client.py +0 -0
  49. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/daemon.py +0 -0
  50. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/init_cmd.py +0 -0
  51. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/pytest_plugin.py +0 -0
  52. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/registry.py +0 -0
  53. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/runner.py +0 -0
  54. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/skills_install.py +0 -0
  55. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/templates/__init__.py +0 -0
  56. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  57. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/__init__.py +0 -0
  58. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/conftest.py +0 -0
  59. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_bridge.py +0 -0
  60. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_broadcast.py +0 -0
  61. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_cli.py +0 -0
  62. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_cli_helpers.py +0 -0
  63. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_client.py +0 -0
  64. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_daemon.py +0 -0
  65. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_duration.py +0 -0
  66. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_client_direct.py +0 -0
  67. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_diag.py +0 -0
  68. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_example.py +0 -0
  69. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_input.py +0 -0
  70. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_multi_instance.py +0 -0
  71. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_record.py +0 -0
  72. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_render.py +0 -0
  73. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_scene.py +0 -0
  74. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_screenshot_gui.py +0 -0
  75. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_e2e_time.py +0 -0
  76. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_init.py +0 -0
  77. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_issue_fixes.py +0 -0
  78. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_pytest_plugin.py +0 -0
  79. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_registry.py +0 -0
  80. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/python/tests/test_runner.py +0 -0
  81. {godot_cli_control-0.4.0 → godot_cli_control-0.4.2}/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.4.0
3
+ Version: 0.4.2
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Project-URL: Homepage, https://github.com/ClaymanTwinkle/godot-cli-control
6
6
  Project-URL: Repository, https://github.com/ClaymanTwinkle/godot-cli-control
@@ -6,6 +6,17 @@
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.2] - 2026-06-14
10
+
11
+ ### Fixed
12
+ - **`call` 对错类型「标量」实参不再假成功(#167 的标量侧补集)**:#167 只关了 `call` 的 **JSON Array → 复合 Variant** 侧假成功;错类型的标量实参(`set_name 42` 数字→StringName、`set_z_index '"abc"'` String→int 等)此前仍直接喂 `callv`,引擎喷 `Cannot convert argument` 返回 `null`,RPC 却假 `ok:true`/`result:null`——调用方以为方法生效实则没跑(与 #164/#167 同级数据风险,且 README/SKILL 已宣称「typed 方法假成功不再可能」,文档与实现脱节)。现连 daemon 前按方法声明签名对非 Array 实参也做类型守卫:镜像 Godot `Variant::can_convert_strict`,命中安全互转组(数值组 `bool`/`int`/`float`、字符串组 `String`/`StringName`/`NodePath`)透传,跨组(必 `Cannot convert`)`-32602` fail-loud。数值宽化(`float`→`int`)、`bool`→`int`、`String`→`String` 等合法调用零回归;`null` 实参保守透传(不在本 issue 新增拦截)。纯 addon 服务端行为改动,老项目跑一次 `init` 同步。
13
+ - **`plugin.cfg` 版本号不再僵在 `0.1.0`**:此前 addon 的 `plugin.cfg` 一直写死 `version="0.1.0"`,CI 仅在打包 AssetLib zip 时按 tag `sed` 修正,而 pip wheel 在那条 sed 之前就已构建——于是 `pip install godot-cli-control` + `init` 复制进下游项目的 addon、以及直接 clone addon 的用法,都拿到错误的 `0.1.0`(Godot 编辑器插件列表 / AssetLib 元数据里显示)。现把 committed 值对齐到上次发布版本,并让 `release.sh` 在打 tag 前自动同步 `plugin.cfg` 版本号并入库,三条取用路径(git-direct / pip+init / AssetLib)从此一致。纯元数据修正,不影响 CLI 行为(CLI 版本一直来自 hatch-vcs 的 `_version.py`)。
14
+
15
+ ## [0.4.1] - 2026-06-14
16
+
17
+ ### Fixed
18
+ - **#167 `call` 对 typed 参数不再假成功,且复用 `set` 的 Array→复合 Variant coerce**:此前 `handle_call_method` 裸 `node.callv(method, args)`,当方法形参是强类型(`Rect2`/`Vector2`/`Color` 等)而 CLI 传 JSON Array 时,Godot 只喷 `Cannot convert argument ...` 引擎错误并返回 `null`,RPC 仍假 `ok:true`/`result:null`——调用方以为方法生效实则没跑(#164 的 live `WANDER` 演示就栽在这)。GDScript 无 try-catch 抓不到 callv 运行期错误,现改为连 daemon 前按方法声明签名(`get_method_list()`)预校验:① JSON Array 喂复合 Variant 形参时按声明类型 coerce(`call /root/Mob enable_wander '[0,0,640,480]'` → `enable_wander(rect: Rect2)`),复用 `set` 那套 `_coerce_compound_array`(长度/元素错同样 `-32602` fail-loud);② Array 喂标量/Object 形参(callv 必转换失败)→ `-32602` fail-loud;③ arg 数量越界(认可选/默认参数与 vararg)→ `-32602` fail-loud。标量实参(数字/字符串/布尔)与拿不到签名的内建方法保持原样透传,零回归。纯 addon 服务端行为改动,老项目跑一次 `init` 同步。
19
+
9
20
  ## [0.4.0] - 2026-06-12
10
21
 
11
22
  ### Added
@@ -89,7 +89,7 @@ All methods callable via `godot-cli-control <method>` or `from godot_cli_control
89
89
  | `get_property(path, property)` | `await client.get_property("/root/Player", "position")` — returns bare value; CLI `get` returns `{"value": ..., "type"?: ...}` shape |
90
90
  | `get_properties(path, properties)` | `await client.get_properties("/root/Player", ["position", "health"])` — multi-property atomic read; returns `{prop: bare_value, ...}` dict |
91
91
  | `set_property(path, property, value)` | `await client.set_property("/root/Player", "visible", False)` |
92
- | `call_method(path, method, args)` | `await client.call_method("/root/Player", "take_damage", [10])` |
92
+ | `call_method(path, method, args)` | `await client.call_method("/root/Player", "take_damage", [10])`. A JSON array passed for a parameter the method declares as a compound Variant (`Rect2`/`Vector2`/`Color`/…) is coerced from the method signature, same as `set_property` (`call_method("/root/Mob", "enable_wander", [[0,0,640,480]])` → `enable_wander(rect: Rect2)`); arg-count / type mismatches fail loud with `-32602` instead of a silent `ok:true`/`result:null` (#167); a scalar of the wrong type (a string for an `int` param, a number for a `String`/`StringName`) likewise fails loud rather than passing through (#183). |
93
93
  | `emit_signal(path, signal, args)` | `await client.emit_signal("/root/Game", "level_complete", [])` — fire a node signal (test seam). **Disabled by default** (server returns `1015`); daemon must be started with `--allow-emit-signal` (debug-build + localhost + explicit opt-in). `call <node> emit_signal` is always blocked by the method blacklist — use this method. CLI: `emit-signal <path> <signal> [args...]` |
94
94
  | `get_text(path)` | `await client.get_text("/root/UI/Label")` |
95
95
  | `node_exists(path)` | `await client.node_exists("/root/MyScene/Button")` |
@@ -157,7 +157,7 @@ Three numeric ranges share `error.code`; they never overlap, so a single field i
157
157
  | `1016` | server | RESPONSE_TOO_LARGE: a single response exceeded the outbound WebSocket buffer (default 10 MB, `godot_cli_control/outbound_buffer_mb`) and was replaced with this error instead of hanging the call to a `-1002` timeout. Usually a bytes-API `screenshot` of a hiDPI frame — pass a path (daemon writes to disk) or raise the buffer. |
158
158
  | `-32600` | server | Malformed JSON-RPC request |
159
159
  | `-32601` | server | Unknown method name |
160
- | `-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; also out-of-range values like a scene `timeout` outside `(0, 3600]` sent directly via `GameClient`, and `find_nodes` called with no filter at all or with both `text` and `text_contains`) |
160
+ | `-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; `call` typed-arg coercion failure / argument-count mismatch — wrong array length, non-numeric element, a JSON array given to a scalar/Object parameter, a scalar of the wrong type (a string for an `int` param, a number for a `String`/`StringName`), or wrong arg count (#167/#183); or `hold` given `duration ≤ 0` — use `press` for an indefinite hold; also out-of-range values like a scene `timeout` outside `(0, 3600]` sent directly via `GameClient`, and `find_nodes` called with no filter at all or with both `text` and `text_contains`) |
161
161
  | `-1001` | client | Connection failure (daemon not running, port wrong, proxy hijacking localhost) |
162
162
  | `-1002` | client | Timeout waiting for response |
163
163
  | `-1003` | client | CLI usage error (combo missing steps, malformed `--steps-json`, a non-numeric `tap`/`wait-time` arg, a `scene-change` path not starting with `res://`/`uid://`, a `scene-reload`/`scene-change` `--timeout` outside `(0, 3600]`, a `set`/`call` value that fails JSON parsing, script path not found, script missing `run(bridge)`, **multi-instance ambiguity** (≥2 instances running and no `--instance`/`--name` given), **named instance not running** (explicit `--instance nope` but that instance isn't up), **instance port not readable yet** (alive but mid-startup — transient, retry), or `--instance` / `--name` conflict). Always exits **64** (#82 / #111). |
@@ -100,6 +100,29 @@ const _ARRAY_PASSTHROUGH_SAFE_TYPES: Array[int] = [
100
100
  TYPE_PACKED_VECTOR4_ARRAY,
101
101
  ]
102
102
 
103
+ # #167:call 实参传 JSON Array 时,「callv 能正常透传、不会 Cannot-convert 静默失败」的
104
+ # 声明参数类型白名单。命中 = 直接把原 Array 喂 callv;不命中且非可 coerce 的复合 Variant
105
+ # = fail-loud(见 _coerce_call_arg)。入选:untyped(Variant) 形参、Array 形参、Packed*Array
106
+ # (Godot 允许 Array→Packed* 的 call-arg 转换)。
107
+ # **刻意不含** Dictionary / 标量 / Object —— JSON Array → 这些类型 callv 会 "Cannot convert
108
+ # argument" 喷错并返回 null(旧版假 ok:true 的根因),必须拦。注意与 _ARRAY_PASSTHROUGH_SAFE_TYPES
109
+ # 语义不同:那张表是 property set 侧「Object.set 不 silent-corrupt」,这张是 call 侧
110
+ # 「callv 不静默失败」——两套失败模式不同,故不能共用同一张表。
111
+ const _CALL_ARG_ARRAY_OK_TYPES: Array[int] = [
112
+ TYPE_NIL, TYPE_ARRAY,
113
+ TYPE_PACKED_BYTE_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY,
114
+ TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY, TYPE_PACKED_STRING_ARRAY,
115
+ TYPE_PACKED_VECTOR2_ARRAY, TYPE_PACKED_VECTOR3_ARRAY, TYPE_PACKED_COLOR_ARRAY,
116
+ TYPE_PACKED_VECTOR4_ARRAY,
117
+ ]
118
+
119
+ # #183:标量 call 实参「callv 能隐式转换、不静默失败」的互转组,镜像 Godot
120
+ # Variant::can_convert_strict 的数值组 / 字符串组(见 _call_arg_convertible)。组内
121
+ # 任意互转 callv 接受(数值宽化 / 字符串类互通);跨组(如 String→int、数字→StringName)
122
+ # callv 必 "Cannot convert" 喷错返 null —— 旧版假 ok:true 的标量侧根因,必须拦。
123
+ const _NUMERIC_ARG_TYPES: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]
124
+ const _STRINGY_ARG_TYPES: Array[int] = [TYPE_STRING, TYPE_STRING_NAME, TYPE_NODE_PATH]
125
+
103
126
 
104
127
  func _ready() -> void:
105
128
  _property_blacklist = _merge_extra(_property_blacklist, SETTING_PROPERTY_BLACKLIST_EXTRA)
@@ -285,14 +308,39 @@ func handle_set_property(params: Dictionary) -> Dictionary:
285
308
  return {"success": true}
286
309
 
287
310
 
288
- ## JSON Array 按声明类型转成 Variant:Vector2/2i/3/3i/4/4i / Rect2/2i /
289
- ## Color / Plane / Quaternion / AABB / Basis / Transform2D/3D / Projection。
311
+ ## 按属性声明类型把 JSON Array coerce 成对应 Variant(property set 侧入口)。
290
312
  ## 节点没声明该属性:返 {} 表示"沿用原 value"。
291
- ## 声明类型在 match 之外且 _ARRAY_PASSTHROUGH_SAFE_TYPES:返 {"error": ...} fail-loud,
292
- ## 防御未来 Godot 加新 compound Variant silent-corrupt 回归(详见 fallback 注释)。
293
- ## 声明类型在 _ARRAY_PASSTHROUGH_SAFE_TYPES(基本类型 / 集合 / Packed*):返 {} 沿用原 value。
294
- ## 转换失败(长度不对 / 元素非数字):返 {"error": ...} 让调用方 fail-loud。
295
- ## 转换成功:返 {"value": <coerced>}。
313
+ ## 声明类型是复合 Variant:委托 _coerce_compound_array(成功 {"value":...} / 长度·元素错 {"error":...})。
314
+ ## 声明类型非复合:在 _ARRAY_PASSTHROUGH_SAFE_TYPES {} 沿用原 value;否则 fail-loud
315
+ ## (防御未来 Godot 加新 compound Variant silent-corrupt 回归,详见下方 fallback 注释)。
316
+ func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) -> Dictionary:
317
+ var declared_type: int = -1
318
+ for prop_info: Dictionary in node.get_property_list():
319
+ if prop_info["name"] == property:
320
+ declared_type = int(prop_info["type"])
321
+ break
322
+ if declared_type == -1:
323
+ return {} # 动态 / 未声明属性,沿用原 value
324
+ var coerced: Dictionary = _coerce_compound_array(declared_type, arr, property)
325
+ if not coerced.is_empty():
326
+ return coerced # 复合类型命中:{"value": ...} 或 {"error": ...}
327
+ # 防御性 fallback:声明类型不是复合 Variant 也不在已知 passthrough-safe 名单里时,主动
328
+ # fail-loud。目的是防止未来 Godot 加新 compound Variant(且 Object.set 同样 silent-corrupt-
329
+ # on-Array)时重蹈 #52。passthrough-safe = 基本类型 / Object / 集合 / Packed*Array —— 它们
330
+ # 要么 Godot Object.set 自己拒收 Array(写入失败但不 silent),要么本就接受 Array 输入。
331
+ if declared_type in _ARRAY_PASSTHROUGH_SAFE_TYPES:
332
+ return {}
333
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
334
+ "value type mismatch for '%s': Array coercion not implemented for declared type %d. If this is a known-safe passthrough, add it to _ARRAY_PASSTHROUGH_SAFE_TYPES; otherwise add a coerce branch."
335
+ % [property, declared_type])
336
+
337
+
338
+ ## 把 JSON Array 按「复合 Variant 声明类型」转成对应 Variant:Vector2/2i/3/3i/4/4i /
339
+ ## Rect2/2i / Color / Plane / Quaternion / AABB / Basis / Transform2D/3D / Projection。
340
+ ## 命中复合类型 → {"value": <coerced>}(成功)或 {"error": ...}(长度/元素不对,fail-loud);
341
+ ## 声明类型不是复合 Variant → 返 {},由调用方决定 passthrough 还是 fail-loud
342
+ ## (property set 侧 _ARRAY_PASSTHROUGH_SAFE_TYPES;call 实参侧 _CALL_ARG_ARRAY_OK_TYPES)。
343
+ ## `label` 仅用于错误信息(property set 传属性名,call 实参传 "arg N of <method>")。
296
344
  ## *i 变体(Vector2i / Vector3i / Vector4i / Rect2i)允许 float 输入并截断到 int,
297
345
  ## 与 GDScript `Vector2i(1.7, 2.3) → (1, 2)` 构造器行为一致。
298
346
  ##
@@ -303,63 +351,56 @@ func handle_set_property(params: Dictionary) -> Dictionary:
303
351
  ## - Transform3D : [basis 9 axis-vector, origin.xyz] (12 floats)
304
352
  ## - Projection : [xaxis.xyzw, yaxis.xyzw, zaxis.xyzw, waxis.xyzw] (16 floats)
305
353
  ## Quaternion / Plane 的 normal 不会自动归一化 —— 调用方传非单位向量后果自负。
306
- func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) -> Dictionary:
307
- var declared_type: int = -1
308
- for prop_info: Dictionary in node.get_property_list():
309
- if prop_info["name"] == property:
310
- declared_type = int(prop_info["type"])
311
- break
312
- if declared_type == -1:
313
- return {} # 动态 / 未声明属性,沿用原 value
354
+ func _coerce_compound_array(declared_type: int, arr: Array, label: String) -> Dictionary:
314
355
  match declared_type:
315
356
  TYPE_VECTOR2:
316
- return _coerce_numeric_array(arr, [2], "Vector2", property, func(v: Array) -> Variant:
357
+ return _coerce_numeric_array(arr, [2], "Vector2", label, func(v: Array) -> Variant:
317
358
  return Vector2(v[0], v[1]))
318
359
  TYPE_VECTOR2I:
319
- return _coerce_numeric_array(arr, [2], "Vector2i", property, func(v: Array) -> Variant:
360
+ return _coerce_numeric_array(arr, [2], "Vector2i", label, func(v: Array) -> Variant:
320
361
  return Vector2i(int(v[0]), int(v[1])))
321
362
  TYPE_VECTOR3:
322
- return _coerce_numeric_array(arr, [3], "Vector3", property, func(v: Array) -> Variant:
363
+ return _coerce_numeric_array(arr, [3], "Vector3", label, func(v: Array) -> Variant:
323
364
  return Vector3(v[0], v[1], v[2]))
324
365
  TYPE_VECTOR3I:
325
- return _coerce_numeric_array(arr, [3], "Vector3i", property, func(v: Array) -> Variant:
366
+ return _coerce_numeric_array(arr, [3], "Vector3i", label, func(v: Array) -> Variant:
326
367
  return Vector3i(int(v[0]), int(v[1]), int(v[2])))
327
368
  TYPE_VECTOR4:
328
- return _coerce_numeric_array(arr, [4], "Vector4", property, func(v: Array) -> Variant:
369
+ return _coerce_numeric_array(arr, [4], "Vector4", label, func(v: Array) -> Variant:
329
370
  return Vector4(v[0], v[1], v[2], v[3]))
330
371
  TYPE_VECTOR4I:
331
- return _coerce_numeric_array(arr, [4], "Vector4i", property, func(v: Array) -> Variant:
372
+ return _coerce_numeric_array(arr, [4], "Vector4i", label, func(v: Array) -> Variant:
332
373
  return Vector4i(int(v[0]), int(v[1]), int(v[2]), int(v[3])))
333
374
  TYPE_RECT2:
334
- return _coerce_numeric_array(arr, [4], "Rect2", property, func(v: Array) -> Variant:
375
+ return _coerce_numeric_array(arr, [4], "Rect2", label, func(v: Array) -> Variant:
335
376
  return Rect2(v[0], v[1], v[2], v[3]))
336
377
  TYPE_RECT2I:
337
- return _coerce_numeric_array(arr, [4], "Rect2i", property, func(v: Array) -> Variant:
378
+ return _coerce_numeric_array(arr, [4], "Rect2i", label, func(v: Array) -> Variant:
338
379
  return Rect2i(int(v[0]), int(v[1]), int(v[2]), int(v[3])))
339
380
  TYPE_COLOR:
340
381
  # Color 接受 RGB(3)或 RGBA(4)。3-element 时 a 默认 1。
341
- return _coerce_numeric_array(arr, [3, 4], "Color", property, func(v: Array) -> Variant:
382
+ return _coerce_numeric_array(arr, [3, 4], "Color", label, func(v: Array) -> Variant:
342
383
  if v.size() == 3:
343
384
  return Color(v[0], v[1], v[2])
344
385
  return Color(v[0], v[1], v[2], v[3]))
345
386
  TYPE_PLANE:
346
387
  # Plane(normal_x, normal_y, normal_z, d) —— 平面方程系数。
347
388
  # 注意:normal 不会自动归一化;非单位 normal 会让距离 / 投影计算失真。
348
- return _coerce_numeric_array(arr, [4], "Plane", property, func(v: Array) -> Variant:
389
+ return _coerce_numeric_array(arr, [4], "Plane", label, func(v: Array) -> Variant:
349
390
  return Plane(v[0], v[1], v[2], v[3]))
350
391
  TYPE_QUATERNION:
351
392
  # Quaternion(x, y, z, w) —— 注意 w 在末位,与 Godot ctor 一致。
352
393
  # 注意:不会自动归一化;非单位四元数会让旋转 / slerp 失真。
353
- return _coerce_numeric_array(arr, [4], "Quaternion", property, func(v: Array) -> Variant:
394
+ return _coerce_numeric_array(arr, [4], "Quaternion", label, func(v: Array) -> Variant:
354
395
  return Quaternion(v[0], v[1], v[2], v[3]))
355
396
  TYPE_AABB:
356
397
  # AABB(position, size) —— 6 floats: [pos.xyz, size.xyz]
357
- return _coerce_numeric_array(arr, [6], "AABB", property, func(v: Array) -> Variant:
398
+ return _coerce_numeric_array(arr, [6], "AABB", label, func(v: Array) -> Variant:
358
399
  return AABB(Vector3(v[0], v[1], v[2]), Vector3(v[3], v[4], v[5])))
359
400
  TYPE_BASIS:
360
401
  # Basis(x_axis, y_axis, z_axis) —— 9 floats axis-vector 顺序:
361
402
  # v[0..2]=x_axis、v[3..5]=y_axis、v[6..8]=z_axis(每 3 个 = 一个 Basis 轴)。
362
- return _coerce_numeric_array(arr, [9], "Basis", property, func(v: Array) -> Variant:
403
+ return _coerce_numeric_array(arr, [9], "Basis", label, func(v: Array) -> Variant:
363
404
  return Basis(
364
405
  Vector3(v[0], v[1], v[2]),
365
406
  Vector3(v[3], v[4], v[5]),
@@ -367,14 +408,14 @@ func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) ->
367
408
  TYPE_TRANSFORM2D:
368
409
  # Transform2D(x_axis, y_axis, origin) —— 6 floats axis-vector 顺序:
369
410
  # v[0..1]=x_axis、v[2..3]=y_axis、v[4..5]=origin。
370
- return _coerce_numeric_array(arr, [6], "Transform2D", property, func(v: Array) -> Variant:
411
+ return _coerce_numeric_array(arr, [6], "Transform2D", label, func(v: Array) -> Variant:
371
412
  return Transform2D(
372
413
  Vector2(v[0], v[1]),
373
414
  Vector2(v[2], v[3]),
374
415
  Vector2(v[4], v[5])))
375
416
  TYPE_TRANSFORM3D:
376
417
  # Transform3D(basis, origin) —— 12 floats: [basis 9 axis-vector 顺序, origin 3]
377
- return _coerce_numeric_array(arr, [12], "Transform3D", property, func(v: Array) -> Variant:
418
+ return _coerce_numeric_array(arr, [12], "Transform3D", label, func(v: Array) -> Variant:
378
419
  return Transform3D(
379
420
  Basis(
380
421
  Vector3(v[0], v[1], v[2]),
@@ -383,35 +424,28 @@ func _coerce_array_to_declared_type(node: Node, property: String, arr: Array) ->
383
424
  Vector3(v[9], v[10], v[11])))
384
425
  TYPE_PROJECTION:
385
426
  # Projection(x, y, z, w) —— 16 floats axis-vector 顺序:每 4 个 = 一个 Vector4 轴。
386
- return _coerce_numeric_array(arr, [16], "Projection", property, func(v: Array) -> Variant:
427
+ return _coerce_numeric_array(arr, [16], "Projection", label, func(v: Array) -> Variant:
387
428
  return Projection(
388
429
  Vector4(v[0], v[1], v[2], v[3]),
389
430
  Vector4(v[4], v[5], v[6], v[7]),
390
431
  Vector4(v[8], v[9], v[10], v[11]),
391
432
  Vector4(v[12], v[13], v[14], v[15])))
392
- # 防御性 fallback:声明类型不是上面的"复合 Variant"也不在已知 passthrough-safe
393
- # 名单里时,主动 fail-loud。目的是防止未来 Godot 加新 compound Variant(且
394
- # Object.set 同样 silent-corrupt-on-Array)时重蹈 #52。
395
- # passthrough-safe = 基本类型 / Object / 集合 / Packed*Array —— 它们要么 Godot
396
- # Object.set 自己拒收 Array(写入失败但不 silent),要么本就接受 Array 输入。
397
- if declared_type in _ARRAY_PASSTHROUGH_SAFE_TYPES:
398
- return {}
399
- return _err(CliControlErrorCodes.INVALID_PARAMS,
400
- "value type mismatch for '%s': Array coercion not implemented for declared type %d. If this is a known-safe passthrough, add it to _ARRAY_PASSTHROUGH_SAFE_TYPES; otherwise add a coerce branch."
401
- % [property, declared_type])
433
+ # 声明类型不是上面任一复合 Variant → 返 {},由调用方决定 passthrough / fail-loud
434
+ # (property set 侧走 _ARRAY_PASSTHROUGH_SAFE_TYPES;call 实参侧走 _CALL_ARG_ARRAY_OK_TYPES)。
435
+ return {}
402
436
 
403
437
 
404
438
  ## 校验 Array 长度 / 元素全为数字,OK 则调 ctor 构造目标 Variant。
405
439
  ## `expected_lens` 是允许长度列表(Color 接 [3, 4],其他类型固定一个长度)。
406
- func _coerce_numeric_array(arr: Array, expected_lens: Array[int], type_name: String, property: String, ctor: Callable) -> Dictionary:
440
+ func _coerce_numeric_array(arr: Array, expected_lens: Array[int], type_name: String, label: String, ctor: Callable) -> Dictionary:
407
441
  if not arr.size() in expected_lens:
408
442
  return _err(CliControlErrorCodes.INVALID_PARAMS,
409
443
  "value type mismatch for '%s': expected %s as numeric array of length %s, got length %d"
410
- % [property, type_name, _format_length_list(expected_lens), arr.size()])
444
+ % [label, type_name, _format_length_list(expected_lens), arr.size()])
411
445
  if not _is_all_numeric(arr):
412
446
  return _err(CliControlErrorCodes.INVALID_PARAMS,
413
447
  "value type mismatch for '%s': expected %s as numeric array, got non-numeric element"
414
- % [property, type_name])
448
+ % [label, type_name])
415
449
  return {"value": ctor.call(arr)}
416
450
 
417
451
 
@@ -441,11 +475,110 @@ func handle_call_method(params: Dictionary) -> Dictionary:
441
475
  if not node.has_method(method):
442
476
  return _err(CliControlErrorCodes.METHOD_NOT_FOUND, "Method not found: %s" % method)
443
477
  var args: Array = params.get("args", []) as Array
444
- # 注意:GDScript 没有 try-catch,callv 参数不匹配会产生引擎错误而非可捕获异常
445
- var result: Variant = node.callv(method, args)
478
+ # #167:GDScript try-catch,callv 参数不匹配只会喷引擎错误("Cannot convert
479
+ # argument ...")并返回 null,RPC 仍假 ok:true / result:null —— 调用方以为方法生效
480
+ # 实则没跑(#164 的 live WANDER 演示就栽在这)。连 daemon 前先按方法声明签名
481
+ # (get_method_list)做 arg 数量校验 + Array→复合 Variant coerce + 类型守卫,对不上
482
+ # 即 fail-loud,绝不放假阳性过去。
483
+ var prepared: Dictionary = _prepare_call_args(node, method, args)
484
+ if prepared.has("error"):
485
+ return prepared
486
+ var result: Variant = node.callv(method, prepared["args"] as Array)
446
487
  return {"result": result}
447
488
 
448
489
 
490
+ ## 按方法声明签名校验 + coerce 实参(#167)。返回 {"args": <coerced Array>}(可直接喂 callv)
491
+ ## 或 {"error": ...}(fail-loud)。拿不到签名(has_method 真但 get_method_list 无此条目——
492
+ ## 极少数引擎内建)→ 原样透传保旧行为(无签名可校验,不强行拦)。
493
+ func _prepare_call_args(node: Node, method: String, args: Array) -> Dictionary:
494
+ var method_info: Dictionary = _find_method_info(node, method)
495
+ if method_info.is_empty():
496
+ return {"args": args}
497
+ var declared_args: Array = method_info.get("args", []) as Array
498
+ var default_args: Array = method_info.get("default_args", []) as Array
499
+ var is_vararg: bool = (int(method_info.get("flags", 0)) & METHOD_FLAG_VARARG) != 0
500
+ # default_args 是尾部可选参数的默认值 → 必填数 = 声明数 − 默认值数。
501
+ var required: int = declared_args.size() - default_args.size()
502
+ if args.size() < required:
503
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
504
+ "argument count mismatch for '%s': expected at least %d, got %d"
505
+ % [method, required, args.size()])
506
+ # vararg 方法(rpc / 内建变参)尾部可接任意多实参 → 只校验下界,不卡上界。
507
+ if not is_vararg and args.size() > declared_args.size():
508
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
509
+ "argument count mismatch for '%s': expected at most %d, got %d"
510
+ % [method, declared_args.size(), args.size()])
511
+ var coerced_args: Array = []
512
+ for i in range(args.size()):
513
+ var arg: Variant = args[i]
514
+ # 超出声明参数个数的尾巴是 vararg 透传段,没有声明类型,原样喂 callv。
515
+ if i >= declared_args.size():
516
+ coerced_args.append(arg)
517
+ continue
518
+ var declared_type: int = int((declared_args[i] as Dictionary).get("type", TYPE_NIL))
519
+ var coerced: Dictionary = _coerce_call_arg(declared_type, arg, "arg %d of %s" % [i, method])
520
+ if coerced.has("error"):
521
+ return coerced
522
+ if coerced.has("value"):
523
+ coerced_args.append(coerced["value"])
524
+ else:
525
+ coerced_args.append(arg)
526
+ return {"args": coerced_args}
527
+
528
+
529
+ func _find_method_info(node: Node, method: String) -> Dictionary:
530
+ for mi: Dictionary in node.get_method_list():
531
+ if str(mi.get("name", "")) == method:
532
+ return mi
533
+ return {}
534
+
535
+
536
+ ## 单个 call 实参的类型守卫 + Array→复合 Variant coerce(#167 / #183)。
537
+ ## 非 Array 实参(标量 / Dictionary / Object):按 _call_arg_convertible 判定——可隐式转换
538
+ ## 则返 {} 透传,否则 fail-loud(#183,String→int / 数字→StringName 等标量错类型的补集)。
539
+ ## Array 实参命中复合声明类型:返复合 coerce 结果({"value":...} 或长度·元素错的 {"error":...})。
540
+ ## Array 实参 + 声明类型接受 Array(untyped / Array / Packed*,见 _CALL_ARG_ARRAY_OK_TYPES):返 {} 透传。
541
+ ## Array 实参 + 标量 / Object / Dictionary 等:callv 必 "Cannot convert" 静默失败 → fail-loud。
542
+ func _coerce_call_arg(declared_type: int, arg: Variant, label: String) -> Dictionary:
543
+ if not (arg is Array):
544
+ # #183:callv 对类型不匹配的标量实参只喷 "Cannot convert argument" 并返回 null,
545
+ # RPC 仍假 ok:true / result:null(#167 只关了 Array 侧)。可隐式转换则透传,否则拦。
546
+ if _call_arg_convertible(typeof(arg), declared_type):
547
+ return {}
548
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
549
+ "value type mismatch for '%s': cannot convert %s to declared parameter type %s"
550
+ % [label, type_string(typeof(arg)), type_string(declared_type)])
551
+ var coerced: Dictionary = _coerce_compound_array(declared_type, arg, label)
552
+ if not coerced.is_empty():
553
+ return coerced
554
+ if declared_type in _CALL_ARG_ARRAY_OK_TYPES:
555
+ return {}
556
+ return _err(CliControlErrorCodes.INVALID_PARAMS,
557
+ "value type mismatch for '%s': cannot convert JSON Array to declared parameter type %d (a non-Array value is required)"
558
+ % [label, declared_type])
559
+
560
+
561
+ ## 标量实参 → 声明形参类型的可转换性判定(#183),镜像 Godot Variant::can_convert_strict
562
+ ## 中与 JSON 标量实参相关的子集:true = callv 能隐式转换(透传安全),false = callv 必
563
+ ## "Cannot convert" 静默失败(须 fail-loud)。白名单刻意 ⊆ can_convert_strict(在 JSON 实参
564
+ ## 场景下),宁可漏拦不误杀合法调用。
565
+ ## 范围边界:JSON 实参只产 Nil / bool / 数字 / String / Array / Dictionary。null(TYPE_NIL)
566
+ ## 实参的可达性随形参 nullable 而异(Object 形参 / 有默认值时 callv 接受),精确判定需更多
567
+ ## 签名信息,故保守透传、不在本 issue 新增对 null 的拦截(与改动前一致,零回归)。
568
+ func _call_arg_convertible(from_type: int, declared_type: int) -> bool:
569
+ if declared_type == TYPE_NIL:
570
+ return true # untyped(Variant) 形参接受任何实参
571
+ if from_type == declared_type:
572
+ return true
573
+ if from_type == TYPE_NIL:
574
+ return true # 见上:null 实参保守透传,不新增拦截
575
+ if from_type in _NUMERIC_ARG_TYPES and declared_type in _NUMERIC_ARG_TYPES:
576
+ return true # 数值组 bool/int/float 互转(callv 宽化)
577
+ if from_type in _STRINGY_ARG_TYPES and declared_type in _STRINGY_ARG_TYPES:
578
+ return true # 字符串组 String/StringName/NodePath 互转
579
+ return false
580
+
581
+
449
582
  ## emit-signal 逃生门(#157 item4):默认禁(1015),daemon 带 --allow-emit-signal 才放行。
450
583
  ## 门控最先短路(功能没开不解析节点/信号、不泄露存在性)。emit_signal 仍在方法黑名单里,
451
584
  ## 本 handler 是唯一的、被门控的发信号入口;通用 call 面 emit_signal 始终被拒。
@@ -3,5 +3,5 @@
3
3
  name="Godot CLI Control"
4
4
  description="WebSocket bridge for headless / scripted control of Godot scenes (click / property / input simulation / screenshot / record)."
5
5
  author="kesar"
6
- version="0.1.0"
6
+ version="0.4.2"
7
7
  script="plugin.gd"
@@ -65,6 +65,31 @@ class _SubPathLeafFixture extends Node:
65
65
  var open_dict: Dictionary = {"foo": 7}
66
66
 
67
67
 
68
+ # issue #167:handle_call_method 的 typed-arg coerce / fail-loud 用 fixture。
69
+ # 各方法的形参带类型声明,get_method_list() 会汇报对应 TYPE_*,从而走 _coerce_call_arg。
70
+ class _CallFixture extends Node:
71
+ var last_rect: Rect2 = Rect2()
72
+ var rect_called: bool = false
73
+ # 复合 Variant 形参:Array → Rect2 应被 coerce。命名避开方法黑名单(≠ "set")。
74
+ func set_wander_rect(rect: Rect2) -> String:
75
+ last_rect = rect
76
+ rect_called = true
77
+ return "ok"
78
+ # 标量形参:Array 实参无法转换 → fail-loud。
79
+ func takes_int(n: int) -> int:
80
+ return n * 2
81
+ # String 形参(#183 标量侧):数字 / bool 实参 callv "Cannot convert" → fail-loud;
82
+ # String 实参透传。
83
+ func takes_string(s: String) -> String:
84
+ return s + "!"
85
+ # 带默认值的可选尾参:required=1,给 1 个实参应放行(b 取默认 5)。
86
+ func takes_two(a: int, b: int = 5) -> int:
87
+ return a + b
88
+ # untyped(Variant) 形参:Array 实参应原样透传,不被误拦。
89
+ func takes_any(v):
90
+ return v
91
+
92
+
68
93
  var _api: Node
69
94
  var _target: Node
70
95
 
@@ -690,6 +715,213 @@ func test_call_method_nonexistent_returns_1003() -> void:
690
715
  assert_eq(int(result.error.code), 1003)
691
716
 
692
717
 
718
+ # ── handle_call_method typed-arg coerce / fail-loud(issue #167) ──────
719
+
720
+ func test_call_method_coerces_array_to_rect2() -> void:
721
+ # 核心修复:typed Rect2 形参收到 JSON Array → coerce 成 Rect2 并真正调用,
722
+ # 不再静默 ok:true/result:null(#164 live WANDER 演示的假阳性)。
723
+ var fixture: _CallFixture = _CallFixture.new()
724
+ fixture.name = "WanderTarget"
725
+ add_child_autofree(fixture)
726
+ var result: Dictionary = _api.handle_call_method({
727
+ "path": str(fixture.get_path()),
728
+ "method": "set_wander_rect",
729
+ "args": [[1, 2, 3, 4]],
730
+ })
731
+ assert_does_not_have(result, "error")
732
+ assert_eq(str(result.get("result")), "ok")
733
+ assert_true(fixture.rect_called)
734
+ assert_eq(fixture.last_rect, Rect2(1, 2, 3, 4))
735
+
736
+
737
+ func test_call_method_rect2_wrong_length_fails_loud() -> void:
738
+ # Rect2 期望 4 个元素,给 3 → -32602,且方法**没**被调用(不能假成功)。
739
+ var fixture: _CallFixture = _CallFixture.new()
740
+ fixture.name = "BadLenWanderTarget"
741
+ add_child_autofree(fixture)
742
+ var result: Dictionary = _api.handle_call_method({
743
+ "path": str(fixture.get_path()),
744
+ "method": "set_wander_rect",
745
+ "args": [[1, 2, 3]],
746
+ })
747
+ assert_has(result, "error")
748
+ assert_eq(int(result.error.code), -32602)
749
+ assert_string_contains(str(result.error.message), "Rect2")
750
+ assert_false(fixture.rect_called)
751
+
752
+
753
+ func test_call_method_rect2_non_numeric_element_fails_loud() -> void:
754
+ # Rect2 元素须全数字,含字符串 → -32602,方法不被调用。
755
+ var fixture: _CallFixture = _CallFixture.new()
756
+ fixture.name = "BadElemWanderTarget"
757
+ add_child_autofree(fixture)
758
+ var result: Dictionary = _api.handle_call_method({
759
+ "path": str(fixture.get_path()),
760
+ "method": "set_wander_rect",
761
+ "args": [[1, 2, "x", 4]],
762
+ })
763
+ assert_has(result, "error")
764
+ assert_eq(int(result.error.code), -32602)
765
+ assert_string_contains(str(result.error.message), "Rect2")
766
+ assert_false(fixture.rect_called)
767
+
768
+
769
+ func test_call_method_too_few_args_fails_loud() -> void:
770
+ # takes_int 必填 1 个,给 0 → -32602(连 callv 都不发,避免引擎错误 + 假 ok:true)。
771
+ var fixture: _CallFixture = _CallFixture.new()
772
+ fixture.name = "TooFewTarget"
773
+ add_child_autofree(fixture)
774
+ var result: Dictionary = _api.handle_call_method({
775
+ "path": str(fixture.get_path()),
776
+ "method": "takes_int",
777
+ "args": [],
778
+ })
779
+ assert_has(result, "error")
780
+ assert_eq(int(result.error.code), -32602)
781
+ assert_string_contains(str(result.error.message), "argument count")
782
+
783
+
784
+ func test_call_method_too_many_args_fails_loud() -> void:
785
+ # takes_int 声明 1 个(非 vararg),给 2 → -32602。
786
+ var fixture: _CallFixture = _CallFixture.new()
787
+ fixture.name = "TooManyTarget"
788
+ add_child_autofree(fixture)
789
+ var result: Dictionary = _api.handle_call_method({
790
+ "path": str(fixture.get_path()),
791
+ "method": "takes_int",
792
+ "args": [1, 2],
793
+ })
794
+ assert_has(result, "error")
795
+ assert_eq(int(result.error.code), -32602)
796
+ assert_string_contains(str(result.error.message), "argument count")
797
+
798
+
799
+ func test_call_method_optional_arg_omitted_ok() -> void:
800
+ # takes_two(a, b=5):只给 a=3 应放行,b 取默认 → 返 8。
801
+ var fixture: _CallFixture = _CallFixture.new()
802
+ fixture.name = "OptionalArgTarget"
803
+ add_child_autofree(fixture)
804
+ var result: Dictionary = _api.handle_call_method({
805
+ "path": str(fixture.get_path()),
806
+ "method": "takes_two",
807
+ "args": [3],
808
+ })
809
+ assert_does_not_have(result, "error")
810
+ assert_eq(int(result.get("result")), 8)
811
+
812
+
813
+ func test_call_method_array_to_scalar_param_fails_loud() -> void:
814
+ # 标量 int 形参收到 Array → callv 会 "Cannot convert" 静默失败,故 pre-empt 成 -32602。
815
+ var fixture: _CallFixture = _CallFixture.new()
816
+ fixture.name = "ArrayToScalarTarget"
817
+ add_child_autofree(fixture)
818
+ var result: Dictionary = _api.handle_call_method({
819
+ "path": str(fixture.get_path()),
820
+ "method": "takes_int",
821
+ "args": [[1, 2]],
822
+ })
823
+ assert_has(result, "error")
824
+ assert_eq(int(result.error.code), -32602)
825
+
826
+
827
+ func test_call_method_untyped_param_passes_array_through() -> void:
828
+ # 防过度拦截:untyped(Variant) 形参的 Array 实参应原样透传,方法收到该数组。
829
+ var fixture: _CallFixture = _CallFixture.new()
830
+ fixture.name = "UntypedArgTarget"
831
+ add_child_autofree(fixture)
832
+ var result: Dictionary = _api.handle_call_method({
833
+ "path": str(fixture.get_path()),
834
+ "method": "takes_any",
835
+ "args": [[1, 2, 3]],
836
+ })
837
+ assert_does_not_have(result, "error")
838
+ assert_eq(result.get("result"), [1, 2, 3])
839
+
840
+
841
+ # ── handle_call_method 标量实参 fail-loud / 宽化(issue #183) ──────
842
+
843
+ func test_call_method_string_to_int_param_fails_loud() -> void:
844
+ # 复现 set_z_index '"abc"':String 实参喂 int 形参 → callv "Cannot convert" 静默失败,
845
+ # pre-empt 成 -32602,方法不被调用(result 缺席,不假 ok:true)。
846
+ var fixture: _CallFixture = _CallFixture.new()
847
+ fixture.name = "StrToIntTarget"
848
+ add_child_autofree(fixture)
849
+ var result: Dictionary = _api.handle_call_method({
850
+ "path": str(fixture.get_path()),
851
+ "method": "takes_int",
852
+ "args": ["abc"],
853
+ })
854
+ assert_has(result, "error")
855
+ assert_eq(int(result.error.code), -32602)
856
+ assert_string_contains(str(result.error.message), "value type mismatch")
857
+ assert_does_not_have(result, "result")
858
+
859
+
860
+ func test_call_method_number_to_string_param_fails_loud() -> void:
861
+ # 复现 set_name 42:数字实参喂 String 形参 → fail-loud,不假 ok:true。
862
+ var fixture: _CallFixture = _CallFixture.new()
863
+ fixture.name = "NumToStrTarget"
864
+ add_child_autofree(fixture)
865
+ var result: Dictionary = _api.handle_call_method({
866
+ "path": str(fixture.get_path()),
867
+ "method": "takes_string",
868
+ "args": [42],
869
+ })
870
+ assert_has(result, "error")
871
+ assert_eq(int(result.error.code), -32602)
872
+ assert_does_not_have(result, "result")
873
+
874
+
875
+ func test_call_method_float_to_int_param_widens_ok() -> void:
876
+ # 防误杀:数值组互转(float→int)是 callv 合法宽化,须透传并真正调用(3.0→3 → 返 6)。
877
+ var fixture: _CallFixture = _CallFixture.new()
878
+ fixture.name = "FloatToIntTarget"
879
+ add_child_autofree(fixture)
880
+ var result: Dictionary = _api.handle_call_method({
881
+ "path": str(fixture.get_path()),
882
+ "method": "takes_int",
883
+ "args": [3.0],
884
+ })
885
+ assert_does_not_have(result, "error")
886
+ assert_eq(int(result.get("result")), 6)
887
+
888
+
889
+ func test_call_method_bool_to_int_param_ok() -> void:
890
+ # 防误杀:bool→int 同属数值组,透传(true→1 → 返 2)。
891
+ var fixture: _CallFixture = _CallFixture.new()
892
+ fixture.name = "BoolToIntTarget"
893
+ add_child_autofree(fixture)
894
+ var result: Dictionary = _api.handle_call_method({
895
+ "path": str(fixture.get_path()),
896
+ "method": "takes_int",
897
+ "args": [true],
898
+ })
899
+ assert_does_not_have(result, "error")
900
+ assert_eq(int(result.get("result")), 2)
901
+
902
+
903
+ func test_call_method_string_to_string_param_ok() -> void:
904
+ # 防误杀:String→String 精确匹配,透传并调用("hi"→"hi!")。
905
+ var fixture: _CallFixture = _CallFixture.new()
906
+ fixture.name = "StrToStrTarget"
907
+ add_child_autofree(fixture)
908
+ var result: Dictionary = _api.handle_call_method({
909
+ "path": str(fixture.get_path()),
910
+ "method": "takes_string",
911
+ "args": ["hi"],
912
+ })
913
+ assert_does_not_have(result, "error")
914
+ assert_eq(str(result.get("result")), "hi!")
915
+
916
+
917
+ func test_prepare_call_args_vararg_not_count_rejected() -> void:
918
+ # vararg 方法(Node.rpc 是变参)尾部可接任意多实参,数量校验不能误拦。
919
+ # 直接打 _prepare_call_args(不实际 callv,避免 rpc 副作用),断言无 error、实参保留。
920
+ var prepared: Dictionary = _api._prepare_call_args(_target, "rpc", ["some_method", 1, 2])
921
+ assert_does_not_have(prepared, "error")
922
+ assert_eq((prepared.get("args") as Array).size(), 3)
923
+
924
+
693
925
  # ── _build_tree 节点总数上限(防 outbound buffer 超限) ───────────────
694
926
 
695
927
  func test_build_tree_short_circuits_above_node_limit() -> void:
@@ -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.4.0'
22
- __version_tuple__ = version_tuple = (0, 4, 0)
21
+ __version__ = version = '0.4.2'
22
+ __version_tuple__ = version_tuple = (0, 4, 2)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -240,7 +240,7 @@ Three numeric ranges cohabit in `error.code`. Knowing which is which lets you de
240
240
  |---|---|
241
241
  | `-32600` | Malformed request (missing / non-string `method`). Bug in client; should never reach an agent. |
242
242
  | `-32601` | Unknown method name. Bug; means client + plugin versions drifted. |
243
- | `-32602` | Invalid params: missing required field, blocked property/method (security blacklist), out-of-range value, value-type mismatch on `set` (e.g. `Vector2` property given an array of wrong length / non-numeric elements), node-isn't-clickable (e.g. you `click`'d a `Node2D`), or `hold` given `duration ≤ 0` (use `press` for an indefinite hold). Don't retry; the request shape is wrong. |
243
+ | `-32602` | Invalid params: missing required field, blocked property/method (security blacklist), out-of-range value, value-type mismatch on `set` (e.g. `Vector2` property given an array of wrong length / non-numeric elements), value-type or argument-count mismatch on `call` (typed-parameter coercion failure, a JSON array given to a scalar/Object parameter, a scalar of the wrong type for the declared parameter — e.g. a string for an `int` param or a number for a `String`/`StringName` — or the wrong number of args), node-isn't-clickable (e.g. you `click`'d a `Node2D`), or `hold` given `duration ≤ 0` (use `press` for an indefinite hold). Don't retry; the request shape is wrong. |
244
244
 
245
245
  **Client-side (CLI / GameClient) — `-1xxx`:**
246
246
 
@@ -363,6 +363,8 @@ godot-cli-control call /root/Game start_game 1 '"easy"' # int 1, string "easy
363
363
 
364
364
  So `position '[100, 200]'` → `Vector2(100, 200)`, `transform '[1,0,0, 0,1,0, 0,0,1, 10,20,30]'` → `Transform3D(IDENTITY, (10,20,30))`. Wrong length or non-numeric elements fail loud with `-32602 "value type mismatch ..."` instead of silently setting `(0, 0)` like pre-0.2.5 versions did.
365
365
 
366
+ **`call` typed arguments use the same coercion (+ arg-count / type checks).** When you `call <node> <method>` and pass a JSON array for a parameter the method declares as a compound Variant (`Rect2`, `Vector2`, `Color`, …), the server reads the method signature (`get_method_list()`) and coerces it exactly like `set` above — e.g. `call /root/Mob enable_wander '[0, 0, 640, 480]'` reaches `func enable_wander(rect: Rect2)` as a real `Rect2`. Mismatches now **fail loud with `-32602`** instead of the old silent `ok: true / result: null`: wrong array length or a non-numeric element (`"value type mismatch ..."`), a JSON array handed to a scalar / Object parameter (`"cannot convert JSON Array ..."`), or the wrong number of arguments (`"argument count mismatch ..."`, which honors optional/default parameters and varargs). Scalar arguments are now type-checked too (#183): a scalar passes through when the engine can implicitly convert it to the declared type (numeric group `bool`/`int`/`float`; string group `String`/`StringName`/`NodePath`), but a mismatch the engine would reject — a string handed to an `int` parameter, a number handed to a `String`/`StringName` parameter — **fails loud with `-32602`** (`"value type mismatch ..."`) rather than the old silent success. This closes the footgun where `callv` printed `Cannot convert argument ...` to the engine log but the RPC still reported success.
367
+
366
368
  **Sub-path + Array also fails loud.** `set <node> transform:origin '[10, 20, 30]'` is rejected with `-32602 "sub-path + Array is not supported"`: Godot's `Object.set("transform:origin", Array)` silently drops the Array (origin stays at `(0,0,0)`) — same class of footgun as the strict Variant checks above, so the server pre-empts it. Sub-paths are scalar-only (`set <node> position:x 1.8`); to write a whole compound Variant use the top-level Array form above.
367
369
 
368
370
  **Sub-path leaf typo also fails loud (symmetric with `get`).** `set <node> position:zz 5` is rejected with `1002` listing the valid leaves, instead of silently dropping the write and returning `{"success": true}` (Godot's `set_indexed` no-ops an unknown leaf — an "I thought I wrote it but the value never changed" footgun). Closed compound types and per-level nesting are exactly the same set as the `get` side; open/dynamic types (Dictionary, Array, Object) still pass through unchecked.
@@ -456,6 +458,29 @@ Key constraints:
456
458
 
457
459
  **Project-level defaults** (`.cli_control/config.json`, optional): set `{"idle_timeout": "30m"}` so `daemon start` / `run` auto-quit after idle without re-typing `--idle-timeout` every time. It's read only when you don't pass `--idle-timeout` (default `0`); an explicit flag wins. Bad JSON / a malformed duration there surfaces as a `-1003` usage error (exit 64).
458
460
 
461
+ #### Recipe: warm-then-record (filming a pre-evolved world)
462
+
463
+ To record a demo that begins from a *long-evolved* world (e.g. many in-game years of simulation), you **cannot** fast-forward inside the recording: under `--record` Movie Maker renders a fixed `--fps × time_scale` frames per game-second, so raising `Engine.time_scale` only fast-forwards the *captured footage* instead of cheaply skipping ahead. Use a two-daemon **split-record** flow — evolve + save the world in a non-recording daemon, then load + perform in a recording one.
464
+
465
+ 1. **Warm up (non-recording daemon).** Start a normal daemon (`--headless` is fine for pure simulation) and push the world to the target state with `time-scale N` or, better, the game's own sim-speed control. Poll a game-side progress value to know when you have arrived — e.g. `wait-property /root/GameState day == <target>` (substitute your game's own node/field).
466
+ 2. **Persist + stop.** Call the game's save entry point to flush the evolved world to a (sandbox) save slot — `call /root/WorldService save_session` (substitute your own) — then `daemon stop` the warm-up daemon.
467
+ 3. **Load + record (recording daemon).** Start a `--record` daemon and `run` a script that loads the saved world, waits for it to settle, then drives the camera/player at `time_scale = 1`:
468
+ ```python
469
+ def run(bridge):
470
+ bridge.call("/root/SceneRouter", "continue_game") # load saved world (game-side API)
471
+ bridge.wait_node("/root/World") # continue_game settles after `await world.ready`
472
+ bridge.set("/root/World/Player", "global_position", [x, y]) # reset the start AFTER the load settles
473
+ # ... scripted walk / clicks, captured to the movie ...
474
+ ```
475
+
476
+ Known gotchas (all hit in real downstream demo work):
477
+
478
+ - **Headless `time_scale` overshoots.** Headless runs uncapped, so a high `time_scale` blows past the target in a single tick. Prefer a GUI daemon (frame-rate capped) and poll a game-side counter to stop precisely, or warm up in small `time-scale` steps, re-checking progress between each.
479
+ - **SIGTERM close-save can clobber your save.** If the game saves on quit, the graceful exit `daemon stop` performs may overwrite the slot with the *warm-up* player position. Confirm the save landed before stopping, disable on-quit save during warm-up, or treat the recording-side `continue_game` load as the source of truth and reset the start there.
480
+ - **`continue_game` settles asynchronously.** The loaded world places entities only after `await world.ready`, not synchronously on the call. In the recording script, `wait` / `wait-node` / `wait-property` for the world to be ready **before** overriding the start point — a position set too early is overwritten when the load resolves.
481
+
482
+ Boundary: to fast-forward *within* a recording, drive the game's **own** sim-speed (which leaves Movie Maker's fixed `--fps × time_scale` frame budget untouched), not engine `time_scale` (which fast-forwards the footage).
483
+
459
484
  ## CLI reference
460
485
 
461
486
  <!-- BEGIN cli_help (auto-generated by godot-cli-control init) -->
@@ -618,6 +643,7 @@ pytest_plugins = ["godot_cli_control.pytest_plugin"]
618
643
  - **`wait-signal` must be armed before the action that fires it.** Every `godot-cli-control` invocation is a fresh process — if you fire the signal before `wait-signal` connects, you'll always timeout. **Preferred (issue #155):** use `--trigger` to arm and fire on the *same* connection: `godot-cli-control wait-signal /root/Area door_opened 3 --trigger 'tap interact'` — the server connects the signal handler first (armed), then your trigger runs, then the result returns. No race, no shell gymnastics. The result envelope includes `trigger_result` with the trigger subcommand's return value (only present when the trigger actually ran). **When using `--trigger`, `timeout` covers only the signal wait, not the trigger's execution time** (issue #172) — the client tells the server to start the deadline *after* your trigger finishes, so slow triggers like `combo` or `drag` no longer eat into the budget. **Fallback (if you can't use --trigger):** `godot-cli-control wait-signal /root/A my_signal & godot-cli-control tap jump; wait` — background the wait *before* triggering. `--trigger` only accepts a single RPC subcommand; for multi-step triggers use `combo` as the `--trigger` subcommand (e.g. `--trigger 'combo --steps-json ...'`).
619
644
  - **Replace magic `wait-time` sleeps with `wait-prop` or `wait-frames`.** Fixed `wait-time 0.3` guesses are fragile — they're too long when the game is fast, too short under load. Prefer: `wait-prop /root/Player on_floor true` (wait for state) or `wait-frames 4` (wait for a specific number of frames to render). These are more reliable and often 2-10× faster.
620
645
  - **`get` on a compound Variant returns an array + type — you can round-trip it straight into `set`.** `get /root/Player position` returns `{"value": [-2480.0, 1400.0], "type": "Vector2"}`. That `value` array is the exact format `set` accepts: `set /root/Player position '[-2480.0, 1400.0]'`. No conversion needed.
646
+ - **`call` into a method with typed params just works — and fails loud if it can't.** A JSON array passed to a `Rect2` / `Vector2` / `Color` (etc.) parameter is coerced from the method signature (`call /root/Mob enable_wander '[0,0,640,480]'` → `enable_wander(rect: Rect2)`), the same as `set`. If the array is the wrong length, has a non-numeric element, is handed to a scalar parameter, or you pass the wrong number of args, you get `-32602` — **not** a fake `ok: true / result: null`. So a successful-looking `call` that does nothing is no longer possible for typed methods: trust the success envelope.
621
647
  - **Arrays/Dicts nested inside compound Variants encode as arrays but carry no `type` field — use a sub-path to read a typed leaf.** For example, a `Dictionary` property that happens to contain a `Vector3` will give you an untyped array. If you need the type, use `get <node> mydict:somekey` to read the leaf directly and get its type.
622
648
  - **Sub-path leaf typo on a closed compound type fails loud — on both `get` and `set`.** Closed types are Vector2/3/4 (incl. `i` variants), Color, Rect2/Rect2i, Transform2D/Transform3D, Basis, Plane, Quaternion, AABB, and Projection — a typo'd leaf returns `1002` listing the valid leaves, e.g. `get /root/Node position:typo` → `{"ok": false, "error": {"code": 1002, "message": "Sub-path leaf not found: position:typo (valid leaves: x, y)"}}`. `set /root/Node position:zz 5` is rejected the same way instead of silently dropping the write and returning `{"success": true}`. Nested paths validate per level, so `get /root/Sprite transform:basis:typo` also fails loud. Only **open/dynamic types** (Dictionary, Array, Object) still return `{"value": null}` (or silently pass a `set` through) for an unknown leaf — their keys/members can't be enumerated, so validating would risk false positives. On `get`, `1002` is also returned if the part before `":"` doesn't exist as a top-level property.
623
649
  - **Python API (`bridge.get_property` / `bridge.get_properties`) returns bare values only — no `type` field.** These convenience methods strip the `type` from the server response to reduce boilerplate. When you need the `type` field (e.g. to distinguish `Vector2` from a plain 2-element array), go through `client.request("get_property", {"path": ..., "property": ...})` directly.