ravensight-playtest 0.1.0

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,836 @@
1
+ # Ravensight Playtest driver: newline delimited JSON-RPC 2.0 over TCP.
2
+ #
3
+ # This autoload is injected into a temporary copy of the customer's project by
4
+ # the ravensight-playtest CLI (src/run/drivers/godot.js). It is never added to
5
+ # the customer's own project directory, and it refuses to run at all unless
6
+ # every activation condition in spec 07 holds:
7
+ #
8
+ # 1. The build is not an export template, OR it was exported with the custom
9
+ # feature tag "ravensight_driver". A release export without that tag never
10
+ # activates the driver even if the flag below is passed.
11
+ # 2. AND either "--ravensight-driver" is present in the user args (after the
12
+ # "--" separator) or RAVENSIGHT_DRIVER=1 is in the environment.
13
+ # 3. It binds 127.0.0.1 only, never 0.0.0.0.
14
+ # 4. If RAVENSIGHT_DRIVER_TOKEN is set, the first request on a connection
15
+ # must be hello{token} or the connection is closed.
16
+ # 5. One client at a time. A second connection is refused with -32001.
17
+ # 6. When any of this fails, the node calls queue_free() on itself, so an
18
+ # inactive driver costs nothing per frame.
19
+ #
20
+ # Requests are drained in _process, at most 8 per frame, so the scene tree is
21
+ # read at a consistent point between frames.
22
+ extends Node
23
+
24
+ const DRIVER_VERSION := "1.0.0"
25
+ const DEFAULT_PORT := 47800
26
+ const FEATURE_TAG := "ravensight_driver"
27
+ const ADDON_DIR := "ravensight_driver"
28
+
29
+ const MAX_REQUEST_BYTES := 1048576
30
+ const MAX_REQUESTS_PER_FRAME := 8
31
+ const MAX_NODES := 2000
32
+ const MAX_TEXT_CHARS := 500
33
+ const MAX_TREE_DEPTH := 32
34
+ const DEFAULT_TREE_DEPTH := 6
35
+ const MAX_PRESS_MS := 10000
36
+ const MAX_WAIT_FRAMES := 3600
37
+ const MAX_STATE_DEPTH := 8
38
+ const MAX_HOOK_BYTES := 65536
39
+ const STATE_GROUP := "ravensight_state"
40
+
41
+ const ERR_PARSE := -32700
42
+ const ERR_INVALID_REQUEST := -32600
43
+ const ERR_METHOD_NOT_FOUND := -32601
44
+ const ERR_INVALID_PARAMS := -32602
45
+ const ERR_BUSY := -32001
46
+ const ERR_UNAUTHORIZED := -32002
47
+ const ERR_HEADLESS := -32010
48
+ const ERR_NODE_NOT_FOUND := -32011
49
+ const ERR_ACTION_NOT_DEFINED := -32012
50
+ const ERR_HOOK := -32020
51
+
52
+ var _server: TCPServer
53
+ var _peer: StreamPeerTCP
54
+ var _buffer := PackedByteArray()
55
+ var _inbox: Array[String] = []
56
+ var _authed := false
57
+ var _token := ""
58
+ var _pending_waits: Array = []
59
+ var _last_scene_path := ""
60
+ var _extra_methods := {}
61
+
62
+ ## --- Lifecycle ----------------------------------------------------------------
63
+
64
+ func _ready() -> void:
65
+ process_mode = Node.PROCESS_MODE_ALWAYS
66
+ if not _should_enable():
67
+ # Inert build: remove the node entirely rather than idling in _process.
68
+ queue_free()
69
+ return
70
+
71
+ _token = OS.get_environment("RAVENSIGHT_DRIVER_TOKEN")
72
+ _authed = _token.is_empty()
73
+
74
+ _server = TCPServer.new()
75
+ var port := _resolve_port()
76
+ var err := _server.listen(port, "127.0.0.1")
77
+ if err != OK:
78
+ push_error("[ravensight-driver] listen failed on 127.0.0.1:%d: %s" % [port, error_string(err)])
79
+ _server = null
80
+ queue_free()
81
+ return
82
+
83
+ var scene := get_tree().current_scene
84
+ _last_scene_path = scene.scene_file_path if scene != null else ""
85
+ _connect_sdk_mirror()
86
+ # The CLI waits for this line in godot.log before it dials the socket.
87
+ print("[ravensight-driver] listening on 127.0.0.1:%d version %s" % [port, DRIVER_VERSION])
88
+
89
+
90
+ func _exit_tree() -> void:
91
+ _close_client()
92
+ if _server != null:
93
+ _server.stop()
94
+ _server = null
95
+
96
+
97
+ ## Every activation condition from spec 07, evaluated once in _ready().
98
+ func _should_enable() -> bool:
99
+ # An export template only ever runs the driver when the export itself was
100
+ # built with the ravensight_driver custom feature tag.
101
+ if OS.has_feature("template") and not OS.has_feature(FEATURE_TAG):
102
+ return false
103
+ if "--ravensight-driver" in OS.get_cmdline_user_args():
104
+ return true
105
+ return OS.get_environment("RAVENSIGHT_DRIVER") == "1"
106
+
107
+
108
+ func _resolve_port() -> int:
109
+ var prefix := "--ravensight-driver-port="
110
+ for arg in OS.get_cmdline_user_args():
111
+ if arg.begins_with(prefix):
112
+ var value := arg.substr(prefix.length())
113
+ if value.is_valid_int():
114
+ return value.to_int()
115
+ var env_port := OS.get_environment("RAVENSIGHT_DRIVER_PORT")
116
+ if env_port.is_valid_int():
117
+ return env_port.to_int()
118
+ return DEFAULT_PORT
119
+
120
+
121
+ ## Mirrors the Ravensight SDK's tracked events as "ravensight_event"
122
+ ## notifications when the SDK autoload is present. Absent SDK is not an error:
123
+ ## most projects under test do not ship it.
124
+ func _connect_sdk_mirror() -> void:
125
+ for autoload_name in ["Ravensight", "GameTracker"]:
126
+ var sdk := get_node_or_null("/root/" + autoload_name)
127
+ if sdk != null and sdk.has_signal("event_tracked"):
128
+ sdk.connect("event_tracked", _on_sdk_event)
129
+ return
130
+ # The SDK autoload does not have to be called Ravensight: a project is free
131
+ # to register it under any name, so fall back to whichever autoload actually
132
+ # exposes the signal.
133
+ for child in get_tree().root.get_children():
134
+ if child == self:
135
+ continue
136
+ if child.has_signal("event_tracked"):
137
+ child.connect("event_tracked", _on_sdk_event)
138
+ return
139
+
140
+
141
+ func _on_sdk_event(event_name: String, data: Dictionary) -> void:
142
+ _notify("ravensight_event", {"name": str(event_name), "data": data})
143
+
144
+
145
+ ## --- Socket pump --------------------------------------------------------------
146
+
147
+ func _process(_delta: float) -> void:
148
+ _accept_connections()
149
+ _read_client()
150
+ _drain_inbox()
151
+ _tick_waits()
152
+ _check_scene_change()
153
+
154
+
155
+ func _accept_connections() -> void:
156
+ while _server != null and _server.is_connection_available():
157
+ var incoming := _server.take_connection()
158
+ incoming.set_no_delay(true)
159
+ if _peer != null and _peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
160
+ # Single client at a time: tell the newcomer why, then hang up.
161
+ var body := JSON.stringify({
162
+ "jsonrpc": "2.0",
163
+ "id": null,
164
+ "error": {"code": ERR_BUSY, "message": "busy: another client is connected"},
165
+ }) + "\n"
166
+ incoming.put_data(body.to_utf8_buffer())
167
+ incoming.poll()
168
+ incoming.disconnect_from_host()
169
+ continue
170
+ _peer = incoming
171
+ _buffer = PackedByteArray()
172
+ _inbox.clear()
173
+ _authed = _token.is_empty()
174
+
175
+
176
+ func _read_client() -> void:
177
+ if _peer == null:
178
+ return
179
+ _peer.poll()
180
+ var status := _peer.get_status()
181
+ if status != StreamPeerTCP.STATUS_CONNECTED:
182
+ if status == StreamPeerTCP.STATUS_NONE or status == StreamPeerTCP.STATUS_ERROR:
183
+ _close_client()
184
+ return
185
+ var available := _peer.get_available_bytes()
186
+ if available > 0:
187
+ var res := _peer.get_data(available)
188
+ if res[0] == OK:
189
+ _buffer.append_array(res[1])
190
+ while true:
191
+ var idx := _buffer.find(10) # "\n"
192
+ if idx == -1:
193
+ if _buffer.size() > MAX_REQUEST_BYTES:
194
+ # An oversized line can never become a valid request, and
195
+ # buffering it further is how a driver runs a machine out of
196
+ # memory. Refuse and drop the connection.
197
+ _reject_oversized_request()
198
+ return
199
+ if idx > MAX_REQUEST_BYTES:
200
+ # The cap applies to a line that did arrive complete just as much as
201
+ # to one still growing: a 4 MB request that ends in a newline is
202
+ # still a 4 MB request.
203
+ _reject_oversized_request()
204
+ return
205
+ var line := _buffer.slice(0, idx).get_string_from_utf8().strip_edges()
206
+ _buffer = _buffer.slice(idx + 1)
207
+ if not line.is_empty():
208
+ _inbox.append(line)
209
+
210
+
211
+ func _reject_oversized_request() -> void:
212
+ _send_error(null, ERR_INVALID_REQUEST, "request line exceeds %d bytes" % MAX_REQUEST_BYTES)
213
+ _close_client()
214
+
215
+
216
+ func _drain_inbox() -> void:
217
+ var handled := 0
218
+ while handled < MAX_REQUESTS_PER_FRAME and not _inbox.is_empty():
219
+ var line: String = _inbox.pop_front()
220
+ handled += 1
221
+ _handle_line(line)
222
+
223
+
224
+ func _close_client() -> void:
225
+ if _peer != null:
226
+ _peer.disconnect_from_host()
227
+ _peer = null
228
+ _buffer = PackedByteArray()
229
+ _inbox.clear()
230
+ _pending_waits.clear()
231
+ _authed = _token.is_empty()
232
+
233
+
234
+ ## --- Dispatch ----------------------------------------------------------------
235
+
236
+ func _handle_line(line: String) -> void:
237
+ # JSON.parse_string() cannot tell "null" (valid JSON, wrong shape) from
238
+ # malformed input, and those are two different JSON-RPC errors, so the
239
+ # instance API is used for its error code.
240
+ var json := JSON.new()
241
+ if json.parse(line) != OK:
242
+ _send_error(null, ERR_PARSE, "parse error")
243
+ return
244
+ var parsed: Variant = json.data
245
+ if typeof(parsed) != TYPE_DICTIONARY:
246
+ _send_error(null, ERR_INVALID_REQUEST, "request must be a JSON object")
247
+ return
248
+ var request: Dictionary = parsed
249
+ var id: Variant = request.get("id")
250
+ var method := str(request.get("method", ""))
251
+ if method.is_empty():
252
+ _send_error(id, ERR_INVALID_REQUEST, "missing method")
253
+ return
254
+ var params: Dictionary = {}
255
+ if typeof(request.get("params")) == TYPE_DICTIONARY:
256
+ params = request.get("params")
257
+
258
+ if method == "hello":
259
+ _handle_hello(id, params)
260
+ return
261
+ if not _authed:
262
+ # Token auth is on and this connection has not said hello yet.
263
+ _send_error(id, ERR_UNAUTHORIZED, "unauthorized: send hello{token} first")
264
+ _close_client()
265
+ return
266
+
267
+ if _extra_methods.has(method):
268
+ var out: Variant = (_extra_methods[method] as Callable).call(params)
269
+ _send_result(id, out)
270
+ return
271
+
272
+ match method:
273
+ "ping": _rpc_ping(id)
274
+ "get_state": _rpc_get_state(id, params)
275
+ "get_game_state": _rpc_get_game_state(id)
276
+ "press_action": _rpc_press_action(id, params)
277
+ "key": _rpc_key(id, params)
278
+ "click": _rpc_click(id, params)
279
+ "wait_frames": _rpc_wait_frames(id, params)
280
+ "screenshot": _rpc_screenshot(id, params)
281
+ "set_time_scale": _rpc_set_time_scale(id, params)
282
+ "list_actions": _send_result(id, {"actions": _action_names()})
283
+ "quit": _rpc_quit(id, params)
284
+ _: _send_error(id, ERR_METHOD_NOT_FOUND, "method not found: %s" % method)
285
+
286
+
287
+ func _handle_hello(id: Variant, params: Dictionary) -> void:
288
+ var problem := _check_params(params, {"token": "string"})
289
+ if not problem.is_empty():
290
+ _send_error(id, ERR_INVALID_PARAMS, problem)
291
+ return
292
+ if _token.is_empty():
293
+ _authed = true
294
+ _send_result(id, {"ok": true, "driver_version": DRIVER_VERSION, "auth": false})
295
+ return
296
+ if str(params.get("token", "")) != _token:
297
+ _send_error(id, ERR_UNAUTHORIZED, "unauthorized")
298
+ _close_client()
299
+ return
300
+ _authed = true
301
+ _send_result(id, {"ok": true, "driver_version": DRIVER_VERSION, "auth": true})
302
+
303
+
304
+ ## Lets a project or an extension script add its own method, e.g. a debug
305
+ ## "skip_to_level". Only reachable when the job config allows custom methods,
306
+ ## which the CLI decides, not the game.
307
+ func register_method(method_name: String, callable: Callable) -> void:
308
+ _extra_methods[method_name] = callable
309
+
310
+
311
+ ## --- Parameter guards ---------------------------------------------------------
312
+
313
+ ## A param of the wrong type is a client bug, and answering -32602 with the name
314
+ ## of the offending key is far easier to act on than a silent int(null) == 0.
315
+ const PARAM_TYPES := {
316
+ "number": [TYPE_INT, TYPE_FLOAT],
317
+ "string": [TYPE_STRING, TYPE_STRING_NAME],
318
+ "boolean": [TYPE_BOOL],
319
+ "array": [TYPE_ARRAY],
320
+ "keycode": [TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME],
321
+ }
322
+
323
+
324
+ ## Returns "" when every present param matches its declared kind, otherwise the
325
+ ## message to send back with -32602.
326
+ func _check_params(params: Dictionary, spec: Dictionary) -> String:
327
+ for key in spec:
328
+ if not params.has(key):
329
+ continue
330
+ var label := str(spec[key])
331
+ if typeof(params[key]) in PARAM_TYPES[label]:
332
+ continue
333
+ if label == "keycode":
334
+ return "keycode must be a key name or an integer Key value"
335
+ return "%s must be a %s" % [key, label]
336
+ return ""
337
+
338
+
339
+ ## --- Methods -----------------------------------------------------------------
340
+
341
+ func _rpc_ping(id: Variant) -> void:
342
+ var scene := get_tree().current_scene
343
+ _send_result(id, {
344
+ "ok": true,
345
+ "godot_version": Engine.get_version_info().get("string", ""),
346
+ "driver_version": DRIVER_VERSION,
347
+ "headless": _is_headless(),
348
+ "frame": Engine.get_process_frames(),
349
+ "time_scale": Engine.time_scale,
350
+ "main_scene": scene.scene_file_path if scene != null else "",
351
+ })
352
+
353
+
354
+ func _rpc_get_state(id: Variant, params: Dictionary) -> void:
355
+ var problem := _check_params(params, {"max_depth": "number", "root": "string", "visible_only": "boolean"})
356
+ if not problem.is_empty():
357
+ _send_error(id, ERR_INVALID_PARAMS, problem)
358
+ return
359
+ var max_depth: int = clampi(int(params.get("max_depth", DEFAULT_TREE_DEPTH)), 1, MAX_TREE_DEPTH)
360
+ var visible_only := bool(params.get("visible_only", true))
361
+ var root: Node = get_tree().root
362
+ if params.has("root"):
363
+ root = get_node_or_null(NodePath(str(params.get("root"))))
364
+ if root == null:
365
+ _send_error(id, ERR_NODE_NOT_FOUND, "node not found: %s" % str(params.get("root")))
366
+ return
367
+ var budget := {"left": MAX_NODES, "truncated": false}
368
+ var tree := _node_to_dict(root, 0, max_depth, visible_only, budget)
369
+ var scene := get_tree().current_scene
370
+ _send_result(id, {
371
+ "frame": Engine.get_process_frames(),
372
+ "current_scene": str(scene.get_path()) if scene != null else "",
373
+ "current_scene_file": scene.scene_file_path if scene != null else "",
374
+ "truncated": budget["truncated"],
375
+ "tree": tree,
376
+ })
377
+
378
+
379
+ func _rpc_get_game_state(id: Variant) -> void:
380
+ var states := {}
381
+ for node in get_tree().get_nodes_in_group(STATE_GROUP):
382
+ if not node.has_method("_ravensight_state"):
383
+ continue
384
+ var path := str(node.get_path())
385
+ var raw: Variant = node.call("_ravensight_state")
386
+ if typeof(raw) != TYPE_DICTIONARY:
387
+ _send_error(id, ERR_HOOK, "_ravensight_state() did not return a Dictionary", {"path": path})
388
+ return
389
+ var value: Variant = _jsonify(raw, 0)
390
+ if JSON.stringify(value).to_utf8_buffer().size() > MAX_HOOK_BYTES:
391
+ _send_error(id, ERR_HOOK, "_ravensight_state() result exceeds %d bytes" % MAX_HOOK_BYTES, {"path": path})
392
+ return
393
+ states[path] = value
394
+ _send_result(id, {"frame": Engine.get_process_frames(), "states": states})
395
+
396
+
397
+ func _rpc_press_action(id: Variant, params: Dictionary) -> void:
398
+ var problem := _check_params(params, {"action": "string", "duration_ms": "number"})
399
+ if not problem.is_empty():
400
+ _send_error(id, ERR_INVALID_PARAMS, problem)
401
+ return
402
+ if not params.has("action"):
403
+ _send_error(id, ERR_INVALID_PARAMS, "action is required")
404
+ return
405
+ var action := str(params.get("action", ""))
406
+ if not InputMap.has_action(action):
407
+ _send_error(id, ERR_ACTION_NOT_DEFINED, "input action not defined: %s" % action, {
408
+ "available_actions": _action_names(),
409
+ })
410
+ return
411
+ var duration_ms: int = clampi(int(params.get("duration_ms", 50)), 0, MAX_PRESS_MS)
412
+ Input.action_press(action)
413
+ var event := InputEventAction.new()
414
+ event.action = action
415
+ event.pressed = true
416
+ Input.parse_input_event(event)
417
+
418
+ # The hold is measured in game time so it scales with time_scale, per spec.
419
+ # A frozen clock (time_scale 0, how the CLI parks the game between turns)
420
+ # would never fire that timer and the call would hang, so the timer
421
+ # ignores the scale in exactly that case and the result says so.
422
+ var frozen := Engine.time_scale <= 0.0
423
+ if duration_ms > 0:
424
+ await get_tree().create_timer(duration_ms / 1000.0, true, false, frozen).timeout
425
+ Input.action_release(action)
426
+ var release := InputEventAction.new()
427
+ release.action = action
428
+ release.pressed = false
429
+ Input.parse_input_event(release)
430
+ _send_result(id, {
431
+ "ok": true,
432
+ "released_at_frame": Engine.get_process_frames(),
433
+ "ignored_time_scale": frozen,
434
+ })
435
+
436
+
437
+ func _rpc_key(id: Variant, params: Dictionary) -> void:
438
+ var problem := _check_params(params, {"keycode": "keycode", "pressed": "boolean", "unicode": "number"})
439
+ if not problem.is_empty():
440
+ _send_error(id, ERR_INVALID_PARAMS, problem)
441
+ return
442
+ var keycode := _resolve_keycode(params.get("keycode"))
443
+ if keycode == KEY_NONE:
444
+ _send_error(id, ERR_INVALID_PARAMS, "unknown keycode: %s" % str(params.get("keycode")))
445
+ return
446
+ var event := InputEventKey.new()
447
+ event.keycode = keycode
448
+ event.physical_keycode = keycode
449
+ event.pressed = bool(params.get("pressed", true))
450
+ var unicode := int(params.get("unicode", 0))
451
+ if unicode == 0:
452
+ unicode = _unicode_for(params.get("keycode"))
453
+ if unicode != 0:
454
+ event.unicode = unicode
455
+ Input.parse_input_event(event)
456
+ _send_result(id, {"ok": true, "keycode": keycode, "pressed": event.pressed})
457
+
458
+
459
+ func _rpc_click(id: Variant, params: Dictionary) -> void:
460
+ var problem := _check_params(params, {
461
+ "x": "number",
462
+ "y": "number",
463
+ "node_path": "string",
464
+ "button": "string",
465
+ "double": "boolean",
466
+ })
467
+ if not problem.is_empty():
468
+ _send_error(id, ERR_INVALID_PARAMS, problem)
469
+ return
470
+ var pos := Vector2.ZERO
471
+ if params.has("node_path"):
472
+ var target := get_node_or_null(NodePath(str(params.get("node_path"))))
473
+ if target == null or not (target is Control):
474
+ _send_error(id, ERR_NODE_NOT_FOUND, "clickable node not found: %s" % str(params.get("node_path")))
475
+ return
476
+ pos = (target as Control).get_global_rect().get_center()
477
+ else:
478
+ pos = Vector2(float(params.get("x", 0.0)), float(params.get("y", 0.0)))
479
+
480
+ var button := MOUSE_BUTTON_LEFT
481
+ match str(params.get("button", "left")):
482
+ "right": button = MOUSE_BUTTON_RIGHT
483
+ "middle": button = MOUSE_BUTTON_MIDDLE
484
+
485
+ var motion := InputEventMouseMotion.new()
486
+ motion.position = pos
487
+ motion.global_position = pos
488
+ Input.parse_input_event(motion)
489
+
490
+ for pressed in [true, false]:
491
+ var click := InputEventMouseButton.new()
492
+ click.button_index = button
493
+ click.position = pos
494
+ click.global_position = pos
495
+ click.pressed = pressed
496
+ click.double_click = pressed and bool(params.get("double", false))
497
+ Input.parse_input_event(click)
498
+ _send_result(id, {"ok": true, "position": [pos.x, pos.y]})
499
+
500
+
501
+ func _rpc_wait_frames(id: Variant, params: Dictionary) -> void:
502
+ var problem := _check_params(params, {"n": "number"})
503
+ if not problem.is_empty():
504
+ _send_error(id, ERR_INVALID_PARAMS, problem)
505
+ return
506
+ var frames: int = clampi(int(params.get("n", 1)), 1, MAX_WAIT_FRAMES)
507
+ _pending_waits.append({"id": id, "left": frames})
508
+
509
+
510
+ func _rpc_screenshot(id: Variant, params: Dictionary) -> void:
511
+ var problem := _check_params(params, {"max_width": "number", "region": "array"})
512
+ if not problem.is_empty():
513
+ _send_error(id, ERR_INVALID_PARAMS, problem)
514
+ return
515
+ if params.has("region") and not _is_number_quad(params.get("region")):
516
+ _send_error(id, ERR_INVALID_PARAMS, "region must be four numbers: [x, y, width, height]")
517
+ return
518
+ if _is_headless():
519
+ _send_error(id, ERR_HEADLESS, "screenshot unavailable in headless mode")
520
+ return
521
+ await RenderingServer.frame_post_draw
522
+ var image := get_viewport().get_texture().get_image()
523
+ if image == null:
524
+ _send_error(id, ERR_HEADLESS, "viewport produced no image")
525
+ return
526
+ if params.has("region"):
527
+ var region: Array = params.get("region")
528
+ image = image.get_region(Rect2i(int(region[0]), int(region[1]), int(region[2]), int(region[3])))
529
+ var max_width: int = maxi(int(params.get("max_width", 1280)), 1)
530
+ if image.get_width() > max_width:
531
+ var height := maxi(1, int(round(image.get_height() * float(max_width) / image.get_width())))
532
+ image.resize(max_width, height, Image.INTERPOLATE_BILINEAR)
533
+ _send_result(id, {
534
+ "format": "png",
535
+ "width": image.get_width(),
536
+ "height": image.get_height(),
537
+ "data": Marshalls.raw_to_base64(image.save_png_to_buffer()),
538
+ })
539
+
540
+
541
+ func _rpc_set_time_scale(id: Variant, params: Dictionary) -> void:
542
+ var problem := _check_params(params, {"scale": "number", "adjust_physics": "boolean"})
543
+ if not problem.is_empty():
544
+ _send_error(id, ERR_INVALID_PARAMS, problem)
545
+ return
546
+ var previous := Engine.time_scale
547
+ var scale := clampf(float(params.get("scale", 1.0)), 0.0, 16.0)
548
+ Engine.time_scale = scale
549
+ if bool(params.get("adjust_physics", false)) and scale > 0.0:
550
+ Engine.physics_ticks_per_second = maxi(1, int(round(60.0 * scale)))
551
+ _send_result(id, {"time_scale": scale, "previous": previous})
552
+
553
+
554
+ func _rpc_quit(id: Variant, params: Dictionary) -> void:
555
+ var problem := _check_params(params, {"code": "number"})
556
+ if not problem.is_empty():
557
+ _send_error(id, ERR_INVALID_PARAMS, problem)
558
+ return
559
+ var code := int(params.get("code", 0))
560
+ _send_result(id, {"ok": true})
561
+ if _peer != null:
562
+ _peer.poll()
563
+ await get_tree().process_frame
564
+ get_tree().quit(code)
565
+
566
+
567
+ func _is_number_quad(value: Variant) -> bool:
568
+ if typeof(value) != TYPE_ARRAY:
569
+ return false
570
+ var list: Array = value
571
+ if list.size() != 4:
572
+ return false
573
+ for item in list:
574
+ if typeof(item) != TYPE_INT and typeof(item) != TYPE_FLOAT:
575
+ return false
576
+ return true
577
+
578
+
579
+ func _action_names() -> Array:
580
+ var game: Array = []
581
+ var builtin: Array = []
582
+ for action in InputMap.get_actions():
583
+ var action_name := str(action)
584
+ if action_name.begins_with("ui_"):
585
+ builtin.append(action_name)
586
+ else:
587
+ game.append(action_name)
588
+ game.sort()
589
+ builtin.sort()
590
+ return game + builtin
591
+
592
+
593
+ ## --- Scene tree snapshot ------------------------------------------------------
594
+
595
+ ## Serializes one node and its children. Every entry carries "path", plus
596
+ ## "scene" and "script" whenever the node owns a scene file or a script, so a
597
+ ## finding in the report can cite the file a developer has to open.
598
+ func _node_to_dict(node: Node, depth: int, max_depth: int, visible_only: bool, budget: Dictionary) -> Dictionary:
599
+ var out := {
600
+ "name": str(node.name),
601
+ "type": node.get_class(),
602
+ "path": str(node.get_path()),
603
+ }
604
+ if not node.scene_file_path.is_empty():
605
+ out["scene"] = node.scene_file_path
606
+ var script: Variant = node.get_script()
607
+ if script != null and script is Script and not (script as Script).resource_path.is_empty():
608
+ out["script"] = (script as Script).resource_path
609
+
610
+ if node is CanvasItem:
611
+ out["visible"] = (node as CanvasItem).is_visible_in_tree()
612
+ elif node is Node3D:
613
+ out["visible"] = (node as Node3D).is_visible_in_tree()
614
+
615
+ var text := _text_of(node)
616
+ if not text.is_empty():
617
+ out["text"] = text.substr(0, MAX_TEXT_CHARS)
618
+
619
+ if node is Node2D:
620
+ var p2: Vector2 = (node as Node2D).global_position
621
+ out["position"] = [p2.x, p2.y]
622
+ elif node is Node3D:
623
+ var p3: Vector3 = (node as Node3D).global_position
624
+ out["position"] = [p3.x, p3.y, p3.z]
625
+ elif node is Control:
626
+ var rect: Rect2 = (node as Control).get_global_rect()
627
+ out["position"] = [rect.position.x, rect.position.y]
628
+ out["rect"] = [rect.position.x, rect.position.y, rect.size.x, rect.size.y]
629
+ out["focus"] = (node as Control).has_focus()
630
+ if node is BaseButton:
631
+ out["disabled"] = (node as BaseButton).disabled
632
+
633
+ var groups: Array = []
634
+ for group in node.get_groups():
635
+ var group_name := str(group)
636
+ if not group_name.begins_with("_"):
637
+ groups.append(group_name)
638
+ out["groups"] = groups
639
+
640
+ var children: Array = []
641
+ if depth < max_depth:
642
+ for child in node.get_children():
643
+ if budget["left"] <= 0:
644
+ budget["truncated"] = true
645
+ break
646
+ if _is_driver_node(child):
647
+ continue
648
+ if visible_only and not _is_visible_candidate(child):
649
+ continue
650
+ budget["left"] = int(budget["left"]) - 1
651
+ children.append(_node_to_dict(child, depth + 1, max_depth, visible_only, budget))
652
+ elif node.get_child_count() > 0:
653
+ # A depth cut is not truncation: the caller chose max_depth and gets
654
+ # child_count so it knows what it chose not to see. "truncated" is
655
+ # reserved for the node budget running out, which the caller did not ask
656
+ # for and has to react to.
657
+ out["child_count"] = node.get_child_count()
658
+ out["children"] = children
659
+ return out
660
+
661
+
662
+ ## The driver's own autoload is not part of the game. Leaving it in the snapshot
663
+ ## would put the driver's script path in the list of scripts a report cites.
664
+ func _is_driver_node(node: Node) -> bool:
665
+ var script: Variant = node.get_script()
666
+ if script == null or not (script is Script):
667
+ return false
668
+ return (script as Script).resource_path.begins_with("res://addons/" + ADDON_DIR + "/")
669
+
670
+
671
+ func _is_visible_candidate(node: Node) -> bool:
672
+ if node is CanvasItem:
673
+ return (node as CanvasItem).is_visible_in_tree()
674
+ if node is Node3D:
675
+ return (node as Node3D).is_visible_in_tree()
676
+ return true
677
+
678
+
679
+ func _text_of(node: Node) -> String:
680
+ if node is RichTextLabel:
681
+ return (node as RichTextLabel).get_parsed_text()
682
+ if node is Label:
683
+ return (node as Label).text
684
+ if node is Button:
685
+ return (node as Button).text
686
+ if node is LineEdit:
687
+ return (node as LineEdit).text
688
+ if node is TextEdit:
689
+ return (node as TextEdit).text
690
+ return ""
691
+
692
+
693
+ ## --- Author hook serialization ------------------------------------------------
694
+
695
+ ## Converts a _ravensight_state() result into JSON-safe values: vectors become
696
+ ## arrays, everything unrecognized becomes its str() form, and recursion stops
697
+ ## at MAX_STATE_DEPTH so a cyclic structure cannot hang the game.
698
+ func _jsonify(value: Variant, depth: int) -> Variant:
699
+ if depth >= MAX_STATE_DEPTH:
700
+ return str(value)
701
+ match typeof(value):
702
+ TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_STRING_NAME:
703
+ return value if typeof(value) != TYPE_STRING_NAME else str(value)
704
+ TYPE_VECTOR2, TYPE_VECTOR2I:
705
+ return [value.x, value.y]
706
+ TYPE_VECTOR3, TYPE_VECTOR3I:
707
+ return [value.x, value.y, value.z]
708
+ TYPE_DICTIONARY:
709
+ var out := {}
710
+ for key in (value as Dictionary):
711
+ out[str(key)] = _jsonify((value as Dictionary)[key], depth + 1)
712
+ return out
713
+ TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY, TYPE_PACKED_INT32_ARRAY, TYPE_PACKED_INT64_ARRAY, TYPE_PACKED_FLOAT32_ARRAY, TYPE_PACKED_FLOAT64_ARRAY:
714
+ var list: Array = []
715
+ for item in value:
716
+ list.append(_jsonify(item, depth + 1))
717
+ return list
718
+ _:
719
+ return str(value)
720
+
721
+
722
+ ## --- Keycodes -----------------------------------------------------------------
723
+
724
+ func _resolve_keycode(raw: Variant) -> int:
725
+ if typeof(raw) == TYPE_INT or typeof(raw) == TYPE_FLOAT:
726
+ return int(raw)
727
+ # A single character is resolved before any trimming. strip_edges() turns
728
+ # " " into "", so typing a sentence used to fail on its first space.
729
+ var literal := str(raw)
730
+ if literal.length() == 1:
731
+ if literal == " ":
732
+ return KEY_SPACE
733
+ if literal == "\t":
734
+ return KEY_TAB
735
+ if literal == "\n" or literal == "\r":
736
+ return KEY_ENTER
737
+ return literal.to_upper().unicode_at(0)
738
+ var key_name := literal.strip_edges()
739
+ if key_name.is_empty():
740
+ return KEY_NONE
741
+ var found := OS.find_keycode_from_string(key_name)
742
+ if found != KEY_NONE:
743
+ return found
744
+ if key_name.begins_with("KEY_"):
745
+ found = OS.find_keycode_from_string(key_name.substr(4))
746
+ if found != KEY_NONE:
747
+ return found
748
+ var aliases := {
749
+ "enter": KEY_ENTER,
750
+ "return": KEY_ENTER,
751
+ "esc": KEY_ESCAPE,
752
+ "escape": KEY_ESCAPE,
753
+ "space": KEY_SPACE,
754
+ "tab": KEY_TAB,
755
+ "backspace": KEY_BACKSPACE,
756
+ "arrowup": KEY_UP,
757
+ "arrowdown": KEY_DOWN,
758
+ "arrowleft": KEY_LEFT,
759
+ "arrowright": KEY_RIGHT,
760
+ }
761
+ var lowered := key_name.to_lower()
762
+ if aliases.has(lowered):
763
+ return int(aliases[lowered])
764
+ if key_name.length() == 1:
765
+ return key_name.to_upper().unicode_at(0)
766
+ return KEY_NONE
767
+
768
+
769
+ ## A single printable character also carries its unicode, so text actually
770
+ ## lands in a LineEdit rather than only firing a raw key handler.
771
+ func _unicode_for(raw: Variant) -> int:
772
+ if typeof(raw) != TYPE_STRING and typeof(raw) != TYPE_STRING_NAME:
773
+ return 0
774
+ var literal := str(raw)
775
+ if literal.length() != 1:
776
+ return 0
777
+ return literal.unicode_at(0)
778
+
779
+
780
+ ## --- Frame bookkeeping --------------------------------------------------------
781
+
782
+ func _tick_waits() -> void:
783
+ if _pending_waits.is_empty():
784
+ return
785
+ var still_waiting: Array = []
786
+ for wait in _pending_waits:
787
+ wait["left"] = int(wait["left"]) - 1
788
+ if int(wait["left"]) <= 0:
789
+ _send_result(wait["id"], {"frame": Engine.get_process_frames()})
790
+ else:
791
+ still_waiting.append(wait)
792
+ _pending_waits = still_waiting
793
+
794
+
795
+ func _check_scene_change() -> void:
796
+ var scene := get_tree().current_scene
797
+ var path := scene.scene_file_path if scene != null else ""
798
+ if path == _last_scene_path:
799
+ return
800
+ _last_scene_path = path
801
+ if path.is_empty():
802
+ # Mid transition change_scene_to_file() leaves current_scene null for a
803
+ # frame. Reporting that as a scene change would tell the persona the
804
+ # screen went blank, so only the arrival is announced.
805
+ return
806
+ _notify("scene_changed", {"scene": path, "node": str(scene.get_path()) if scene != null else ""})
807
+
808
+
809
+ func _is_headless() -> bool:
810
+ return DisplayServer.get_name() == "headless"
811
+
812
+
813
+ ## --- Wire format --------------------------------------------------------------
814
+
815
+ func _send_result(id: Variant, result: Variant) -> void:
816
+ _send({"jsonrpc": "2.0", "id": id, "result": result})
817
+
818
+
819
+ func _send_error(id: Variant, code: int, message: String, data: Variant = null) -> void:
820
+ var error := {"code": code, "message": message}
821
+ if data != null:
822
+ error["data"] = data
823
+ _send({"jsonrpc": "2.0", "id": id, "error": error})
824
+
825
+
826
+ func _notify(type: String, params: Dictionary) -> void:
827
+ var body := params.duplicate()
828
+ body["type"] = type
829
+ body["frame"] = Engine.get_process_frames()
830
+ _send({"jsonrpc": "2.0", "method": "event", "params": body})
831
+
832
+
833
+ func _send(message: Dictionary) -> void:
834
+ if _peer == null or _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
835
+ return
836
+ _peer.put_data((JSON.stringify(message) + "\n").to_utf8_buffer())