gdharness 0.12.17 → 0.13.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 (36) hide show
  1. package/build/cli.js +174 -56
  2. package/build/godot/addons/auto_reload/auto_reload.gd +9 -2
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +11 -5
  4. package/build/godot/addons/gdharness_editor/plugin.gd +17 -4
  5. package/build/godot/addons/gdharness_editor/reading.gd +64 -0
  6. package/build/godot/addons/gdharness_editor/reading.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +30 -12
  8. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +5 -2
  9. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +3 -1
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +50 -27
  11. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +92 -42
  12. package/build/godot/addons/gdharness_runtime/reading.gd +64 -0
  13. package/build/godot/addons/gdharness_runtime/reading.gd.uid +1 -0
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +24 -10
  15. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +7 -5
  16. package/build/godot/addons/gdharness_runtime/runtime_input.gd +24 -22
  17. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +16 -13
  18. package/build/godot/addons/gdharness_runtime/runtime_values.gd +24 -8
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +7 -4
  20. package/build/godot/operations/audio_buses.gd +7 -6
  21. package/build/godot/operations/class_cache.gd +14 -9
  22. package/build/godot/operations/classdb_queries.gd +4 -3
  23. package/build/godot/operations/dependencies.gd +19 -16
  24. package/build/godot/operations/file_walk.gd +4 -2
  25. package/build/godot/operations/gdscript_analysis.gd +2 -3
  26. package/build/godot/operations/gdscript_authoring.gd +20 -8
  27. package/build/godot/operations/godot_operations.gd +7 -2
  28. package/build/godot/operations/import_pipeline.gd +5 -4
  29. package/build/godot/operations/input_actions.gd +13 -9
  30. package/build/godot/operations/patterns.gd +22 -0
  31. package/build/godot/operations/plugins.gd +10 -6
  32. package/build/godot/operations/project_config.gd +41 -3
  33. package/build/godot/operations/reading.gd +66 -0
  34. package/build/godot/operations/serialisation.gd +41 -9
  35. package/build/index.js +147 -53
  36. package/package.json +1 -1
@@ -13,6 +13,7 @@ extends Node
13
13
  const Capture = preload("runtime_capture.gd")
14
14
  const InputCommands = preload("runtime_input.gd")
15
15
  const Queries = preload("runtime_queries.gd")
16
+ const Read = preload("reading.gd")
16
17
  const Values = preload("runtime_values.gd")
17
18
  const Waits = preload("runtime_waits.gd")
18
19
 
@@ -110,8 +111,10 @@ func _process(_delta: float) -> void:
110
111
 
111
112
  var gone: Array[StreamPeerTCP] = []
112
113
  for client: StreamPeerTCP in _clients:
113
- client.poll()
114
- if client.get_status() != StreamPeerTCP.STATUS_CONNECTED:
114
+ # A poll that fails is a client that has already gone, which the status check below is
115
+ # about to find; the answer is read so it is not dropped, not because it adds anything.
116
+ var polled: Error = client.poll()
117
+ if polled != OK or client.get_status() != StreamPeerTCP.STATUS_CONNECTED:
115
118
  gone.append(client)
116
119
  continue
117
120
  var available: int = client.get_available_bytes()
@@ -124,8 +127,11 @@ func _process(_delta: float) -> void:
124
127
 
125
128
  for client: StreamPeerTCP in gone:
126
129
  _clients.erase(client)
127
- _pending.erase(client)
128
- _outgoing.erase(client)
130
+ # Dictionary.erase answers with whether the key was there, and underscore-prefixed locals
131
+ # are how GDScript says an answer is deliberately unwanted: a client that had nothing
132
+ # pending or owed is as forgotten as one that had both.
133
+ var _had_pending: bool = _pending.erase(client)
134
+ var _had_outgoing: bool = _outgoing.erase(client)
129
135
 
130
136
 
131
137
  ## Hands the socket as much of what it is owed as it will take, and says whether it is still worth
@@ -138,7 +144,7 @@ func _drain(client: StreamPeerTCP) -> bool:
138
144
  var sent: Array = client.put_partial_data(owed)
139
145
  if sent[0] != OK:
140
146
  return false
141
- _outgoing[client] = owed.slice(int(sent[1]))
147
+ _outgoing[client] = owed.slice(Read.as_int(sent[1]))
142
148
  return true
143
149
 
144
150
 
@@ -172,14 +178,14 @@ func _start_server() -> void:
172
178
  # own path: a gate that starts sixteen engines at once announces sixteen games that are not
173
179
  # games, and a client asking the runtime anything while they run can be answered by whichever
174
180
  # of them replies first. Measured on two projects before it was written here.
175
- if _script_run() and not bool(ProjectSettings.get_setting(SCRIPT_RUNS_SETTING, false)):
181
+ if _script_run() and not Read.as_bool(ProjectSettings.get_setting(SCRIPT_RUNS_SETTING, false)):
176
182
  _enabled = false
