gdharness 1.2.39 → 1.2.41

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 +1435 -1410
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -2
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +2 -1
  4. package/build/godot/addons/gdharness_editor/headless_check.gd +22 -11
  5. package/build/godot/addons/gdharness_editor/property_values.gd +1 -1
  6. package/build/godot/addons/gdharness_editor/reading.gd +15 -0
  7. package/build/godot/addons/gdharness_editor/scene_file.gd +9 -0
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +39 -5
  9. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -15
  10. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +12 -12
  11. package/build/godot/addons/gdharness_runtime/reading.gd +15 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +3 -1
  13. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +2 -2
  14. package/build/godot/addons/gdharness_runtime/runtime_input.gd +15 -2
  15. package/build/godot/addons/gdharness_runtime/runtime_items.gd +175 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_items.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +43 -10
  18. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +61 -20
  19. package/build/godot/addons/gdharness_runtime/runtime_values.gd +3 -1
  20. package/build/godot/addons/gdharness_runtime/runtime_words.gd +48 -18
  21. package/build/godot/operations/animation_reads.gd +11 -2
  22. package/build/godot/operations/editor_tools.gd +9 -0
  23. package/build/godot/operations/godot_operations.gd +2 -1
  24. package/build/godot/operations/property_values.gd +1 -1
  25. package/build/godot/operations/reading.gd +15 -0
  26. package/build/index.js +2789 -2765
  27. package/package.json +1 -1
@@ -5,6 +5,7 @@ extends RefCounted
5
5
  ## stores, which runs no script of the scene's, so a read answers the same either way.
6
6
 
7
7
  const FileReads = preload("file_reads.gd")
8
+ const Read = preload("reading.gd")
8
9
  const Serialisation = preload("serialisation.gd")
9
10
 
