gdharness 1.2.31 → 1.2.32

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 (27) hide show
  1. package/build/cli.js +7212 -6230
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +14 -0
  3. package/build/godot/addons/gdharness_editor/file_reads.gd +237 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd.uid +1 -0
  5. package/build/godot/addons/gdharness_editor/plugin.gd +27 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd +38 -1
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +1 -0
  8. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +22 -0
  9. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +64 -1
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +4 -41
  11. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +178 -0
  12. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +54 -120
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +39 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +285 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_hover.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_input.gd +151 -106
  18. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +112 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd.uid +1 -0
  20. package/build/godot/operations/file_reads.gd +237 -0
  21. package/build/godot/operations/gdscript_authoring.gd +136 -19
  22. package/build/godot/operations/godot_operations.gd +24 -0
  23. package/build/godot/operations/property_values.gd +767 -0
  24. package/build/godot/operations/rename_checks.gd +5 -2
  25. package/build/index.js +5437 -4469
  26. package/build/keeper.js +184 -71
  27. package/package.json +1 -1
@@ -0,0 +1,178 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## What a new node in a scene is made or copied from, what the scene owns of it, and where among its
5
+ ## siblings a node may go.
6
+
7
+ const Read = preload("../reading.gd")
8
+ const PropertyValues = preload("../property_values.gd")
9
+
10
+
11
+ ## The scene to instance for an add, or null for a node of a class, beside why neither can be made
12
+ ## ("" when one can), as [PackedScene, String].
13
+ static func what_to_add(node_type: String, instance_path: String, scene_path: String) -> Array:
14
+ if node_type.is_empty() == instance_path.is_empty():
15
+ return [
16
+ null,
17
+ "add takes nodeType for a node of a class, or instancePath for a scene, and one of them only."
18
+ ]
19
+ if not instance_path.is_empty():
20
+ var cycle: String = _instancing_cycle(scene_path, instance_path)
21
+ if not cycle.is_empty():
22
+ return [null, cycle]
23
+ if not ResourceLoader.exists(instance_path, "PackedScene"):
24
+ return [null, "No scene at %s to instance." % instance_path]
25
+ var packed: PackedScene = ResourceLoader.load(instance_path, "PackedScene") as PackedScene
26
+ if packed == null or not packed.can_instantiate():
27
+ return [null, "%s does not load as a scene that can be instanced." % instance_path]
28
+ return [packed, ""]
29
+ if node_type.begins_with("res://") or node_type.get_extension() in ["tscn", "scn"]:
30
+ return [
31
+ null,
32
+ (
33
+ "nodeType must be a node class, and %s is a scene: pass it as instancePath to instance it."
34
+ % node_type
35
+ )
36
+ ]
37
+ if not ClassDB.class_exists(node_type) or not ClassDB.is_parent_class(node_type, "Node"):
38
+ return [null, "nodeType must be a node class, and %s is not one." % node_type]
39
+ if not ClassDB.can_instantiate(node_type):
40
+ return [null, "%s cannot be instantiated." % node_type]
41
+ return [null, ""]
42
+
43
+
44
+ ## A new node of [param node_type], or an instance of [param packed] when there is one. The instance
45
+ ## is made in the editor's edit state, so the pack writes it as an instance of its file, and owning
46
+ ## its root leaves its own nodes to the instanced scene.
47
+ static func made(packed: PackedScene, node_type: String) -> Node:
48
+ if packed != null:
49
+ return packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
50
+ var node: Object = ClassDB.instantiate(node_type)
51
+ return node as Node
52
+
53
+
54
+ ## A copy of [param source] and the nodes under it that [param root] owns, each copy recorded in
55
+ ## [param copies] against its source, or null when an instance in it no longer loads.
56
+ static func copied(source: Node, root: Node, copies: Dictionary) -> Node:
57
+ var copy: Node = null
58
+ if not source.scene_file_path.is_empty():
59
+ # An instanced scene made again from its file, so the copy is an instance too. Node.duplicate
60
+ # makes a plain node of the instance's class, and the pack saved it with every property of the
61
+ # instance's root frozen into this scene.
62
+ var packed: PackedScene = ResourceLoader.load(source.scene_file_path, "PackedScene") as PackedScene
63
+ if packed == null:
64
+ return null
65
+ copy = packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
66
+ for declared: Dictionary in source.get_property_list():
67
+ if not (Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
68
+ continue
69
+ var property: String = str(declared.get("name", ""))
70
+ var value: Variant = source.get(property)
71
+ if not PropertyValues.same(copy.get(property), value):
72
+ copy.set(property, value)
73
+ for group: StringName in source.get_groups():
74
+ if not copy.is_in_group(group):
75
+ copy.add_to_group(group, true)
76
+ else:
77
+ copy = source.duplicate(Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS)
78
+ for child: Node in copy.get_children():
79
+ copy.remove_child(child)
80
+ child.free()
81
+ copy.name = source.name
82
+ copies[source] = copy
83
+ # Only what this scene owns: an instance's own nodes come with the instance.
84
+ for child: Node in source.get_children():
85
+ if child.owner == root:
86
+ var under: Node = copied(child, root, copies)
87
+ if under == null:
88
+ return null
89
+ copy.add_child(under)
90
+ return copy
91
+
92
+
93
+ ## Gives each copy in [param copies] the persistent connections its source has, to the copy of the
94
+ ## target when the target was copied too. A connection an instance makes inside itself is skipped:
95
+ ## the instance made again from its file already has it.
96
+ static func reconnect(copies: Dictionary, top: Node) -> void:
97
+ for key: Variant in copies:
98
+ var source: Node = key
99
+ var copy: Node = copies[key]
100
+ for info: Dictionary in source.get_signal_list():
101
+ var signal_name: String = str(info.get("name", ""))
102
+ for conn: Dictionary in source.get_signal_connection_list(signal_name):
103
+ var flags: int = Read.as_int(conn.get("flags", 0))
104
+ var callable: Callable = conn.get("callable", Callable())
105
+ var target: Object = callable.get_object()
106
+ if not flags & Object.CONNECT_PERSIST or target == null:
107
+ continue
108
+ var to: Object = copies.get(target, target)
109
+ if to == target and target is Node and (target == top or top.is_ancestor_of(target as Node)):
110
+ continue
111
+ var aimed: Callable = Callable(to, callable.get_method())
112
+ if (
113
+ not copy.is_connected(signal_name, aimed)
114
+ and copy.connect(signal_name, aimed, flags) != OK
115
+ ):
116
+ push_error(
117
+ "gdharness: could not copy the connection %s of %s" % [signal_name, source.name]
118
+ )
119
+
120
+
121
+ ## Owns [param node] and the nodes under it to [param root], except those an instanced scene
122
+ ## inside it owns: giving those to the root made the pack write the instance's own nodes into this
123
+ ## scene as overrides.
124
+ static func own(node: Node, root: Node) -> void:
125
+ node.owner = root
126
+ for child: Node in node.get_children():
127
+ if child.owner == null or child.owner == root:
128
+ own(child, root)
129
+
130
+
131
+ ## Why [param asked] is not a position [param node] can take among [param parent]'s children, or ""
132
+ ## when it is, or when none was asked for. [param node] is null for a node not yet added.
133
+ static func unplaceable(parent: Node, node: Node, asked: Variant) -> String:
134
+ if asked == null:
135
+ return ""
136
+ var others: int = parent.get_child_count() - (1 if node != null and node.get_parent() == parent else 0)
137
+ var at: int = Read.as_int(asked, -1)
138
+ if at < 0 or at > others:
139
+ return (
140
+ "index %s is not a place among the %d other children of %s: give 0 to %d, 0 for first."
141
+ % [
142
+ str(at) if asked is int or asked is float else JSON.stringify(asked),
143
+ others,
144
+ parent.name,
145
+ others
146
+ ]
147
+ )
148
+ return ""
149
+
150
+
151
+ ## Moves [param node] to [param asked] among its siblings, when a place was asked for.
152
+ static func place(node: Node, asked: Variant) -> void:
153
+ if asked != null:
154
+ node.get_parent().move_child(node, Read.as_int(asked))
155
+
156
+
157
+ ## Why [param instanced] cannot be instanced into [param scene_path], or "" when it can: a scene
158
+ ## inside itself, directly or through the scenes it instances, which Godot's own dialog refuses and
159
+ ## which never finishes loading.
160
+ static func _instancing_cycle(scene_path: String, instanced: String) -> String:
161
+ var seen: Dictionary[String, bool] = {}
162
+ var waiting: Array[String] = [instanced]
163
+ while not waiting.is_empty():
164
+ var at: String = waiting.pop_back()
165
+ if at == scene_path:
166
+ return (
167
+ "%s cannot be instanced into %s: it %s, so the scene would hold itself."
168
+ % [instanced, scene_path, "is that scene" if at == instanced else "instances it"]
169
+ )
170
+ if seen.has(at):
171
+ continue
172
+ seen[at] = true
173
+ for dependency: String in ResourceLoader.get_dependencies(at):
174
+ # Slices joined by "::", the UID, the type and the path, some of them empty.
175
+ for part: String in dependency.split("::"):
176
+ if part.begins_with("res://") and part.get_extension() in ["tscn", "scn"]:
177
+ waiting.append(part)
178
+ return ""
@@ -0,0 +1 @@
1
+ uid://bgnjhedq2qj3t
@@ -3,7 +3,9 @@ extends Node
3
3
 
4
4
  ## Scenes and their nodes, edited in the open editor and read back from what it holds.
5
5
 
6
+ const FileReads = preload("../file_reads.gd")
6
7
  const Read = preload("../reading.gd")
8
+ const Placing = preload("scene_placing.gd")
7
9
  const PropertyValues = preload("../property_values.gd")
8
10
  const ResourceTools = preload("resource_tools.gd")
9
11
  const SceneFile = preload("../scene_file.gd")
@@ -114,81 +116,6 @@ func _kept(scene: SceneFile, saved: Node, node: Node, names: Array) -> String:
114
116
  return ""
115
117
 
116
118
 
117
- ## Owns [param node] and the nodes under it to [param root], except those an instanced scene
118
- ## inside it owns: giving those to the root made the pack write the instance's own nodes into this
119
- ## scene as overrides.
120
- func _copy(source: Node, root: Node, copies: Dictionary) -> Node:
121
- var copy: Node = null
122
- if not source.scene_file_path.is_empty():
123
- # An instanced scene made again from its file, so the copy is an instance too. Node.duplicate
124
- # makes a plain node of the instance's class, and the pack saved it with every property of the
125
- # instance's root frozen into this scene.
126
- var packed: PackedScene = ResourceLoader.load(source.scene_file_path, "PackedScene") as PackedScene
127
- if packed == null:
128
- return null
129
- copy = packed.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
130
- for declared: Dictionary in source.get_property_list():
131
- if not (Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
132
- continue
133
- var property: String = str(declared.get("name", ""))
134
- var value: Variant = source.get(property)
135
- if not PropertyValues.same(copy.get(property), value):
136
- copy.set(property, value)
137
- for group: StringName in source.get_groups():
138
- if not copy.is_in_group(group):
139
- copy.add_to_group(group, true)
140
- else:
141
- copy = source.duplicate(Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS)
142
- for child: Node in copy.get_children():
143
- copy.remove_child(child)
144
- child.free()
145
- copy.name = source.name
146
- copies[source] = copy
147
- # Only what this scene owns: an instance's own nodes come with the instance.
148
- for child: Node in source.get_children():
149
- if child.owner == root:
150
- var copied: Node = _copy(child, root, copies)
151
- if copied == null:
152
- return null
153
- copy.add_child(copied)
154
- return copy
155
-
156
-
157
- ## Gives each copy in [param copies] the persistent connections its source has, to the copy of the
158
- ## target when the target was copied too. A connection an instance makes inside itself is skipped:
159
- ## the instance made again from its file already has it.
160
- func _reconnect(copies: Dictionary, top: Node) -> void:
161
- for key: Variant in copies:
162
- var source: Node = key
163
- var copy: Node = copies[key]
164
- for info: Dictionary in source.get_signal_list():
165
- var signal_name: String = str(info.get("name", ""))
166
- for conn: Dictionary in source.get_signal_connection_list(signal_name):
167
- var flags: int = Read.as_int(conn.get("flags", 0))
168
- var callable: Callable = conn.get("callable", Callable())
169
- var target: Object = callable.get_object()
170
- if not flags & Object.CONNECT_PERSIST or target == null:
171
- continue
172
- var to: Object = copies.get(target, target)
173
- if to == target and target is Node and (target == top or top.is_ancestor_of(target as Node)):
174
- continue
175
- var copied: Callable = Callable(to, callable.get_method())
176
- if (
177
- not copy.is_connected(signal_name, copied)
178
- and copy.connect(signal_name, copied, flags) != OK
179
- ):
180
- push_error(
181
- "gdharness: could not copy the connection %s of %s" % [signal_name, source.name]
182
- )
183
-
184
-
185
- func _own(node: Node, root: Node) -> void:
186
- node.owner = root
187
- for child: Node in node.get_children():
188
- if child.owner == null or child.owner == root:
189
- _own(child, root)
190
-
191
-
192
119
  ## [param node]'s stored properties as a read answers them. A property holding a node is answered
193
120
  ## as the path to it: the copy read here is never in the tree, and an object description named a
194
121
  ## node that was freed when the call ended.
@@ -241,27 +168,6 @@ func _parse_properties_arg(raw_properties: Variant) -> Dictionary:
241
168
  return {}
242
169
 
243
170
 
244
- func _build_node_tree(
245
- node: Node, include_properties: bool, depth: int, current_depth: int, node_path: String
246
- ) -> Dictionary:
247
- var children: Array[Dictionary] = []
248
- var data: Dictionary = {
249
- "name": str(node.name), "type": node.get_class(), "path": node_path, "children": children
250
- }
251
-
252
- if include_properties:
253
- data["properties"] = _properties_of(node, true, {})
254
-
255
- if depth >= 0 and current_depth >= depth:
256
- return data
257
-
258
- for child: Node in node.get_children():
259
- var child_path: String = str(child.name) if node_path == "." else node_path + "/" + str(child.name)
260
- children.append(_build_node_tree(child, include_properties, depth, current_depth + 1, child_path))
261
-
262
- return data
263
-
264
-
265
171
  func _collect_nodes_recursive(node: Node, path: String, out_nodes: Array) -> void:
266
172
  out_nodes.append({"path": path, "node": node})
267
173
  for child: Node in node.get_children():
@@ -350,31 +256,29 @@ func list_scene_nodes(args: Dictionary) -> Dictionary:
350
256
  var depth: int = Read.as_int(args.get("depth", -1), -1)
351
257
  var include_properties: bool = Read.as_bool(args.get("includeProperties", false))
352
258
 
353
- var opened: Array = _open(scene_path, false)
354
- var scene: SceneFile = opened[0]
355
- var refused: Dictionary = opened[1]
356
- if not refused.is_empty():
357
- return refused
358
-
359
- var tree: Dictionary = _build_node_tree(scene.root, include_properties, depth, 0, ".")
360
- scene.close()
361
- return {"ok": true, "tree": tree, "unsavedInEditor": SceneFile.unsaved(scene_path)}
259
+ # Read as a headless engine reads it with no editor connected, so the answer does not depend on
260
+ # whether one is.
261
+ var read: Dictionary = FileReads.scene_tree(scene_path, depth, include_properties)
262
+ if read.get("ok", false):
263
+ read["unsavedInEditor"] = SceneFile.unsaved(scene_path)
264
+ return read
362
265
 
363
266
 
364
267
  func add_node(args: Dictionary) -> Dictionary:
365
268
  var project_path: String = str(args.get("projectPath", ""))
366
269
  var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
367
270
  var node_type: String = str(args.get("nodeType", ""))
271
+ var instance_path: String = str(args.get("instancePath", ""))
368
272
  var node_name: String = str(args.get("nodeName", ""))
369
273
  var parent_node_path: String = str(args.get("parentNodePath", "."))
370
274
  var properties: Dictionary = _parse_properties_arg(args.get("properties", {}))
371
275
 
372
- if node_type.is_empty() or node_name.is_empty():
373
- return {"ok": false, "error": "Missing nodeType or nodeName"}
374
- if not ClassDB.class_exists(node_type) or not ClassDB.is_parent_class(node_type, "Node"):
375
- return {"ok": false, "error": "nodeType must be a node class, and %s is not one." % node_type}
376
- if not ClassDB.can_instantiate(node_type):
377
- return {"ok": false, "error": "%s cannot be instantiated." % node_type}
276
+ if node_name.is_empty():
277
+ return {"ok": false, "error": "Missing nodeName"}
278
+ var making: Array = Placing.what_to_add(node_type, instance_path, scene_path)
279
+ var packed: PackedScene = making[0]
280
+ if not str(making[1]).is_empty():
281
+ return {"ok": false, "error": making[1]}
378
282
 
379
283
  var opened: Array = _open(scene_path, true)
380
284
  var scene: SceneFile = opened[0]
@@ -392,24 +296,39 @@ func add_node(args: Dictionary) -> Dictionary:
392
296
  var unusable: String = scene.unusable_name(parent, node_name)
393
297
  if not unusable.is_empty():
394
298
  return scene.refuse(unusable)
299
+ var place: Variant = args.get("index")
300
+ var unplaceable: String = Placing.unplaceable(parent, null, place)
301
+ if not unplaceable.is_empty():
302
+ return scene.refuse(unplaceable)
395
303
 
396
304
  # In the tree before its properties, so a property holding a node can find it by path.
397
- var new_node: Node = ClassDB.instantiate(node_type)
305
+ var new_node: Node = Placing.made(packed, node_type)
398
306
  new_node.name = node_name
399
307
  parent.add_child(new_node)
400
- _own(new_node, root)
308
+ Placing.place(new_node, place)
309
+ Placing.own(new_node, root)
401
310
  var refused_property: String = _properties.write_all(new_node, properties)
402
311
  if not refused_property.is_empty():
403
312
  return scene.refuse(refused_property)
404
313
 
405
314
  var new_path: String = scene.path_of(new_node)
315
+ # Read before the write, which frees the scene it was added to.
316
+ var answer: Dictionary = {
317
+ "ok": true,
318
+ "nodeName": node_name,
319
+ "nodeType": new_node.get_class(),
320
+ "nodePath": new_path,
321
+ "index": new_node.get_index(),
322
+ }
406
323
  var written: Dictionary = scene.write(
407
324
  func(saved: Node) -> String: return _kept(scene, saved, new_node, properties.keys())
408
325
  )
409
326
  if not written.is_empty():
410
327
  return written
411
328
 
412
- return {"ok": true, "nodeName": node_name, "nodeType": node_type, "nodePath": new_path}
329
+ if packed != null:
330
+ answer["instanceOf"] = instance_path
331
+ return answer
413
332
 
414
333
 
415
334
  func delete_node(args: Dictionary) -> Dictionary:
@@ -488,14 +407,14 @@ func duplicate_node(args: Dictionary) -> Dictionary:
488
407
  return scene.refuse(unusable)
489
408
 
490
409
  var copies: Dictionary = {}
491
- var duplicated_node: Node = _copy(source, root, copies)
410
+ var duplicated_node: Node = Placing.copied(source, root, copies)
492
411
  if not duplicated_node:
493
412
  return scene.refuse("Failed to duplicate node: " + node_path)
494
413
 
495
414
  duplicated_node.name = new_name
496
415
  target_parent.add_child(duplicated_node)
497
- _own(duplicated_node, root)
498
- _reconnect(copies, source)
416
+ Placing.own(duplicated_node, root)
417
+ Placing.reconnect(copies, source)
499
418
 
500
419
  var new_path: String = scene.path_of(duplicated_node)
501
420
  var written: Dictionary = scene.write(
@@ -549,25 +468,40 @@ func reparent_node(args: Dictionary) -> Dictionary:
549
468
  var unusable: String = scene.unusable_name(new_parent, str(node.name), node)
550
469
  if not unusable.is_empty():
551
470
  return scene.refuse(unusable)
471
+ var place: Variant = args.get("index")
472
+ var unplaceable: String = Placing.unplaceable(new_parent, node, place)
473
+ if not unplaceable.is_empty():
474
+ return scene.refuse(unplaceable)
552
475
 
553
476
  var old_path: String = scene.path_of(node)
554
477
  node.get_parent().remove_child(node)
555
478
  new_parent.add_child(node)
556
- _own(node, root)
479
+ Placing.place(node, place)
480
+ Placing.own(node, root)
557
481
 
558
482
  var new_path: String = scene.path_of(node)
483
+ var index: int = node.get_index()
559
484
  var written: Dictionary = scene.write(
560
485
  func(saved: Node) -> String:
561
- if saved.get_node_or_null(new_path) == null:
486
+ var copy: Node = saved.get_node_or_null(new_path)
487
+ if copy == null:
562
488
  return "%s would not be in the scene when it loads." % new_path
563
489
  if old_path != new_path and saved.get_node_or_null(old_path) != null:
564
490
  return "%s would still be in the scene as well when it loads." % old_path
491
+ if copy.get_index() != index:
492
+ return "%s would load at index %d, not %d." % [new_path, copy.get_index(), index]
565
493
  return ""
566
494
  )
567
495
  if not written.is_empty():
568
496
  return written
569
497
 
570
- return {"ok": true, "nodePath": old_path, "newParentPath": new_parent_path, "newNodePath": new_path}
498
+ return {
499
+ "ok": true,
500
+ "nodePath": old_path,
501
+ "newParentPath": new_parent_path,
502
+ "newNodePath": new_path,
503
+ "index": index,
504
+ }
571
505
 
572
506
 
573
507
  func set_node_properties(args: Dictionary) -> Dictionary:
@@ -49,6 +49,10 @@ const PROFILER_PROBE_CALLS: int = 10000
49
49
  ## on every 2048th call and only counts the others.
50
50
  const POLLS_PER_READ: int = 2048
51
51
 
52
+ ## The most frames a close waits for its answer to leave: a client that stopped reading would
53
+ ## otherwise hold the close for ever, and the server is past caring about the answer by then.
54
+ const CLOSE_AFTER_FRAMES: int = 30
55
+
52
56
  ## Set in a game's environment by whatever starts it to be profiled from its start: the server for
53
57
  ## a run it spawns, the editor for a play. Kept in step with `PROFILE_FROM_START` in the server.
54
58
  const PROFILE_FROM_START: String = "GDHARNESS_PROFILE_FROM_START"
@@ -118,6 +122,8 @@ var _commands: Dictionary = {}
118
122
  var _loaded_version: String = _read_marker(VERSION_MARKER)
119
123
  var _loaded_digest: String = _read_marker(DIGEST_MARKER)
120
124
  var _profile_received: bool = false
125
+ var _close_asked: bool = false
126
+ var _close_frames: int = 0
121
127
  var _profiler_calibrated: bool = false
122
128
 
123
129
 
@@ -140,6 +146,7 @@ func _init() -> void:
140
146
  "inject_text": _typing.inject_text,
141
147
  "inject_mouse_click": _input.inject_mouse_click,
142
148
  "inject_mouse_motion": _input.inject_mouse_motion,
149
+ "inject_mouse_drag": _input.inject_mouse_drag,
143
150
  "inject_joypad_button": _pad.inject_joypad_button,
144
151
  "inject_joypad_motion": _pad.inject_joypad_motion,
145
152
  "click": _input.click,
@@ -148,6 +155,7 @@ func _init() -> void:
148
155
  "wait_signal": _waits.wait_signal,
149
156
  "wait_until": _waits.wait_until,
150
157
  "send_profile": _send_profile_now,
158
+ "close_game": _close_game,
151
159
  }
152
160
  _wait_for_the_profiler()
153
161
 
@@ -341,6 +349,14 @@ func _process(_delta: float) -> void:
341
349
  var _had_pending: bool = _pending.erase(client)
342
350
  var _had_outgoing: bool = _outgoing.erase(client)
343
351
 
352
+ # After the answer to the close has gone out: the close request reaches this node too, which then
353
+ # drops its clients and whatever they were still owed.
354
+ if _close_asked:
355
+ _close_frames += 1
356
+ if _close_frames >= CLOSE_AFTER_FRAMES or _outgoing.values().all(_is_empty):
357
+ _close_asked = false
358
+ _deliver_close()
359
+
344
360
 
345
361
  ## Hands the socket as much of what it is owed as it will take, and says whether it is still worth
346
362
  ## talking to. Reading comes first in the frame, so a client that asked and left is noticed here
@@ -578,6 +594,29 @@ func _ping(_params: Dictionary) -> Dictionary:
578
594
  return {"type": "pong", "timestamp": Time.get_unix_time_from_system()}
579
595
 
580
596
 
597
+ ## The game asked to close, as a player's close asks it: answered at once and delivered once the
598
+ ## answer has gone out. `acceptsQuit` says whether the tree quits on the request by itself; a game
599
+ ## that turned that off decides for itself, and may ask the player first.
600
+ func _close_game(_params: Dictionary) -> Dictionary:
601
+ _close_asked = true
602
+ _close_frames = 0
603
+ return {"type": "closing", "acceptsQuit": get_tree().auto_accept_quit}
604
+
605
+
606
+ ## What a player's close does: the close request reaches every node, and then the root window says
607
+ ## it was asked to close, which the tree answers by quitting when it accepts quits. So the game's own
608
+ ## close runs, a crash guard's included, where a stop that ended the process ran none of it.
609
+ func _deliver_close() -> void:
610
+ var root: Window = get_tree().root
611
+ root.propagate_notification(NOTIFICATION_WM_CLOSE_REQUEST)
612
+ root.close_requested.emit()
613
+
614
+
615
+ static func _is_empty(owed: Variant) -> bool:
616
+ var bytes: PackedByteArray = owed
617
+ return bytes.is_empty()
618
+
619
+
581
620
  ## One JSON object and a newline, queued for [method _drain] rather than written here.
582
621
  ##
583
622
  ## Raw bytes rather than put_utf8_string, which would prefix them with a length the other side is