177
183
  return
178
184
 
179
185
  _server = TCPServer.new()
180
186
  # listen() defaults bind_address to "*", which exposes the game to the whole network.
181
187
  var bind_address: String = str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
182
- var wanted_port: int = int(ProjectSettings.get_setting(PORT_SETTING, 0))
188
+ var wanted_port: int = Read.as_int(ProjectSettings.get_setting(PORT_SETTING, 0))
183
189
  var error: Error = _server.listen(wanted_port, bind_address)
184
190
  if error != OK:
185
191
  # A warning, not an error: callers treat any ERROR line on stderr as a failed
@@ -233,8 +239,11 @@ func _announce(bind_address: String) -> void:
233
239
  )
234
240
  )
235
241
  return
236
- file.store_string(JSON.stringify(_identity(bind_address)))
242
+ var announced: bool = file.store_string(JSON.stringify(_identity(bind_address)))
237
243
  file.close()
244
+ if not announced:
245
+ push_warning("[gdharness] cannot write %s; the server will not find this game" % path)
246
+ return
238
247
  _announcement = path
239
248
 
240
249
 
@@ -286,11 +295,12 @@ func _handle_message(client: StreamPeerTCP, line: String) -> void:
286
295
  if not params is Dictionary:
287
296
  _send_error(client, request_id, "params must be an object")
288
297
  return
298
+ var arguments: Dictionary = params
289
299
 
290
300
  # A command that waits on the game (a frame, a signal, a condition) suspends here and answers
291
301
  # when it is done, while _process keeps serving the other clients in the meantime. A client
292
302
  # that hung up while its command waited is simply not written to.
293
- var result: Dictionary = await _execute_command(command, params)
303
+ var result: Dictionary = await _execute_command(command, arguments)
294
304
  result["id"] = request_id
295
305
  if client.get_status() == StreamPeerTCP.STATUS_CONNECTED:
296
306
  _send_response(client, result)
@@ -346,5 +356,9 @@ func _cleanup() -> void:
346
356
  # The announcement is what tells the server this game exists, so it goes before the
347
357
  # process does. A crash leaves it behind, and the server drops one whose process is gone.
348
358
  if not _announcement.is_empty():
349
- DirAccess.remove_absolute(_announcement)
359
+ var removed: Error = DirAccess.remove_absolute(_announcement)
360
+ if removed != OK:
361
+ push_warning(
362
+ "[gdharness] cannot remove %s; the server will find a game that is gone" % _announcement
363
+ )
350
364
  _announcement = ""
@@ -2,6 +2,7 @@ extends RefCounted
2
2
 
3
3
  ## A picture of the running game, as a PNG written where the server asked for it.
4
4
 
5
+ const Read = preload("reading.gd")
5
6
  const Values = preload("runtime_values.gd")
6
7
 
7
8
  var _host: Node
@@ -16,7 +17,7 @@ func capture_screenshot(params: Dictionary) -> Dictionary:
16
17
 
17
18
 
18
19
  func capture_viewport(params: Dictionary) -> Dictionary:
19
- var viewport_path: String = String(params.get("viewportPath", ""))
20
+ var viewport_path: String = str(params.get("viewportPath", ""))
20
21
  if viewport_path.is_empty():
21
22
  return capture_screenshot(params)
22
23
 
@@ -26,13 +27,14 @@ func capture_viewport(params: Dictionary) -> Dictionary:
26
27
  var node: Node = standing["node"]
27
28
  if not node is Viewport:
28
29
  return {"type": "error", "message": "Node is not a Viewport: " + viewport_path}
29
- return _capture(node, params)
30
+ var viewport: Viewport = node
31
+ return _capture(viewport, params)
30
32
 
31
33
 
32
34
  ## The server names the file, so a game cannot point it at a path of its own choosing; a call
33
35
  ## with no path is a call the server did not make.
34
36
  func _capture(viewport: Viewport, params: Dictionary) -> Dictionary:
35
- var requested_path: String = String(params.get("output_path", ""))
37
+ var requested_path: String = str(params.get("output_path", ""))
36
38
  if requested_path.is_empty():
37
39
  return {"type": "error", "message": "output_path required"}
38
40
 
@@ -59,8 +61,8 @@ func _capture(viewport: Viewport, params: Dictionary) -> Dictionary:
59
61
  if image == null:
60
62
  return {"type": "error", "message": "Failed to capture viewport image"}
61
63
 
62
- var width: int = int(params.get("width", 0))
63
- var height: int = int(params.get("height", 0))
64
+ var width: int = Read.as_int(params.get("width", 0))
65
+ var height: int = Read.as_int(params.get("height", 0))
64
66
  if width > 0 and height > 0:
65
67
  image.resize(width, height)
66
68
 