10
11
  const LOOP_MODE_NAMES: Dictionary[int, String] = {
@@ -60,7 +61,12 @@ static func _bare(scene_path: String, node_path: String, kind: String) -> Array:
60
61
  return [null, read]
61
62
  var type: String = str(read.get("type", ""))
62
63
  if not ClassDB.class_exists(type) or not ClassDB.is_parent_class(type, kind):
63
- return [null, {"ok": false, "error": "%s not found at: %s" % [kind, node_path]}]
64
+ var said: String = "%s not found at: %s" % [kind, node_path]
65
+ if read.is_empty() and FileReads.scene_node(scene_path, node_path.get_slice("/", 0)).is_empty():
66
+ said = Read.from_the_root(
67
+ said, node_path, str(FileReads.scene_node(scene_path, ".").get("name", ""))
68
+ )
69
+ return [null, {"ok": false, "error": said}]
64
70
  var bare: Node = ClassDB.instantiate(type)
65
71
  bare.name = str(read["name"])
66
72
  var given: Dictionary = read["properties"]
@@ -164,7 +170,10 @@ static func state_machine(tree: AnimationTree, state_machine_path: String) -> An
164
170
  ## the path that failed, and what is there instead.
165
171
  static func no_machine(tree: AnimationTree, state_machine_path: String) -> String:
166
172
  if tree == null or tree.tree_root == null:
167
- return "The AnimationTree has no tree_root."
173
+ return (
174
+ "The AnimationTree has no tree_root. Make one with resource_edit create of an "
175
+ + "AnimationNodeStateMachine, then give its res:// path to tree_root with scene_node set."
176
+ )
168
177
  var current: AnimationNodeStateMachine = tree.tree_root as AnimationNodeStateMachine
169
178
  if current == null:
170
179
  return (
@@ -544,7 +544,8 @@ func send_tool_result(request_id: String, success: bool, result: Variant = null,
544
544
 
545
545
  func _send_message(message: Dictionary) -> void:
546
546
  if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
547
- var sent: Error = socket.send_text(JSON.stringify(message))
547
+ # Keys in the order they were put in, which for a map read is the order the project keeps.
548
+ var sent: Error = socket.send_text(JSON.stringify(message, "", false))
548
549
  if sent != OK:
549
550
  push_error("gdharness: could not send to the server: " + error_string(sent))
550
551
 
@@ -147,12 +147,8 @@ static func reached(object: Object, property: String) -> Dictionary:
147
147
  ## Whether [param object] holds, at any depth, an embedded resource with a script that is not a tool.
148
148
  static func holds_scripted_embedded(object: Object, seen: Dictionary) -> bool:
149
149
  for value: Variant in stored(object).values():
150
- var items: Array = value if value is Array else [value]
151
- for item: Variant in items:
152
- if not item is Resource or seen.has(item):
153
- continue
154
- var resource: Resource = item
155
- if not resource.is_built_in():
150
+ for resource: Resource in resources_in(value):
151
+ if seen.has(resource) or not resource.is_built_in():
156
152
  continue
157
153
  seen[resource] = true
158
154
  var script: Script = resource.get_script()
@@ -163,6 +159,25 @@ static func holds_scripted_embedded(object: Object, seen: Dictionary) -> bool:
163
159
  return false
164
160
 
165
161
 
162
+ ## The resources [param value] is or holds through arrays and dictionaries at any depth, keys
163
+ ## included: a file embeds a resource kept as a map's value, the way an AnimationLibrary keeps its
164
+ ## animations, as it embeds one held directly.
165
+ static func resources_in(value: Variant) -> Array[Resource]:
166
+ var found: Array[Resource] = []
167
+ if value is Resource:
168
+ found.append(value)
169
+ elif value is Array:
170
+ var items: Array = value
171
+ for item: Variant in items:
172
+ found.append_array(resources_in(item))
173
+ elif value is Dictionary:
174
+ var entries_held: Dictionary = value
175
+ for key: Variant in entries_held:
176
+ found.append_array(resources_in(key))
177
+ found.append_array(resources_in(entries_held[key]))
178
+ return found
179
+
180
+
166
181
  ## Puts the file at [param file_path] back as [param previous], or removes it when there was none,
167
182
  ## answering "" or a sentence saying that failed too.
168
183
  static func put_back(file_path: String, previous: Variant) -> String:
@@ -370,11 +385,7 @@ static func settle_saved(
370
385
  ## Every embedded resource [param values] reach, at any depth, by its id in the file.
371
386
  static func embedded_in(values: Array, found: Dictionary) -> Dictionary:
372
387
  for value: Variant in values:
373
- var items: Array = value if value is Array else [value]
374
- for item: Variant in items:
375
- if not item is Resource:
376
- continue
377
- var resource: Resource = item
388
+ for resource: Resource in resources_in(value):
378
389
  var id: String = resource.get_scene_unique_id()
379
390
  if not resource.is_built_in() or id.is_empty() or found.has(id):
380
391
  continue
@@ -590,7 +590,7 @@ static func _listed(type: int) -> bool:
590
590
 
591
591
  ## [param value] as a caller would write it, for a refusal to quote.
592
592
  func shown(value: Variant) -> String:
593
- return JSON.stringify(_values.serialize_value(value))
593
+ return JSON.stringify(_values.serialize_value(value), "", false)
594
594
 
595
595
 
596
596
  ## A dictionary carrying its own type name, as the serialiser writes it.
@@ -99,3 +99,18 @@ static func json_or_null(text: String) -> Variant:
99
99
  if json.parse(text) != OK:
100
100
  return null
101
101
  return json.data
102
+
103
+
104
+ ## [param said], the refusal for [param node_path] found nowhere in a scene whose root is called
105
+ ## [param root_name], with how paths are read when the path starts with that name and the root has
106
+ ## no child called that. Paths go from the root, so its own name there names a child of it, and a
107
+ ## caller who read the root as Main in a tree wrote Main/Body and was told only that nothing is there.
108
+ static func from_the_root(said: String, node_path: String, root_name: String) -> String:
109
+ var first: String = node_path.get_slice("/", 0)
110
+ if root_name.is_empty() or first != root_name:
111
+ return said
112
+ var rest: String = node_path.substr(first.length() + 1)
113
+ var how: String = 'Paths go from the root, %s, whose own path is "."' % root_name
114
+ if not rest.is_empty():
115
+ how += ', so this one is "%s"' % rest
116
+ return "%s%s %s." % [said, "" if said.ends_with(".") else ".", how]
@@ -30,6 +30,7 @@ const HeadlessCheck = preload("headless_check.gd")
30
30
  ## How many times what scripts changed is put back before a write is refused for it.
31
31
  const PUTTING_BACK_PASSES: int = 4
32
32
  const PropertyValues = preload("property_values.gd")
33
+ const Read = preload("reading.gd")
33
34
 
34
35
  ## Whether a scan asked for through [method scan_later] is still to start, which a caller waiting
35
36
  ## for the editor's files to settle has to wait through as well.
@@ -150,6 +151,14 @@ func refuse(error: String) -> Dictionary:
150
151
  return {"ok": false, "error": error}
151
152
 
152
153
 
154
+ ## [param said], the refusal for [param node_path] found nowhere in this scene, with how a path is
155
+ ## read when it starts with the root's own name.
156
+ func not_found(said: String, node_path: String) -> String:
157
+ if root == null or root.has_node(NodePath(node_path.get_slice("/", 0))):
158
+ return said
159
+ return Read.from_the_root(said, node_path, str(root.name))
160
+
161
+
153
162
  func close() -> void:
154
163
  if root != null:
155
164
  root.free()
@@ -1,9 +1,11 @@
1
1
  @tool
2
2
  extends Node
3
3
 
4
- ## Animations and animation trees, edited in the open editor.
4
+ ## Animations and animation trees, edited in the open editor or, for a project no editor serves, by a
5
+ ## headless engine.
5
6
 
6
7
  const AnimationReads = preload("../animation_reads.gd")
8
+ const HeadlessCheck = preload("../headless_check.gd")
7
9
  const PropertyValues = preload("../property_values.gd")
8
10
  const Read = preload("../reading.gd")
9
11
  const SceneFile = preload("../scene_file.gd")
@@ -46,7 +48,9 @@ func _open_at(scene_path: String, node_path: String, kind: String) -> Array:
46
48
  return [scene, null, refused]
47
49
  var node: Node = _find_node(scene.root, node_path)
48
50
  if node == null or not node.is_class(kind):
49
- return [scene, null, scene.refuse("%s not found at: %s" % [kind, node_path])]
51
+ return [
52
+ scene, null, scene.refuse(scene.not_found("%s not found at: %s" % [kind, node_path], node_path))
53
+ ]
50
54
  var foreign: String = scene.foreign(node)
51
55
  if not foreign.is_empty():
52
56
  return [scene, null, scene.refuse(foreign)]
@@ -62,25 +66,55 @@ func _read_at(scene_path: String, node_path: String, kind: String) -> Array:
62
66
  return [scene, null, refused]
63
67
  var node: Node = _find_node(scene.root, node_path)
64
68
  if node == null or not node.is_class(kind):
65
- return [scene, null, scene.refuse("%s not found at: %s" % [kind, node_path])]
69
+ return [
70
+ scene, null, scene.refuse(scene.not_found("%s not found at: %s" % [kind, node_path], node_path))
71
+ ]
66
72
  return [scene, node, {}]
67
73
 
68
74
 
69
75
  ## Writes [param scene] once [param check] passes, then the files of [param homes], answering
70
76
  ## [param answer] or a refusal.
71
77
  func _finish(scene: SceneFile, check: Callable, homes: Dictionary, answer: Dictionary) -> Dictionary:
78
+ if not Engine.is_editor_hint():
79
+ for file: Variant in homes:
80
+ var home: Resource = homes[file]
81
+ if _scripted(home):
82
+ return (
83
+ scene
84
+ . refuse(
85
+ (
86
+ (
87
+ (
88
+ "Nothing was written: %s holds a resource with a script that is not a tool, and what "
89
+ + "that script changed when a headless engine loaded it cannot be told apart from the "
90
+ + "edit. "
91
+ )
92
+ % file
93
+ )
94
+ + HeadlessCheck.REMEDY
95
+ )
96
+ )
97
+ )
72
98
  var written: Dictionary = scene.write(check)
73
99
  if not written.is_empty():
74
100
  return written
75
101
  for file: Variant in homes:
76
102
  var resource: Resource = homes[file]
77
- var saving: Error = ResourceSaver.save(resource, str(file))
103
+ var saving: Error = SceneFile.save_keeping_uid(resource, str(file))
78
104
  if saving != OK:
79
105
  return {"ok": false, "error": "Failed to save %s: %s" % [file, error_string(saving)]}
80
- EditorInterface.get_resource_filesystem().update_file(str(file))
106
+ if Engine.is_editor_hint():
107
+ EditorInterface.get_resource_filesystem().update_file(str(file))
81
108
  return answer
82
109
 
83
110
 
111
+ ## Whether [param resource], or one it embeds, has a script that is not a tool, which a headless
112
+ ## engine ran as it loaded the file.
113
+ func _scripted(resource: Resource) -> bool:
114
+ var script: Script = resource.get_script()
115
+ return (script != null and not script.is_tool()) or HeadlessCheck.holds_scripted_embedded(resource, {})
116
+
117
+
84
118
  func _parse_json_maybe(value: Variant) -> Variant:
85
119
  if typeof(value) != TYPE_STRING:
86
120
  return value
@@ -399,6 +399,19 @@ func _atlas(entry: Variant, index: int) -> Array:
399
399
  if texture_path.split("/").has(".."):
400
400
  return [false, "sources[%d].texture leaves the project: %s" % [index, texture_path]]
401
401
  if not ResourceLoader.exists(texture_path):
402
+ # An image the engine has not imported loads as nothing, where "no texture" would send a caller
403
+ # looking for a file that is there.
404
+ if FileAccess.file_exists(texture_path) and not FileAccess.file_exists(texture_path + ".import"):
405
+ return [
406
+ false,
407
+ (
408
+ (
409
+ "sources[%d].texture: %s has not been imported, so the engine has no texture for it "
410
+ + "yet. project_import reimport with resourcePath %s imports it."
411
+ )
412
+ % [index, texture_path, texture_path]
413
+ )
414
+ ]
402
415
  return [false, "sources[%d].texture: no texture at %s" % [index, texture_path]]
403
416
  var texture: Texture2D = load(texture_path) as Texture2D
404
417
  if texture == null:
@@ -720,13 +733,15 @@ func set_theme_color(args: Dictionary) -> Dictionary:
720
733
  return {"ok": false, "error": "The theme saved without %s/%s" % [control_type, color_name]}
721
734
 
722
735
  var stored: Color = saved.get_color(color_name, control_type)
723
- return {
724
- "ok": true,
725
- "themePath": theme_path,
726
- "controlType": control_type,
727
- "colorName": color_name,
728
- "color": {"r": stored.r, "g": stored.g, "b": stored.b, "a": stored.a}
729
- }
736
+ return _noting_variation(
737
+ {
738
+ "ok": true,
739
+ "themePath": theme_path,
740
+ "controlType": control_type,
741
+ "colorName": color_name,
742
+ "color": {"r": stored.r, "g": stored.g, "b": stored.b, "a": stored.a}
743
+ }
744
+ )
730
745
 
731
746
 
732
747
  func set_theme_font_size(args: Dictionary) -> Dictionary:
@@ -743,7 +758,23 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
743
758
  if theme == null:
744
759
  return {"ok": false, "error": "No Theme at %s. Create one there first." % theme_path}
745
760
 
746
- theme.set_font_size(font_size_name, control_type, Read.as_int(args.get("size", 0)))
761
+ # Godot reads a size of 0 or less as unset and answers the theme's default, so -3 would be read
762
+ # back as 16.
763
+ var pixels: float = Read.as_float(args.get("size"), 0.0)
764
+ if not is_finite(pixels) or pixels < 1 or pixels != floorf(pixels):
765
+ var shown: String = str(int(pixels)) if pixels == floorf(pixels) else str(pixels)
766
+ return {
767
+ "ok": false,
768
+ "error":
769
+ (
770
+ (
771
+ "size must be a whole number of pixels, 1 or more, not %s: Godot reads 0 or less as no size "
772
+ % shown
773
+ )
774
+ + "and uses the theme's default instead."
775
+ )
776
+ }
777
+ theme.set_font_size(font_size_name, control_type, int(pixels))
747
778
 
748
779
  var unsaved: String = _save_theme(theme, theme_path, "%s/font_sizes/%s" % [control_type, font_size_name])
749
780
  if not unsaved.is_empty():
@@ -753,10 +784,32 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
753
784
  if saved == null or not saved.has_font_size(font_size_name, control_type):
754
785
  return {"ok": false, "error": "The theme saved without %s/%s" % [control_type, font_size_name]}
755
786
 
756
- return {
757
- "ok": true,
758
- "themePath": theme_path,
759
- "controlType": control_type,
760
- "fontSizeName": font_size_name,
761
- "size": saved.get_font_size(font_size_name, control_type)
762
- }
787
+ return _noting_variation(
788
+ {
789
+ "ok": true,
790
+ "themePath": theme_path,
791
+ "controlType": control_type,
792
+ "fontSizeName": font_size_name,
793
+ "size": saved.get_font_size(font_size_name, control_type)
794
+ }
795
+ )
796
+
797
+
798
+ ## [param answer] with a note when its controlType names no class a theme styles. Any name is a
799
+ ## theme type, so one that is no class is a type variation, which styles only a control whose
800
+ ## theme_type_variation names it, and a misspelt "Buton" would otherwise read as Button set.
801
+ func _noting_variation(answer: Dictionary) -> Dictionary:
802
+ var type: String = str(answer.get("controlType", ""))
803
+ if (
804
+ ClassDB.class_exists(type)
805
+ and (ClassDB.is_parent_class(type, "Control") or ClassDB.is_parent_class(type, "Window"))
806
+ ):
807
+ return answer
808
+ answer["note"] = (
809
+ (
810
+ "%s is not a Control or Window class, so this is a type variation: it styles only a control "
811
+ + "whose theme_type_variation is %s."
812
+ )
813
+ % [type, type]
814
+ )
815
+ return answer
@@ -111,7 +111,7 @@ func _kept(scene: SceneFile, saved: Node, node: Node, names: Array) -> String:
111
111
  "%s.%s would load from the file as %s: the scene does not keep it, because the property "
112
112
  + "is not one a scene stores or the node belongs to another scene."
113
113
  )
114
- % [at, property, JSON.stringify(_values.serialize_value(loaded))]
114
+ % [at, property, JSON.stringify(_values.serialize_value(loaded), "", false)]
115
115
  )
116
116
  return ""
117
117
 
@@ -312,7 +312,7 @@ func add_node(args: Dictionary) -> Dictionary:
312
312
  var root: Node = scene.root
313
313
  var parent: Node = _find_node(root, parent_node_path)
314
314
  if not parent:
315
- return scene.refuse("Parent node not found: " + parent_node_path)
315
+ return scene.refuse(scene.not_found("Parent node not found: " + parent_node_path, parent_node_path))
316
316
  var foreign: String = scene.foreign(parent)
317
317
  if not foreign.is_empty():
318
318
  return scene.refuse(foreign)
@@ -387,7 +387,7 @@ func delete_node(args: Dictionary) -> Dictionary:
387
387
 
388
388
  var node: Node = _find_node(scene.root, node_path)
389
389
  if not node:
390
- return scene.refuse("Node not found: " + node_path)
390
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
391
391
  var fixed: String = scene.fixed(node)
392
392
  if not fixed.is_empty():
393
393
  return scene.refuse(fixed)
@@ -427,7 +427,7 @@ func duplicate_node(args: Dictionary) -> Dictionary:
427
427
  var root: Node = scene.root
428
428
  var source: Node = _find_node(root, node_path)
429
429
  if not source:
430
- return scene.refuse("Node not found: " + node_path)
430
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
431
431
  if source == root:
432
432
  return scene.refuse("The root of %s cannot be copied into its own scene." % scene_path)
433
433
  var foreign: String = scene.foreign(source)
@@ -438,7 +438,7 @@ func duplicate_node(args: Dictionary) -> Dictionary:
438
438
  if not parent_path.is_empty():
439
439
  target_parent = _find_node(root, parent_path)
440
440
  if not target_parent:
441
- return scene.refuse("Parent not found: " + parent_path)
441
+ return scene.refuse(scene.not_found("Parent not found: " + parent_path, parent_path))
442
442
  foreign = scene.foreign(target_parent)
443
443
  if not foreign.is_empty():
444
444
  return scene.refuse(foreign)
@@ -491,9 +491,9 @@ func reparent_node(args: Dictionary) -> Dictionary:
491
491
  var node: Node = _find_node(root, node_path)
492
492
  var new_parent: Node = _find_node(root, new_parent_path)
493
493
  if not node:
494
- return scene.refuse("Node not found: " + node_path)
494
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
495
495
  if not new_parent:
496
- return scene.refuse("New parent not found: " + new_parent_path)
496
+ return scene.refuse(scene.not_found("New parent not found: " + new_parent_path, new_parent_path))
497
497
  var fixed: String = scene.fixed(node)
498
498
  if not fixed.is_empty():
499
499
  return scene.refuse(fixed)
@@ -562,7 +562,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
562
562
 
563
563
  var node: Node = _find_node(scene.root, node_path)
564
564
  if not node:
565
- return scene.refuse("Node not found: " + node_path)
565
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
566
566
  var foreign: String = scene.foreign(node)
567
567
  if not foreign.is_empty():
568
568
  return scene.refuse(foreign)
@@ -606,7 +606,7 @@ func get_node_properties(args: Dictionary) -> Dictionary:
606
606
 
607
607
  var node: Node = _find_node(scene.root, node_path)
608
608
  if not node:
609
- return scene.refuse("Node not found: " + node_path)
609
+ return scene.refuse(scene.not_found("Node not found: " + node_path, node_path))
610
610
 
611
611
  # Against the default the node would have in no scene: its script's for what the script
612
612
  # declares, the class's for the rest. Compared with a bare instance of the class, every exported
@@ -792,9 +792,9 @@ func _connection_ends(
792
792
  var source: Node = _find_node(scene.root, source_node_path)
793
793
  var target: Node = _find_node(scene.root, target_node_path)
794
794
  if not source:
795
- return [null, "Source node not found: " + source_node_path]
795
+ return [null, scene.not_found("Source node not found: " + source_node_path, source_node_path)]
796
796
  if not target:
797
- return [null, "Target node not found: " + target_node_path]
797
+ return [null, scene.not_found("Target node not found: " + target_node_path, target_node_path)]
798
798
  # pack drops a connection with either end inside an instanced scene, and the answer said it was
799
799
  # made or removed.
800
800
  for end: Node in [source, target]:
@@ -897,7 +897,7 @@ func list_connections(args: Dictionary) -> Dictionary:
897
897
  if not filter_path.is_empty():
898
898
  filtered = _find_node(root, filter_path)
899
899
  if filtered == null:
900
- return scene.refuse("Node not found: " + filter_path)
900
+ return scene.refuse(scene.not_found("Node not found: " + filter_path, filter_path))
901
901
  var nodes: Array = []
902
902
  _collect_nodes_recursive(root, ".", nodes)
903
903
 
@@ -99,3 +99,18 @@ static func json_or_null(text: String) -> Variant:
99
99
  if json.parse(text) != OK:
100
100
  return null
101
101
  return json.data
102
+
103
+
104
+ ## [param said], the refusal for [param node_path] found nowhere in a scene whose root is called
105
+ ## [param root_name], with how paths are read when the path starts with that name and the root has
106
+ ## no child called that. Paths go from the root, so its own name there names a child of it, and a
107
+ ## caller who read the root as Main in a tree wrote Main/Body and was told only that nothing is there.
108
+ static func from_the_root(said: String, node_path: String, root_name: String) -> String:
109
+ var first: String = node_path.get_slice("/", 0)
110
+ if root_name.is_empty() or first != root_name:
111
+ return said
112
+ var rest: String = node_path.substr(first.length() + 1)
113
+ var how: String = 'Paths go from the root, %s, whose own path is "."' % root_name
114
+ if not rest.is_empty():
115
+ how += ', so this one is "%s"' % rest
116
+ return "%s%s %s." % [said, "" if said.ends_with(".") else ".", how]
@@ -660,7 +660,9 @@ static func _is_empty(owed: Variant) -> bool:
660
660
  ## taken the lot: see [member _outgoing].
661
661
  func _send_response(client: StreamPeerTCP, data: Dictionary) -> void:
662
662
  var owed: PackedByteArray = _outgoing.get(client, PackedByteArray())
663
- owed.append_array((JSON.stringify(data) + "\n").to_utf8_buffer())
663
+ # Keys left in the order they were put in: sorted, a map read came back in another order than
664
+ # its keys() and the game iterate it in.
665
+ owed.append_array((JSON.stringify(data, "", false) + "\n").to_utf8_buffer())
664
666
  _outgoing[client] = owed
665
667
 
666
668
 
@@ -129,8 +129,8 @@ func set_property(params: Dictionary) -> Dictionary:
129
129
  % [
130
130
  reached["called"],
131
131
  named,
132
- JSON.stringify(_values.serialize(given)),
133
- JSON.stringify(_values.serialize(now)),
132
+ JSON.stringify(_values.serialize(given), "", false),
133
+ JSON.stringify(_values.serialize(now), "", false),
134
134
  why,
135
135
  ]
136
136
  )
@@ -12,6 +12,7 @@ const Values = preload("runtime_values.gd")
12
12
  const Queries = preload("runtime_queries.gd")
13
13
  const Read = preload("reading.gd")
14
14
  const Hover = preload("runtime_hover.gd")
15
+ const Items = preload("runtime_items.gd")
15
16
  const Screen = preload("runtime_screen.gd")
16
17
  const Targets = preload("runtime_targets.gd")
17
18
  const Mouse = preload("runtime_mouse.gd")
@@ -509,6 +510,7 @@ func click(params: Dictionary) -> Dictionary:
509
510
  var node_path: String = str(params.get("path", ""))
510
511
  var wanted: String = str(params.get("says", ""))
511
512
  var found: Variant = null
513
+ var item: Variant = null
512
514
  if not wanted.is_empty():
513
515
  var picked: Dictionary = Targets.control_saying(
514
516
  _host.get_tree().root, "/root" if node_path.is_empty() else node_path, wanted, params.get("index")
@@ -517,6 +519,7 @@ func click(params: Dictionary) -> Dictionary:
517
519
  return picked
518
520
  node_path = picked["path"]
519
521
  found = picked["found"]
522
+ item = picked.get("item")
520
523
  if node_path.is_empty():
521
524
  return {"type": "error", "message": "click needs a path, or says to find the control by its words"}
522
525
 
@@ -541,6 +544,16 @@ func click(params: Dictionary) -> Dictionary:
541
544
  # other words, a link among them. Said either way, so a press on the middle is never read as one
542
545
  # on the words.
543
546
  var aim: Vector2 = control.size * 0.5
547
+ var item_scrolled: bool = false
548
+ if item != null:
549
+ var found_by_words: Dictionary = found
550
+ var placed_item: Dictionary = await Items.in_view(
551
+ _host.get_tree(), control, node_path, item, wanted, found_by_words
552
+ )
553
+ if placed_item.has("message"):
554
+ return placed_item
555
+ aim = placed_item["point"]
556
+ item_scrolled = placed_item["scrolled"]
544
557
  var rich: RichTextLabel = control as RichTextLabel
545
558
  if found != null and rich != null:
546
559
  var placed: Dictionary = Targets.point_on_words(rich, wanted)
@@ -568,9 +581,9 @@ func click(params: Dictionary) -> Dictionary:
568
581
  # off the top of its container is inside the viewport, behind whatever is drawn up there, so
569
582
  # the click went to that instead and said so. Measured on a hall whose staff panel had been
570
583
  # scrolled past: the button was at y 69 and its container started at y 166.
571
- var scrolled: bool = false
584
+ var scrolled: bool = item_scrolled
572
585
  if not _in_sight(control, own, local):
573
- scrolled = await _scroll_into_view(control)
586
+ scrolled = await _scroll_into_view(control) or item_scrolled
574
587
  var left: String = Values.afterwards(control)
575
588
  if left != "in_tree":
576
589
  return {