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.
- package/LICENSE +21 -0
- package/README.md +380 -0
- package/addons/ravensight_driver/driver.gd +836 -0
- package/addons/ravensight_driver/export_plugin.gd +51 -0
- package/addons/ravensight_driver/plugin.cfg +7 -0
- package/addons/ravensight_driver/plugin.gd +36 -0
- package/bin/ravensight-playtest.js +31 -0
- package/package.json +45 -0
- package/src/api/README.md +500 -0
- package/src/api/client.js +340 -0
- package/src/api/errors.js +115 -0
- package/src/api/http.js +194 -0
- package/src/api/index.js +107 -0
- package/src/auth/deviceCode.js +79 -0
- package/src/auth/keychain.js +159 -0
- package/src/auth/session.js +128 -0
- package/src/cli.js +335 -0
- package/src/commands/brief.js +303 -0
- package/src/commands/check.js +318 -0
- package/src/commands/fakeCore.js +379 -0
- package/src/commands/init.js +120 -0
- package/src/commands/login.js +90 -0
- package/src/commands/logout.js +70 -0
- package/src/commands/open.js +125 -0
- package/src/commands/profile.js +262 -0
- package/src/commands/resume.js +156 -0
- package/src/commands/run.js +1015 -0
- package/src/commands/upload.js +137 -0
- package/src/config.js +100 -0
- package/src/dashboard.js +97 -0
- package/src/detect.js +77 -0
- package/src/errors.js +44 -0
- package/src/fsutil.js +77 -0
- package/src/godot.js +85 -0
- package/src/packs/index.js +191 -0
- package/src/paths.js +129 -0
- package/src/run/aggregate.js +658 -0
- package/src/run/args.js +111 -0
- package/src/run/context.js +181 -0
- package/src/run/deps.js +184 -0
- package/src/run/drivers/driver.js +183 -0
- package/src/run/drivers/godot-observation.js +138 -0
- package/src/run/drivers/godot-project.js +475 -0
- package/src/run/drivers/godot-rpc.js +225 -0
- package/src/run/drivers/godot.js +587 -0
- package/src/run/drivers/index.js +52 -0
- package/src/run/drivers/web.js +385 -0
- package/src/run/exit.js +21 -0
- package/src/run/heartbeat.js +131 -0
- package/src/run/index.js +31 -0
- package/src/run/json.js +56 -0
- package/src/run/model.js +384 -0
- package/src/run/paths.js +88 -0
- package/src/run/personaLoop.js +871 -0
- package/src/run/profile.js +214 -0
- package/src/run/regenerate.js +149 -0
- package/src/run/repoTools.js +286 -0
- package/src/run/report.js +222 -0
- package/src/run/resume.js +272 -0
- package/src/run/secretScan.js +171 -0
- package/src/run/state.js +198 -0
- package/src/run/synthetic.js +206 -0
- package/src/run/tools.js +344 -0
- package/src/run/transcript.js +93 -0
- package/src/run/usage.js +115 -0
- package/src/state/index.js +105 -0
- package/src/states.js +104 -0
- package/src/ui/index.js +195 -0
- package/src/upload/allowlist.js +116 -0
- package/src/upload/index.js +467 -0
- package/src/upload/queue.js +114 -0
- 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())
|