@@ -9,6 +9,7 @@ const Values = preload("runtime_values.gd")
9
9
  ## query module rather than worked out again here, so the place this aims at and the place a rect
10
10
  ## reports are the same place by construction rather than by agreement.
11
11
  const Queries = preload("runtime_queries.gd")
12
+ const Read = preload("reading.gd")
12
13
 
13
14
  ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
14
15
  const TO_SMALL: int = 32
@@ -40,10 +41,10 @@ func _init(host: Node, values: Values) -> void:
40
41
  ##
41
42
  ## `pressed` is how a caller says otherwise: true holds it down, false lets go of one being held.
42
43
  func inject_action(params: Dictionary) -> Dictionary:
43
- var action: String = String(params.get("action", ""))
44
- var held: bool = bool(params.get("pressed", true))
44
+ var action: String = str(params.get("action", ""))
45
+ var held: bool = Read.as_bool(params.get("pressed", true), true)
45
46
  var whole: bool = not params.has("pressed")
46
- var strength: float = float(params.get("strength", 1.0))
47
+ var strength: float = Read.as_float(params.get("strength", 1.0), 1.0)
47
48
 
48
49
  if action.is_empty():
49
50
  return {"type": "error", "message": "Action name required"}
@@ -79,15 +80,15 @@ func _say_action(action: String, down: bool, strength: float) -> void:
79
80
  ## Presses one key, and lets go of it again unless asked to hold it. See [method inject_action].
80
81
  func inject_key(params: Dictionary) -> Dictionary:
81
82
  var keycode_raw: Variant = params.get("keycode", 0)
82
- var held: bool = bool(params.get("pressed", true))
83
+ var held: bool = Read.as_bool(params.get("pressed", true), true)
83
84
  var whole: bool = not params.has("pressed")
84
- var key_label: String = String(params.get("key_label", ""))
85
+ var key_label: String = str(params.get("key_label", ""))
85
86
 
86
87
  if keycode_raw is String:
87
88
  var named: String = keycode_raw
88
89
  if not named.is_empty() and key_label.is_empty():
89
90
  key_label = named
90
- var keycode: int = 0 if keycode_raw is String else int(keycode_raw)
91
+ var keycode: int = 0 if keycode_raw is String else Read.as_int(keycode_raw)
91
92
 
92
93
  var event: InputEventKey = InputEventKey.new()
93
94
  event.pressed = whole or held
@@ -109,9 +110,9 @@ func inject_key(params: Dictionary) -> Dictionary:
109
110
  event.physical_keycode = event.keycode
110
111
  event.key_label = event.keycode
111
112
 
112
- event.shift_pressed = bool(params.get("shift", false))
113
- event.ctrl_pressed = bool(params.get("ctrl", false))
114
- event.alt_pressed = bool(params.get("alt", false))
113
+ event.shift_pressed = Read.as_bool(params.get("shift", false))
114
+ event.ctrl_pressed = Read.as_bool(params.get("ctrl", false))
115
+ event.alt_pressed = Read.as_bool(params.get("alt", false))
115
116
 
116
117
  # The fourth thing a real event carries, and the only one a text field reads: LineEdit and
117
118
  # TextEdit insert `unicode` and never consult the keycode, so an injected key could press any
@@ -161,8 +162,8 @@ func inject_key(params: Dictionary) -> Dictionary:
161
162
  ## 2.1, and the answer said four characters had gone in. What the field holds afterwards is read
162
163
  ## back now rather than echoed from the request, so a call that typed somewhere unhelpful says so.
163
164
  func inject_text(params: Dictionary) -> Dictionary:
164
- var text: String = String(params.get("text", ""))
165
- var over: bool = bool(params.get("replace", false))
165
+ var text: String = str(params.get("text", ""))
166
+ var over: bool = Read.as_bool(params.get("replace", false))
166
167
  if text.is_empty() and not over:
167
168
  return {"type": "error", "message": "text needs something to type, or replace to empty a field"}
168
169
 
@@ -305,7 +306,7 @@ func _glyph_of(keycode: Key, shifted: bool) -> int:
305
306
  ## Answers a Vector2, or the String that says what was wrong with it.
306
307
  func _read_point(params: Dictionary, x_key: String, y_key: String, pair_key: String) -> Variant:
307
308
  if params.has(x_key) and params.has(y_key):
308
- return Vector2(float(params[x_key]), float(params[y_key]))
309
+ return Vector2(Read.as_float(params[x_key]), Read.as_float(params[y_key]))
309
310
  var raw: Variant = params.get(pair_key, Vector2.ZERO)
310
311
  if raw is Vector2:
311
312
  return raw
@@ -313,7 +314,7 @@ func _read_point(params: Dictionary, x_key: String, y_key: String, pair_key: Str
313
314
  var pair: Array = raw
314
315
  if pair.size() < 2:
315
316
  return "%s array must contain [x, y]" % pair_key
316
- return Vector2(float(pair[0]), float(pair[1]))
317
+ return Vector2(Read.as_float(pair[0]), Read.as_float(pair[1]))
317
318
  return "%s must be Vector2 or [x, y]" % pair_key
318
319
 
319
320
 
@@ -325,8 +326,8 @@ func inject_mouse_click(params: Dictionary) -> Dictionary:
325
326
  var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
326
327
  if button < 0:
327
328
  return _no_such_button(params.get("button"))
328
- var pressed: bool = bool(params.get("pressed", true))
329
- var double: bool = bool(params.get("doubleClick", false))
329
+ var pressed: bool = Read.as_bool(params.get("pressed", true), true)
330
+ var double: bool = Read.as_bool(params.get("doubleClick", false))
330
331
 
331
332
  Input.parse_input_event(_button(position, button, pressed, double))
332
333
 
@@ -445,7 +446,8 @@ func click(params: Dictionary) -> Dictionary:
445
446
  return standing
446
447
  var node: Node = standing["node"]
447
448
  if node is Node3D:
448
- return await _click_in_the_world(node_path, node, params)
449
+ var spatial: Node3D = node
450
+ return await _click_in_the_world(node_path, spatial, params)
449
451
  if not node is Control:
450
452
  return {
451
453
  "type": "error",
@@ -495,7 +497,7 @@ func click(params: Dictionary) -> Dictionary:
495
497
  var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
496
498
  if button < 0:
497
499
  return _no_such_button(params.get("button"))
498
- var double: bool = bool(params.get("double", false))
500
+ var double: bool = Read.as_bool(params.get("double", false))
499
501
 
500
502
  # Pushed into the viewport rather than through Input: Input accumulates events and flushes
501
503
  # them at the next frame, so the hovered control read below would be the one from before
@@ -672,7 +674,7 @@ static func _menu_of(node: Node) -> PopupMenu:
672
674
  ## Minus one when neither names one that is there.
673
675
  static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
674
676
  if params.has("index"):
675
- var asked: int = int(params.get("index", -1))
677
+ var asked: int = Read.as_int(params.get("index", -1), -1)
676
678
  return asked if asked >= 0 and asked < menu.get_item_count() else -1
677
679
  var wanted: String = str(params.get("text", ""))
678
680
  if wanted.is_empty():
@@ -687,8 +689,8 @@ static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
687
689
 
688
690
 
689
691
  ## What the menu says, for a refusal that names the choices rather than the miss.
690
- static func _items_of(menu: PopupMenu) -> PackedStringArray:
691
- var said: PackedStringArray = PackedStringArray()
692
+ static func _items_of(menu: PopupMenu) -> Array[String]:
693
+ var said: Array[String] = []
692
694
  for index: int in menu.get_item_count():
693
695
  said.append("%d: %s" % [index, menu.get_item_text(index)])
694
696
  return said
@@ -758,7 +760,7 @@ func _click_in_the_world(node_path: String, item: Node3D, params: Dictionary) ->
758
760
  var button: int = _resolve_mouse_button(params.get("button", MOUSE_BUTTON_LEFT))
759
761
  if button < 0:
760
762
  return _no_such_button(params.get("button"))
761
- var double: bool = bool(params.get("double", false))
763
+ var double: bool = Read.as_bool(params.get("double", false))
762
764
 
763
765
  viewport.push_input(_motion(position, Vector2.ZERO))
764
766
  # Read before the press, for the reason the Control click reads it: what the caller needs to
@@ -839,4 +841,4 @@ func _resolve_mouse_button(raw: Variant) -> int:
839
841
  return MOUSE_BUTTON_WHEEL_DOWN
840
842
  _:
841
843
  return -1
842
- return int(raw)
844
+ return Read.as_int(raw, -1)
@@ -3,6 +3,7 @@ extends RefCounted
3
3
  ## What the server asks about the running tree: its shape, one node, the nodes matching a
4
4
  ## question, where one is on screen, a property set, a method called, the metrics.
5
5
 
6
+ const Read = preload("reading.gd")
6
7
  const Values = preload("runtime_values.gd")
7
8
 
8
9
  ## The most nodes one find answers with, unless asked for fewer: enough for any real query and
@@ -31,8 +32,8 @@ func _init(host: Node, values: Values) -> void:
31
32
 
32
33
  func get_tree(params: Dictionary) -> Dictionary:
33
34
  var root_path: String = str(params.get("root", "/root"))
34
- var max_depth: int = int(params.get("depth", 3))
35
- var include_properties: bool = bool(params.get("include_properties", false))
35
+ var max_depth: int = Read.as_int(params.get("depth", 3), 3)
36
+ var include_properties: bool = Read.as_bool(params.get("include_properties", false))
36
37
 
37
38
  var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
38
39
  if reached.has("message"):
@@ -65,8 +66,8 @@ func find_nodes(params: Dictionary) -> Dictionary:
65
66
  for filter: String in FILTERS:
66
67
  wanted[filter] = str(params.get(filter, ""))
67
68
  var wanted_property: String = str(params.get("property", ""))
68
- var include_hidden: bool = bool(params.get("include_hidden", true))
69
- var limit: int = clampi(int(params.get("limit", FIND_LIMIT)), 1, FIND_LIMIT_CEILING)
69
+ var include_hidden: bool = Read.as_bool(params.get("include_hidden", true), true)
70
+ var limit: int = clampi(Read.as_int(params.get("limit", FIND_LIMIT), FIND_LIMIT), 1, FIND_LIMIT_CEILING)
70
71
 
71
72
  if not _anything_asked(wanted):
72
73
  return {"type": "error", "message": "find_nodes needs at least one of " + ", ".join(FILTERS)}
@@ -110,7 +111,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
110
111
  pending.push_front(children[index])
111
112
 
112
113
  var answer: Dictionary = {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
113
- var notes: PackedStringArray = []
114
+ var notes: Array[String] = []
114
115
  # Nothing found is the one answer that cannot be told apart from having asked the wrong
115
116
  # question, and a name written without a wildcard is the way an agent writes "contains".
116
117
  # Said only when it changes the answer, so a genuine nothing stays a plain nothing.
@@ -262,8 +263,8 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
262
263
  ## whose interesting half is below the cut says so in a number rather than in a flag.
263
264
  func read_text(params: Dictionary) -> Dictionary:
264
265
  var root_path: String = str(params.get("root", "/root"))
265
- var include_hidden: bool = bool(params.get("include_hidden", false))
266
- var limit: int = clampi(int(params.get("limit", READ_LIMIT)), 1, READ_LIMIT)
266
+ var include_hidden: bool = Read.as_bool(params.get("include_hidden", false))
267
+ var limit: int = clampi(Read.as_int(params.get("limit", READ_LIMIT), READ_LIMIT), 1, READ_LIMIT)
267
268
 
268
269
  var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
269
270
  if reached.has("message"):
@@ -275,7 +276,7 @@ func read_text(params: Dictionary) -> Dictionary:
275
276
  # player is being asked to answer below them reads exactly like a dialog that is not there,
276
277
  # and a bare `truncated: true` was looked straight past twice before the limit was suspected.
277
278
  # A number is what sends somebody back.
278
- var lines: PackedStringArray = PackedStringArray()
279
+ var lines: Array[String] = []
279
280
  var left_out: int = _read_into(root, include_hidden, limit, lines)
280
281
  return {
281
282
  "type": "text",
@@ -297,7 +298,7 @@ func read_text(params: Dictionary) -> Dictionary:
297
298
  ## until it is opened, and reading a closed menu would put every item on the screen.
298
299
  ## Fills [param into] up to [param most] lines and answers with how many further lines the rest of
299
300
  ## the subtree says, which is what the caller is told rather than left to infer.
300
- func _read_into(node: Node, include_hidden: bool, most: int, into: PackedStringArray) -> int:
301
+ func _read_into(node: Node, include_hidden: bool, most: int, into: Array[String]) -> int:
301
302
  if not include_hidden and not _drawn(node):
302
303
  return 0
303
304
  var left_out: int = 0
@@ -336,7 +337,7 @@ static func _drawn(node: Node) -> bool:
336
337
  ## the three of them come to disagree about a SpinBox.
337
338
  static func said_by(node: Node) -> String:
338
339
  for property: Dictionary in node.get_property_list():
339
- if str(property.get("name", "")) == "text" and int(property.get("type", 0)) == TYPE_STRING:
340
+ if str(property.get("name", "")) == "text" and Read.as_int(property.get("type", 0)) == TYPE_STRING:
340
341
  return str(node.get("text")).strip_edges()
341
342
  return ""
342
343
 
@@ -374,7 +375,8 @@ func get_rect(params: Dictionary) -> Dictionary:
374
375
  "window": _values.serialize(window_position),
375
376
  }
376
377
  if node is Node3D:
377
- return _in_the_frame(node_path, node)
378
+ var spatial: Node3D = node
379
+ return _in_the_frame(node_path, spatial)
378
380
  return {
379
381
  "type": "error", "message": "%s is a %s, which has no place on screen" % [node_path, node.get_class()]
380
382
  }
@@ -573,7 +575,8 @@ static func _can_read(holder: Variant, named: String) -> bool:
573
575
  var map: Dictionary = holder
574
576
  return map.has(named) or map.has(StringName(named))
575
577
  if holder is Object:
576
- return _has_property(holder, named)
578
+ var object: Object = holder
579
+ return _has_property(object, named)
577
580
  return false
578
581
 
579
582
 
@@ -638,7 +641,7 @@ static func _nothing_there(holder: Variant, named: String, called: String) -> St
638
641
  var keys: Array = map.keys()
639
642
  # Eight of them, because a map keyed by something unexpected is told by the first few and a
640
643
  # map of two hundred would bury the sentence saying which key was missing.
641
- var some: PackedStringArray = []
644
+ var some: Array[String] = []
642
645
  for key: Variant in keys.slice(0, 8):
643
646
  some.append(str(key))
644
647
  var rest: String = ""
@@ -3,6 +3,8 @@ extends RefCounted
3
3
  ## What crosses the wire between the game and the server, in both directions: Godot values
4
4
  ## as JSON-safe dictionaries, and JSON back into the values a property or parameter wants.
5
5
 
6
+ const Read = preload("reading.gd")
7
+
6
8
  # What each Godot type becomes on the wire, keyed on typeof() rather than written as a chain of
7
9
  # `is` tests whose order has to be trusted: Resource had to be tested before Object, or every
8
10
  # resource came back as a bare class name with its path dropped.
@@ -190,17 +192,30 @@ func deserialize(value: Variant) -> Variant:
190
192
 
191
193
  match fields["_type"]:
192
194
  "Vector2":
193
- return Vector2(fields.get("x", 0), fields.get("y", 0))
195
+ return Vector2(Read.as_float(fields.get("x", 0)), Read.as_float(fields.get("y", 0)))
194
196
  "Vector3":
195
- return Vector3(fields.get("x", 0), fields.get("y", 0), fields.get("z", 0))
197
+ return Vector3(
198
+ Read.as_float(fields.get("x", 0)),
199
+ Read.as_float(fields.get("y", 0)),
200
+ Read.as_float(fields.get("z", 0))
201
+ )
196
202
  "Vector2i":
197
- return Vector2i(fields.get("x", 0), fields.get("y", 0))
203
+ return Vector2i(Read.as_int(fields.get("x", 0)), Read.as_int(fields.get("y", 0)))
198
204
  "Vector3i":
199
- return Vector3i(fields.get("x", 0), fields.get("y", 0), fields.get("z", 0))
205
+ return Vector3i(
206
+ Read.as_int(fields.get("x", 0)),
207
+ Read.as_int(fields.get("y", 0)),
208
+ Read.as_int(fields.get("z", 0))
209
+ )
200
210
  "Color":
201
- return Color(fields.get("r", 0), fields.get("g", 0), fields.get("b", 0), fields.get("a", 1))
211
+ return Color(
212
+ Read.as_float(fields.get("r", 0)),
213
+ Read.as_float(fields.get("g", 0)),
214
+ Read.as_float(fields.get("b", 0)),
215
+ Read.as_float(fields.get("a", 1), 1.0)
216
+ )
202
217
  "NodePath":
203
- return NodePath(fields.get("path", ""))
218
+ return NodePath(str(fields.get("path", "")))
204
219
  return value
205
220
 
206
221
 
@@ -214,7 +229,7 @@ func parameter_type(object: Object, method: String, index: int) -> int:
214
229
  if index < 0 or index >= params.size():
215
230
  return TYPE_NIL
216
231
  var parameter: Dictionary = params[index]
217
- return int(parameter.get("type", TYPE_NIL))
232
+ return Read.as_int(parameter.get("type", TYPE_NIL), TYPE_NIL)
218
233
  return TYPE_NIL
219
234
 
220
235
 
@@ -231,7 +246,8 @@ func fitted(value: Variant, type: int) -> Variant:
231
246
  return rebuilt
232
247
 
233
248
  if rebuilt is String and type != TYPE_STRING:
234
- var parsed: Variant = JSON.parse_string(rebuilt)
249
+ var text: String = rebuilt
250
+ var parsed: Variant = JSON.parse_string(text)
235
251
  if typeof(parsed) != TYPE_NIL and typeof(parsed) != TYPE_STRING:
236
252
  rebuilt = parsed
237
253
 
@@ -4,6 +4,7 @@ extends RefCounted
4
4
  ## happened, or when the time ran out, so the caller never sleeps for a guessed length.
5
5
 
6
6
  const Queries = preload("runtime_queries.gd")
7
+ const Read = preload("reading.gd")
7
8
  const Values = preload("runtime_values.gd")
8
9
 
9
10
  ## The longest one wait may last, whatever the request says: past this the server has long
@@ -34,7 +35,7 @@ static func _out_of_range(named: String, asked: int, least: int, most: int) -> D
34
35
 
35
36
 
36
37
  func wait_frames(params: Dictionary) -> Dictionary:
37
- var frames: int = int(params.get("frames", 1))
38
+ var frames: int = Read.as_int(params.get("frames", 1), 1)
38
39
  if frames < 1 or frames > MOST_FRAMES:
39
40
  return _out_of_range("frames", frames, 1, MOST_FRAMES)
40
41
  var started: int = Time.get_ticks_msec()
@@ -47,7 +48,7 @@ func wait_frames(params: Dictionary) -> Dictionary:
47
48
  func wait_signal(params: Dictionary) -> Dictionary:
48
49
  var node_path: String = str(params.get("path", ""))
49
50
  var signal_name: String = str(params.get("signal", ""))
50
- var timeout_ms: int = int(params.get("timeout_ms", 5000))
51
+ var timeout_ms: int = Read.as_int(params.get("timeout_ms", 5000), 5000)
51
52
  if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
52
53
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
53
54
  if node_path.is_empty() or signal_name.is_empty():
@@ -63,7 +64,9 @@ func wait_signal(params: Dictionary) -> Dictionary:
63
64
  var catcher: SignalCatcher = SignalCatcher.new()
64
65
  catcher.arity = _signal_arity(node, signal_name)
65
66
  var callable: Callable = catcher._on_fired
66
- node.connect(signal_name, callable, CONNECT_ONE_SHOT)
67
+ var listening: Error = node.connect(signal_name, callable, CONNECT_ONE_SHOT)
68
+ if listening != OK:
69
+ return {"type": "error", "message": "Could not listen to signal: " + signal_name}
67
70
  var started: int = Time.get_ticks_msec()
68
71
  while not catcher.fired and Time.get_ticks_msec() - started < timeout_ms:
69
72
  await _host.get_tree().process_frame
@@ -86,7 +89,7 @@ func wait_until(params: Dictionary) -> Dictionary:
86
89
  var node_path: String = str(params.get("path", ""))
87
90
  var property: String = str(params.get("property", ""))
88
91
  var says: String = str(params.get("says", ""))
89
- var timeout_ms: int = int(params.get("timeout_ms", 5000))
92
+ var timeout_ms: int = Read.as_int(params.get("timeout_ms", 5000), 5000)
90
93
  if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
91
94
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
92
95
  if node_path.is_empty():
@@ -3,6 +3,7 @@ extends RefCounted
3
3
  # The bus layout is a project resource the engine loads at startup and this process holds in
4
4
  # memory, so every change is written back to that file or it is gone with the process.
5
5
 
6
+ const Read = preload("reading.gd")
6
7
  const Log = preload("logger.gd")
7
8
 
8
9
  var _log: Log
@@ -16,7 +17,7 @@ func create_audio_bus(params: Dictionary) -> Dictionary:
16
17
  var bus_name: String = str(params.get("bus_name", ""))
17
18
  if bus_name.is_empty():
18
19
  return _log.failure("bus_name is required")
19
- var parent_idx: int = int(params.get("parent_bus_index", 0))
20
+ var parent_idx: int = Read.as_int(params.get("parent_bus_index", 0))
20
21
  if parent_idx < 0 or parent_idx >= AudioServer.bus_count:
21
22
  return _log.failure("No bus at index " + str(parent_idx))
22
23
 
@@ -40,10 +41,10 @@ func get_audio_buses(_params: Dictionary) -> Dictionary:
40
41
 
41
42
 
42
43
  func set_audio_bus_effect(params: Dictionary) -> Dictionary:
43
- var bus_idx: int = int(params.get("bus_index", 0))
44
- var effect_idx: int = int(params.get("effect_index", 0))
44
+ var bus_idx: int = Read.as_int(params.get("bus_index", 0))
45
+ var effect_idx: int = Read.as_int(params.get("effect_index", 0))
45
46
  var effect_type: String = str(params.get("effect_type", ""))
46
- var enabled: bool = bool(params.get("enabled", true))
47
+ var enabled: bool = Read.as_bool(params.get("enabled", true), true)
47
48
  if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
48
49
  return _log.failure("No bus at index " + str(bus_idx))
49
50
 
@@ -65,8 +66,8 @@ func set_audio_bus_effect(params: Dictionary) -> Dictionary:
65
66
 
66
67
 
67
68
  func set_audio_bus_volume(params: Dictionary) -> Dictionary:
68
- var bus_idx: int = int(params.get("bus_index", 0))
69
- var volume_db: float = float(params.get("volume_db", 0.0))
69
+ var bus_idx: int = Read.as_int(params.get("bus_index", 0))
70
+ var volume_db: float = Read.as_float(params.get("volume_db", 0.0))
70
71
  if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
71
72
  return _log.failure("No bus at index " + str(bus_idx))
72
73
 
@@ -5,6 +5,8 @@ extends RefCounted
5
5
  # A game started from a stale editor then cannot resolve any class_name written since, and the
6
6
  # engine reads the same file headless, so the list is rebuilt here from the scripts themselves.
7
7
 
8
+ const Patterns = preload("patterns.gd")
9
+ const Read = preload("reading.gd")
8
10
  const FileWalk = preload("file_walk.gd")
9
11
  const Log = preload("logger.gd")
10
12
 
@@ -33,7 +35,11 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
33
35
  var after: Dictionary = _entries_by_class(entries)
34
36
  var config: ConfigFile = ConfigFile.new()
35
37
  config.set_value("", "list", entries)
36
- DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CACHE_PATH.get_base_dir()))
38
+ var made: Error = DirAccess.make_dir_recursive_absolute(
39
+ ProjectSettings.globalize_path(CACHE_PATH.get_base_dir())
40
+ )
41
+ if made != OK and made != ERR_ALREADY_EXISTS:
42
+ return _log.failure("Could not make " + CACHE_PATH.get_base_dir() + ": " + error_string(made))
37
43
  var err: Error = config.save(CACHE_PATH)
38
44
  if err != OK:
39
45
  return _log.failure("Failed to write " + CACHE_PATH + ": " + error_string(err))
@@ -80,8 +86,8 @@ func _entry_for(path: String, skipped: Array[Dictionary]) -> Dictionary:
80
86
  "base": StringName(base),
81
87
  "class": StringName(declared),
82
88
  "icon": str(header.get("icon", "")),
83
- "is_abstract": bool(header.get("abstract", false)),
84
- "is_tool": bool(header.get("tool", false)),
89
+ "is_abstract": Read.as_bool(header.get("abstract", false)),
90
+ "is_tool": Read.as_bool(header.get("tool", false)),
85
91
  "language": StringName("GDScript"),
86
92
  "path": path,
87
93
  }
@@ -123,12 +129,11 @@ func _header_of(path: String) -> Dictionary:
123
129
  var file: FileAccess = FileAccess.open(path, FileAccess.READ)
124
130
  if not file:
125
131
  return header
126
- var annotation: RegEx = RegEx.new()
127
- annotation.compile('^@([a-z_]+)(?:\\(\\s*(?:"([^"]*)")?[^)]*\\))?\\s*')
128
- var class_line: RegEx = RegEx.new()
129
- class_line.compile("^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s+extends\\s+(\\S+))?")
130
- var extends_line: RegEx = RegEx.new()
131
- extends_line.compile("^extends\\s+(\\S+)")
132
+ var annotation: RegEx = Patterns.compiled('^@([a-z_]+)(?:\\(\\s*(?:"([^"]*)")?[^)]*\\))?\\s*')
133
+ var class_line: RegEx = Patterns.compiled(
134
+ "^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s+extends\\s+(\\S+))?"
135
+ )
136
+ var extends_line: RegEx = Patterns.compiled("^extends\\s+(\\S+)")
132
137
  while not file.eof_reached():
133
138
  var rest: String = file.get_line().strip_edges()
134
139
  # Annotations may share a line with what they annotate, as in `@abstract class_name X`.
@@ -1,5 +1,6 @@
1
1
  extends RefCounted
2
2
 
3
+ const Read = preload("reading.gd")
3
4
  const Log = preload("logger.gd")
4
5
 
5
6
  # The base class each category name stands for.
@@ -27,7 +28,7 @@ func _init(p_log: Log) -> void:
27
28
  func query_classes(params: Dictionary) -> Dictionary:
28
29
  var filter: String = str(params.get("filter", ""))
29
30
  var category: String = str(params.get("category", ""))
30
- var instantiable_only: bool = bool(params.get("instantiable_only", false))
31
+ var instantiable_only: bool = Read.as_bool(params.get("instantiable_only", false))
31
32
 
32
33
  _log.info(
33
34
  (
@@ -82,7 +83,7 @@ func query_classes(params: Dictionary) -> Dictionary:
82
83
  # Query detailed info about a specific class from ClassDB
83
84
  func query_class_info(params: Dictionary) -> Dictionary:
84
85
  var class_name_str: String = str(params.get("class_name", ""))
85
- var include_inherited: bool = bool(params.get("include_inherited", false))
86
+ var include_inherited: bool = Read.as_bool(params.get("include_inherited", false))
86
87
 
87
88
  _log.info(
88
89
  "Querying class info for: " + class_name_str + " (include_inherited: " + str(include_inherited) + ")"
@@ -220,7 +221,7 @@ func _properties_of(class_name_str: String, include_inherited: bool) -> Array[Di
220
221
 
221
222
  for p: Dictionary in ClassDB.class_get_property_list(class_name_str, not include_inherited):
222
223
  # A category, group or subgroup is an editor heading rather than a property.
223
- var usage: int = int(p.get("usage", 0))
224
+ var usage: int = Read.as_int(p.get("usage", 0))
224
225
  if usage & PROPERTY_USAGE_CATEGORY or usage & PROPERTY_USAGE_GROUP or usage & PROPERTY_USAGE_SUBGROUP:
225
226
  continue
226
227
  properties.append(