gdharness 1.2.35 → 1.2.37
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/build/cli.js +478 -100
- package/build/godot/addons/gdharness_editor/headless_check.gd +374 -0
- package/build/godot/addons/gdharness_editor/headless_check.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/property_values.gd +3 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +345 -10
- package/build/godot/addons/gdharness_editor/serialisation.gd +103 -5
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -18
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +24 -13
- package/build/godot/addons/gdharness_runtime/call_errors.gd +7 -2
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +9 -5
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +33 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +21 -4
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +7 -5
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +116 -3
- package/build/godot/addons/gdharness_runtime/serialisation.gd +103 -5
- package/build/godot/operations/classdb_queries.gd +22 -0
- package/build/godot/operations/dependencies.gd +90 -20
- package/build/godot/operations/editor_tools.gd +57 -0
- package/build/godot/operations/gdscript_analysis.gd +45 -2
- package/build/godot/operations/gdscript_authoring.gd +5 -2
- package/build/godot/operations/godot_operations.gd +53 -2
- package/build/godot/operations/property_values.gd +3 -0
- package/build/godot/operations/rename_checks.gd +0 -19
- package/build/godot/operations/script_check.gd +98 -0
- package/build/godot/operations/serialisation.gd +103 -5
- package/build/index.js +466 -90
- package/package.json +1 -1
|
@@ -4,6 +4,7 @@ extends Node
|
|
|
4
4
|
## Resource files, shaders, tilesets and themes, written through the open editor.
|
|
5
5
|
|
|
6
6
|
const FileReads = preload("../file_reads.gd")
|
|
7
|
+
const HeadlessCheck = preload("../headless_check.gd")
|
|
7
8
|
const PropertyValues = preload("../property_values.gd")
|
|
8
9
|
const Read = preload("../reading.gd")
|
|
9
10
|
const SceneFile = preload("../scene_file.gd")
|
|
@@ -118,9 +119,12 @@ func _load_theme(theme_path: String) -> Theme:
|
|
|
118
119
|
## Read back because a property can be set and still not be saved: one without storage, a
|
|
119
120
|
## script variable that is not exported. Both were answered as changed.
|
|
120
121
|
func _save_checked(resource: Resource, path: String, names: Array, previous: Variant) -> String:
|
|
121
|
-
var saving: Error =
|
|
122
|
+
var saving: Error = SceneFile.save_keeping_uid(resource, path)
|
|
122
123
|
if saving != OK:
|
|
123
124
|
return "Failed to save %s: %s" % [path, error_string(saving)]
|
|
125
|
+
var unkept: String = _unkept_headless(resource, path, names, previous)
|
|
126
|
+
if not unkept.is_empty():
|
|
127
|
+
return "Nothing was written: " + unkept + HeadlessCheck.put_back(path, previous)
|
|
124
128
|
var loaded: Resource = ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
125
129
|
var lost: String = ""
|
|
126
130
|
if loaded == null:
|
|
@@ -141,14 +145,50 @@ func _save_checked(resource: Resource, path: String, names: Array, previous: Var
|
|
|
141
145
|
if lost.is_empty():
|
|
142
146
|
_written(path)
|
|
143
147
|
return ""
|
|
148
|
+
return lost + HeadlessCheck.put_back(path, previous)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
## Outside the editor, puts back in the file just saved at [param path] what a script that is not a
|
|
152
|
+
## tool changed and the call did not ask for, [param names] being what it asked for, and answers ""
|
|
153
|
+
## or why that did not hold. See [HeadlessCheck].
|
|
154
|
+
func _unkept_headless(resource: Resource, path: String, names: Array, previous: Variant) -> String:
|
|
155
|
+
if Engine.is_editor_hint():
|
|
156
|
+
return ""
|
|
157
|
+
var script: Script = resource.get_script()
|
|
158
|
+
var scripted: bool = script != null and not script.is_tool()
|
|
159
|
+
if not path.ends_with(".tres"):
|
|
160
|
+
if scripted or HeadlessCheck.holds_scripted_embedded(resource, {}):
|
|
161
|
+
return (
|
|
162
|
+
(
|
|
163
|
+
(
|
|
164
|
+
"%s is a binary resource with a script that is not a tool, and what that script "
|
|
165
|
+
+ "changed when a headless engine loaded it cannot be told apart from the file here. "
|
|
166
|
+
)
|
|
167
|
+
% path
|
|
168
|
+
)
|
|
169
|
+
+ HeadlessCheck.REMEDY
|
|
170
|
+
)
|
|
171
|
+
return ""
|
|
172
|
+
var before: String = ""
|
|
144
173
|
if previous is PackedByteArray:
|
|
145
174
|
var bytes: PackedByteArray = previous
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
before = bytes.get_string_from_utf8()
|
|
176
|
+
var asked: Dictionary = {"script": true}
|
|
177
|
+
var reached: Dictionary = {}
|
|
178
|
+
for key: Variant in names:
|
|
179
|
+
var property: String = str(key)
|
|
180
|
+
asked[property] = true
|
|
181
|
+
asked[property.get_slice(":", 0)] = true
|
|
182
|
+
reached.merge(HeadlessCheck.reached(resource, property))
|
|
183
|
+
return HeadlessCheck.settle_saved(
|
|
184
|
+
path,
|
|
185
|
+
before,
|
|
186
|
+
HeadlessCheck.stored(resource).values(),
|
|
187
|
+
resource if scripted else null,
|
|
188
|
+
asked,
|
|
189
|
+
reached,
|
|
190
|
+
func() -> Error: return SceneFile.save_keeping_uid(resource, path)
|
|
191
|
+
)
|
|
152
192
|
|
|
153
193
|
|
|
154
194
|
func create_resource(args: Dictionary) -> Dictionary:
|
|
@@ -321,7 +361,7 @@ func create_tileset(args: Dictionary) -> Dictionary:
|
|
|
321
361
|
var folder: String = SceneFile.make_folder_for(tileset_path)
|
|
322
362
|
if not folder.is_empty():
|
|
323
363
|
return {"ok": false, "error": folder}
|
|
324
|
-
var saving: Error =
|
|
364
|
+
var saving: Error = SceneFile.save_keeping_uid(tileset, tileset_path)
|
|
325
365
|
if saving != OK:
|
|
326
366
|
return {"ok": false, "error": "Failed to save %s: %s" % [tileset_path, error_string(saving)]}
|
|
327
367
|
|
|
@@ -443,6 +483,7 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
|
|
|
443
483
|
# reports placing tiles and a scene that shows none.
|
|
444
484
|
var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
|
|
445
485
|
|
|
486
|
+
scene.asked(node, ["layer_%d/tile_data" % layer if layered != null else "tile_map_data"])
|
|
446
487
|
var wanted: Array = []
|
|
447
488
|
for index: int in placed.size():
|
|
448
489
|
var cell: Array = _cell(placed[index], index, tile_set)
|
|
@@ -621,6 +662,19 @@ func _cell_at(node: Node, layer: int, at: Vector2i) -> Array:
|
|
|
621
662
|
return []
|
|
622
663
|
|
|
623
664
|
|
|
665
|
+
## Saves [param theme] over [param theme_path] with [param item] changed, answering "" or why not.
|
|
666
|
+
func _save_theme(theme: Theme, theme_path: String, item: String) -> String:
|
|
667
|
+
var previous: PackedByteArray = FileAccess.get_file_as_bytes(theme_path)
|
|
668
|
+
var saving: Error = SceneFile.save_keeping_uid(theme, theme_path)
|
|
669
|
+
if saving != OK:
|
|
670
|
+
return "Failed to save %s: %s" % [theme_path, error_string(saving)]
|
|
671
|
+
var unkept: String = _unkept_headless(theme, theme_path, [item], previous)
|
|
672
|
+
if not unkept.is_empty():
|
|
673
|
+
return "Nothing was written: " + unkept + HeadlessCheck.put_back(theme_path, previous)
|
|
674
|
+
_written(theme_path)
|
|
675
|
+
return ""
|
|
676
|
+
|
|
677
|
+
|
|
624
678
|
func set_theme_color(args: Dictionary) -> Dictionary:
|
|
625
679
|
var theme_path: String = _ensure_res_path(str(args.get("themePath", "")))
|
|
626
680
|
if theme_path == "res://":
|
|
@@ -644,11 +698,9 @@ func set_theme_color(args: Dictionary) -> Dictionary:
|
|
|
644
698
|
)
|
|
645
699
|
theme.set_color(color_name, control_type, color)
|
|
646
700
|
|
|
647
|
-
var
|
|
648
|
-
if
|
|
649
|
-
return {"ok": false, "error":
|
|
650
|
-
|
|
651
|
-
_written(theme_path)
|
|
701
|
+
var unsaved: String = _save_theme(theme, theme_path, "%s/colors/%s" % [control_type, color_name])
|
|
702
|
+
if not unsaved.is_empty():
|
|
703
|
+
return {"ok": false, "error": unsaved}
|
|
652
704
|
|
|
653
705
|
var saved: Theme = _load_theme(theme_path)
|
|
654
706
|
if saved == null or not saved.has_color(color_name, control_type):
|
|
@@ -680,11 +732,9 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
|
|
|
680
732
|
|
|
681
733
|
theme.set_font_size(font_size_name, control_type, Read.as_int(args.get("size", 0)))
|
|
682
734
|
|
|
683
|
-
var
|
|
684
|
-
if
|
|
685
|
-
return {"ok": false, "error":
|
|
686
|
-
|
|
687
|
-
_written(theme_path)
|
|
735
|
+
var unsaved: String = _save_theme(theme, theme_path, "%s/font_sizes/%s" % [control_type, font_size_name])
|
|
736
|
+
if not unsaved.is_empty():
|
|
737
|
+
return {"ok": false, "error": unsaved}
|
|
688
738
|
|
|
689
739
|
var saved: Theme = _load_theme(theme_path)
|
|
690
740
|
if saved == null or not saved.has_font_size(font_size_name, control_type):
|
|
@@ -90,7 +90,7 @@ func _missing_on(node: Node, missing: String) -> String:
|
|
|
90
90
|
if probe.reload() == OK:
|
|
91
91
|
return missing
|
|
92
92
|
return (
|
|
93
|
-
"%s. Its script, %s, does not compile, so
|
|
93
|
+
"%s. Its script, %s, does not compile, so none of what it declares is known here."
|
|
94
94
|
% [missing, script.resource_path]
|
|
95
95
|
)
|
|
96
96
|
|
|
@@ -213,6 +213,10 @@ func create_scene(args: Dictionary) -> Dictionary:
|
|
|
213
213
|
|
|
214
214
|
var root: Node = ClassDB.instantiate(root_node_type)
|
|
215
215
|
root.name = root_node_name
|
|
216
|
+
var scene: SceneFile = SceneFile.new()
|
|
217
|
+
scene.root = root
|
|
218
|
+
scene.path = scene_path
|
|
219
|
+
scene.asked(root, ["script"])
|
|
216
220
|
|
|
217
221
|
if not script_path.is_empty():
|
|
218
222
|
var full_script_path: String = _to_scene_res_path(project_path, script_path)
|
|
@@ -238,9 +242,6 @@ func create_scene(args: Dictionary) -> Dictionary:
|
|
|
238
242
|
if not folder.is_empty():
|
|
239
243
|
root.free()
|
|
240
244
|
return {"ok": false, "error": folder}
|
|
241
|
-
var scene: SceneFile = SceneFile.new()
|
|
242
|
-
scene.root = root
|
|
243
|
-
scene.path = scene_path
|
|
244
245
|
var written: Dictionary = scene.write()
|
|
245
246
|
if not written.is_empty():
|
|
246
247
|
return written
|
|
@@ -307,6 +308,10 @@ func add_node(args: Dictionary) -> Dictionary:
|
|
|
307
308
|
parent.add_child(new_node)
|
|
308
309
|
Placing.place(new_node, place)
|
|
309
310
|
Placing.own(new_node, root)
|
|
311
|
+
var unsettled: String = scene.settle()
|
|
312
|
+
if not unsettled.is_empty():
|
|
313
|
+
return scene.refuse("Nothing was written: " + unsettled)
|
|
314
|
+
scene.asked(new_node, properties.keys())
|
|
310
315
|
var refused_property: String = _properties.write_all(new_node, properties)
|
|
311
316
|
if not refused_property.is_empty():
|
|
312
317
|
return scene.refuse(refused_property)
|
|
@@ -415,6 +420,7 @@ func duplicate_node(args: Dictionary) -> Dictionary:
|
|
|
415
420
|
target_parent.add_child(duplicated_node)
|
|
416
421
|
Placing.own(duplicated_node, root)
|
|
417
422
|
Placing.reconnect(copies, source)
|
|
423
|
+
scene.copied(copies)
|
|
418
424
|
|
|
419
425
|
var new_path: String = scene.path_of(duplicated_node)
|
|
420
426
|
var written: Dictionary = scene.write(
|
|
@@ -530,6 +536,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
|
|
|
530
536
|
if not unusable.is_empty():
|
|
531
537
|
return scene.refuse(unusable)
|
|
532
538
|
|
|
539
|
+
scene.asked(node, properties.keys())
|
|
533
540
|
var refused_property: String = _properties.write_all(node, properties)
|
|
534
541
|
if not refused_property.is_empty():
|
|
535
542
|
return scene.refuse(_missing_on(node, refused_property))
|
|
@@ -570,12 +577,10 @@ func get_node_properties(args: Dictionary) -> Dictionary:
|
|
|
570
577
|
var props: Dictionary = _properties_of(node, include_defaults, scene.set_here(node))
|
|
571
578
|
var cells: Dictionary = ResourceTools.cells_of(node)
|
|
572
579
|
scene.close()
|
|
573
|
-
var answer: Dictionary = {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
"
|
|
577
|
-
"unsavedInEditor": SceneFile.unsaved(scene_path)
|
|
578
|
-
}
|
|
580
|
+
var answer: Dictionary = {"ok": true, "nodePath": node_path, "properties": props}
|
|
581
|
+
# Only an editor holds anything unsaved; a headless engine answering false would say it looked.
|
|
582
|
+
if Engine.is_editor_hint():
|
|
583
|
+
answer["unsavedInEditor"] = SceneFile.unsaved(scene_path)
|
|
579
584
|
answer.merge(cells)
|
|
580
585
|
# An empty answer read as a node with nothing on it, rather than one with nothing changed.
|
|
581
586
|
if props.is_empty() and not include_defaults:
|
|
@@ -600,7 +605,10 @@ func save_scene(args: Dictionary) -> Dictionary:
|
|
|
600
605
|
return {"ok": false, "error": unsaved}
|
|
601
606
|
|
|
602
607
|
var open_root: Node = null
|
|
603
|
-
|
|
608
|
+
var open_roots: Array[Node] = []
|
|
609
|
+
if Engine.is_editor_hint():
|
|
610
|
+
open_roots = EditorInterface.get_open_scene_roots()
|
|
611
|
+
for open: Node in open_roots:
|
|
604
612
|
if open.scene_file_path == scene_path:
|
|
605
613
|
open_root = open
|
|
606
614
|
if open_root != null and target_path == scene_path:
|
|
@@ -618,7 +626,7 @@ func save_scene(args: Dictionary) -> Dictionary:
|
|
|
618
626
|
written = SceneFile.save_packed(packed, target_path)
|
|
619
627
|
else:
|
|
620
628
|
var scene: SceneFile = SceneFile.new()
|
|
621
|
-
var refused: Dictionary = scene.open(scene_path,
|
|
629
|
+
var refused: Dictionary = scene.open(scene_path, true)
|
|
622
630
|
if not refused.is_empty():
|
|
623
631
|
return refused
|
|
624
632
|
scene.path = target_path
|
|
@@ -897,4 +905,7 @@ func list_connections(args: Dictionary) -> Dictionary:
|
|
|
897
905
|
)
|
|
898
906
|
|
|
899
907
|
scene.close()
|
|
900
|
-
|
|
908
|
+
var listed: Dictionary = {"ok": true, "connections": connections}
|
|
909
|
+
if Engine.is_editor_hint():
|
|
910
|
+
listed["unsavedInEditor"] = SceneFile.unsaved(scene_path)
|
|
911
|
+
return listed
|
|
@@ -12,8 +12,10 @@ const REFUSED: String = "Error calling method from 'callv': "
|
|
|
12
12
|
|
|
13
13
|
## The engine's reason for refusing the call, without [constant REFUSED], or "" when it ran.
|
|
14
14
|
var refused: String = ""
|
|
15
|
-
##
|
|
15
|
+
## The errors reported while it ran, a line each.
|
|
16
16
|
var raised: Array[String] = []
|
|
17
|
+
## The warnings reported while it ran, a line each.
|
|
18
|
+
var warned: Array[String] = []
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
func _log_message(_message: String, _error: bool) -> void:
|
|
@@ -39,4 +41,7 @@ func _log_error(
|
|
|
39
41
|
if function == "callv" and text.begins_with(REFUSED) and refused.is_empty():
|
|
40
42
|
refused = text.trim_prefix(REFUSED)
|
|
41
43
|
return
|
|
42
|
-
|
|
44
|
+
if error_type == ERROR_TYPE_WARNING:
|
|
45
|
+
warned.append(text)
|
|
46
|
+
else:
|
|
47
|
+
raised.append(text)
|
|
@@ -108,6 +108,9 @@ try {
|
|
|
108
108
|
}
|
|
109
109
|
$p = Patterned $element 'RangeValue'
|
|
110
110
|
if ($p) {
|
|
111
|
+
# A reader speaks the Value pattern's words, such as "75%" for a slider whose game sets them,
|
|
112
|
+
# and the number would otherwise replace them.
|
|
113
|
+
if ($node.Contains('value') -and "$($node.value)") { $node.value_text = $node.value }
|
|
111
114
|
$node.value = $p.Current.Value
|
|
112
115
|
$node.range = @($p.Current.Minimum, $p.Current.Maximum)
|
|
113
116
|
if (-not $p.Current.IsReadOnly) { $actions += 'set_value' }
|
|
@@ -389,17 +392,18 @@ static func _narrow(result: Dictionary, params: Dictionary) -> void:
|
|
|
389
392
|
|
|
390
393
|
|
|
391
394
|
## Whether [param element] is made by the node at [param under] or one below it, and says
|
|
392
|
-
## [param said], each where asked: words as find matches them, in the element's name or
|
|
395
|
+
## [param said], each where asked: words as find matches them, in the element's name, value or
|
|
396
|
+
## value_text.
|
|
393
397
|
static func _matches(element: Dictionary, under: String, said: String) -> bool:
|
|
394
398
|
if not under.is_empty():
|
|
395
399
|
var path: String = str(element.get("node", element.get("part_of", "")))
|
|
396
400
|
if path != under and not path.begins_with(under + "/"):
|
|
397
401
|
return false
|
|
398
402
|
if not said.is_empty():
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
+
for field: String in ["name", "value", "value_text"]:
|
|
404
|
+
if element.has(field) and Says.matches(str(element[field]), said):
|
|
405
|
+
return true
|
|
406
|
+
return false
|
|
403
407
|
return true
|
|
404
408
|
|
|
405
409
|
|
|
@@ -18,6 +18,7 @@ const InputCommands = preload("runtime_input.gd")
|
|
|
18
18
|
const Pad = preload("runtime_pad.gd")
|
|
19
19
|
const Queries = preload("runtime_queries.gd")
|
|
20
20
|
const Read = preload("reading.gd")
|
|
21
|
+
const Targets = preload("runtime_targets.gd")
|
|
21
22
|
const Typing = preload("runtime_typing.gd")
|
|
22
23
|
const Values = preload("runtime_values.gd")
|
|
23
24
|
const Waits = preload("runtime_waits.gd")
|
|
@@ -153,6 +154,7 @@ func _init() -> void:
|
|
|
153
154
|
"choose": _typing.choose,
|
|
154
155
|
"wait_frames": _waits.wait_frames,
|
|
155
156
|
"wait_signal": _waits.wait_signal,
|
|
157
|
+
"watch_signal": _waits.watch_signal,
|
|
156
158
|
"wait_until": _waits.wait_until,
|
|
157
159
|
"send_profile": _send_profile_now,
|
|
158
160
|
"close_game": _close_game,
|
|
@@ -160,6 +162,36 @@ func _init() -> void:
|
|
|
160
162
|
_wait_for_the_profiler()
|
|
161
163
|
|
|
162
164
|
|
|
165
|
+
## Gives a game with no window the size the project opens its window at. Measured: a
|
|
166
|
+
## headless engine's root viewport is 64 by 64 whatever the project settings or --resolution say, so
|
|
167
|
+
## a headless game laid its interface out in 64 pixels, and a button every windowed run shows was
|
|
168
|
+
## refused as off screen. The root takes a size set once the autoloads are in the tree, before the
|
|
169
|
+
## main scene enters it, and the scene lays out at it from its first frame. A size set at the
|
|
170
|
+
## autoload's construction does not hold: the root is a 100 by 100 placeholder then, and the engine
|
|
171
|
+
## gives it the 64 by 64 afterwards. A script run is not a game and keeps the viewport its own code
|
|
172
|
+
## gives it, and a release build is left as the engine made it.
|
|
173
|
+
func _size_a_windowless_game() -> void:
|
|
174
|
+
if DisplayServer.get_name() != "headless" or _script_run() or not OS.is_debug_build():
|
|
175
|
+
return
|
|
176
|
+
var root: Window = get_tree().root
|
|
177
|
+
if Vector2(root.size) != Targets.HEADLESS_VIEWPORT:
|
|
178
|
+
return
|
|
179
|
+
root.size = Vector2i(_window_dimension("width", 1152), _window_dimension("height", 648))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## The window's [param axis] as the project opens it: the override when one is set, otherwise the
|
|
183
|
+
## viewport's size, whose engine default is [param fallback].
|
|
184
|
+
static func _window_dimension(axis: String, fallback: int) -> int:
|
|
185
|
+
var override: int = Read.as_int(
|
|
186
|
+
ProjectSettings.get_setting("display/window/size/window_%s_override" % axis, 0)
|
|
187
|
+
)
|
|
188
|
+
if override > 0:
|
|
189
|
+
return override
|
|
190
|
+
return Read.as_int(
|
|
191
|
+
ProjectSettings.get_setting("display/window/size/viewport_%s" % axis, fallback), fallback
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
163
195
|
## Holds the game's start until the profiler is on, when whatever started the game asked for a
|
|
164
196
|
## profile from its start. The debugger switches the profiler on with a message the game reads only
|
|
165
197
|
## on a poll, which comes every [constant POLLS_PER_READ] lines of script, so the main scene's _ready
|
|
@@ -197,6 +229,7 @@ func _ready() -> void:
|
|
|
197
229
|
# even be un-paused over the socket. A debug server has to stay responsive while the game
|
|
198
230
|
# is frozen, to inspect, capture, inject or resume it.
|
|
199
231
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
232
|
+
_size_a_windowless_game()
|
|
200
233
|
var watched: int = get_tree().root.tree_exiting.connect(_send_the_profile)
|
|
201
234
|
if watched != OK:
|
|
202
235
|
push_error("gdharness could not watch for the game quitting: %d" % watched)
|
|
@@ -168,6 +168,12 @@ func _argument_for(
|
|
|
168
168
|
% [called, named, type_string(wants), index + 1, JSON.stringify(sent)]
|
|
169
169
|
)
|
|
170
170
|
}
|
|
171
|
+
var rounded: String = Values.inexact(sent, wants)
|
|
172
|
+
if not rounded.is_empty():
|
|
173
|
+
return {
|
|
174
|
+
"type": "error",
|
|
175
|
+
"message": "%s.%s takes int as argument %d: %s." % [called, named, index + 1, rounded]
|
|
176
|
+
}
|
|
171
177
|
var given: Variant = _values.fitted(sent, wants)
|
|
172
178
|
if not Values.acceptable(given, wants):
|
|
173
179
|
return {
|
|
@@ -242,6 +248,9 @@ func _value_for(
|
|
|
242
248
|
"message": "%s.%s holds an object: %s" % [called, named, named_object["message"]]
|
|
243
249
|
}
|
|
244
250
|
return {"value": named_object["object"]}
|
|
251
|
+
var rounded: String = Values.inexact(value, wanted)
|
|
252
|
+
if not rounded.is_empty():
|
|
253
|
+
return {"type": "error", "message": "%s.%s holds int: %s." % [called, named, rounded]}
|
|
245
254
|
var as_sent: Variant = _values.fitted(value, TYPE_NIL)
|
|
246
255
|
var cut: bool = Values.cuts(value, wanted)
|
|
247
256
|
var given: Variant = as_sent
|
|
@@ -441,16 +450,24 @@ func call_method(params: Dictionary) -> Dictionary:
|
|
|
441
450
|
"result": _values.serialize(result),
|
|
442
451
|
"elapsed_usec": elapsed
|
|
443
452
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if caught.raised.size() > ERRORS_KEPT:
|
|
447
|
-
answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
|
|
453
|
+
_keep_reported(answer, "errors", caught.raised)
|
|
454
|
+
_keep_reported(answer, "warnings", caught.warned)
|
|
448
455
|
var asked: Variant = params.get("properties")
|
|
449
456
|
if _reads_the_result(asked):
|
|
450
457
|
answer.merge(_read_off_result(result, asked))
|
|
451
458
|
return answer
|
|
452
459
|
|
|
453
460
|
|
|
461
|
+
## Puts the first [constant ERRORS_KEPT] of [param reported] in [param answer] under [param field],
|
|
462
|
+
## with how many more there were, when anything was reported.
|
|
463
|
+
static func _keep_reported(answer: Dictionary, field: String, reported: Array[String]) -> void:
|
|
464
|
+
if reported.is_empty():
|
|
465
|
+
return
|
|
466
|
+
answer[field] = reported.slice(0, ERRORS_KEPT)
|
|
467
|
+
if reported.size() > ERRORS_KEPT:
|
|
468
|
+
answer[field + "_not_shown"] = reported.size() - ERRORS_KEPT
|
|
469
|
+
|
|
470
|
+
|
|
454
471
|
## Why [param named], the last step of a call's method, names no method of [param holder] to call, or
|
|
455
472
|
## "" when it does. Brackets on it holding arguments are refused rather than read: the arguments are
|
|
456
473
|
## taken from args alone, so a call is never made with half of them in one place and half in the
|
|
@@ -412,17 +412,19 @@ static func _outside_note(outside: int, root: Node) -> String:
|
|
|
412
412
|
## headless game's button twenty pixels in from the corner was refused as off screen, and the
|
|
413
413
|
## refusal read as though the button were not there.
|
|
414
414
|
##
|
|
415
|
-
## The size is
|
|
416
|
-
##
|
|
415
|
+
## The size named is the one in force, which the rect printed beside it agrees with. A game the
|
|
416
|
+
## runtime started headless has the size the project opens its window at, and a window of that size
|
|
417
|
+
## would not reach the control either, so only the engine's own 64 by 64 is answered with a window.
|
|
417
418
|
static func no_window_note(viewport: Viewport, root: Window) -> String:
|
|
418
419
|
if viewport != root or root.can_draw():
|
|
419
420
|
return ""
|
|
420
|
-
|
|
421
|
+
var size: Vector2 = viewport.get_visible_rect().size
|
|
422
|
+
if size == HEADLESS_VIEWPORT:
|
|
421
423
|
return (
|
|
422
424
|
". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
|
|
423
425
|
+ "the project settings say: run it with a window to reach this control"
|
|
424
426
|
)
|
|
425
|
-
return ". This game has no window
|
|
427
|
+
return ". This game has no window, and its viewport is %d by %d" % [roundi(size.x), roundi(size.y)]
|
|
426
428
|
|
|
427
429
|
|
|
428
430
|
## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
|
|
@@ -771,7 +773,7 @@ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[Scrol
|
|
|
771
773
|
## Whether a click could not reach [param target] however it scrolled: its centre, where the click
|
|
772
774
|
## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
|
|
773
775
|
## container the click does not scroll. A drawer parked off the side of the screen, or anything
|
|
774
|
-
## outside the
|
|
776
|
+
## outside the viewport of a game with no window, was listed among the controls on screen.
|
|
775
777
|
static func _off_screen(target: Control) -> bool:
|
|
776
778
|
var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
|
|
777
779
|
var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
|
|
@@ -156,6 +156,11 @@ static func cuts(given: Variant, type: int) -> bool:
|
|
|
156
156
|
return not (whole and (value == 0.0 or value == 1.0))
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
## Why [param given] may not be the whole number sent, or "": see [method Serialisation.inexact].
|
|
160
|
+
static func inexact(given: Variant, type: int) -> String:
|
|
161
|
+
return Serialisation.inexact(given, type)
|
|
162
|
+
|
|
163
|
+
|
|
159
164
|
## How a value of [param type] is written to be read as one, or empty when it is not built from fields.
|
|
160
165
|
static func written_as(type: int) -> String:
|
|
161
166
|
return Serialisation.written_as(type)
|
|
@@ -26,8 +26,13 @@ const LOOKS_APART: int = 9
|
|
|
26
26
|
## two looks and a wait for 32 never sees it.
|
|
27
27
|
const COMPARES: Array[String] = ["==", "!=", ">=", "<=", ">", "<"]
|
|
28
28
|
|
|
29
|
+
## The most signal watches kept at once; arming one past this lets the oldest go.
|
|
30
|
+
const MOST_WATCHES: int = 16
|
|
31
|
+
|
|
29
32
|
var _host: Node
|
|
30
33
|
var _values: Values
|
|
34
|
+
var _watches: Dictionary[int, Watch] = {}
|
|
35
|
+
var _next_watch: int = 1
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
func _init(host: Node, values: Values) -> void:
|
|
@@ -56,13 +61,16 @@ func wait_frames(params: Dictionary) -> Dictionary:
|
|
|
56
61
|
return {"type": "waited", "frames": frames, "elapsed_ms": Time.get_ticks_msec() - started}
|
|
57
62
|
|
|
58
63
|
|
|
59
|
-
## Waits for a signal to fire, and answers with what it carried, or that the time ran out.
|
|
64
|
+
## Waits for a signal to fire, and answers with what it carried, or that the time ran out. Given a
|
|
65
|
+
## watch, collects that instead: see [method watch_signal].
|
|
60
66
|
func wait_signal(params: Dictionary) -> Dictionary:
|
|
61
67
|
var node_path: String = str(params.get("path", ""))
|
|
62
68
|
var signal_name: String = str(params.get("signal", ""))
|
|
63
69
|
var timeout_ms: int = Read.as_int(params.get("timeout_ms", 5000), 5000)
|
|
64
70
|
if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
|
|
65
71
|
return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
|
|
72
|
+
if params.has("watch"):
|
|
73
|
+
return await _collect(Read.as_int(params["watch"], 0), timeout_ms)
|
|
66
74
|
if node_path.is_empty() or signal_name.is_empty():
|
|
67
75
|
return {"type": "error", "message": "Node path and signal name required"}
|
|
68
76
|
|
|
@@ -84,7 +92,7 @@ func wait_signal(params: Dictionary) -> Dictionary:
|
|
|
84
92
|
if not catcher.fired and is_instance_valid(node) and node.is_connected(signal_name, callable):
|
|
85
93
|
node.disconnect(signal_name, callable)
|
|
86
94
|
|
|
87
|
-
|
|
95
|
+
var answer: Dictionary = {
|
|
88
96
|
"type": "signal",
|
|
89
97
|
"path": node_path,
|
|
90
98
|
"signal": signal_name,
|
|
@@ -92,6 +100,90 @@ func wait_signal(params: Dictionary) -> Dictionary:
|
|
|
92
100
|
"args": _values.serialize(catcher.args),
|
|
93
101
|
"elapsed_ms": Time.get_ticks_msec() - started,
|
|
94
102
|
}
|
|
103
|
+
if not catcher.fired:
|
|
104
|
+
answer["note"] = (
|
|
105
|
+
"If a call meant to set the signal off was sent beside this wait, it may have run only after"
|
|
106
|
+
+ " the wait answered, as a client can run its calls in turn: arm runtime_wait watch first,"
|
|
107
|
+
+ " make the call, then wait with signal and that watch"
|
|
108
|
+
)
|
|
109
|
+
return answer
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
## Starts recording a signal and answers at once with the watch to collect it by.
|
|
113
|
+
##
|
|
114
|
+
## A wait sent beside the call meant to set its signal off does not see it when the client runs the
|
|
115
|
+
## two in turn: Claude Code ran a key press only after the signal wait beside it had answered, so the
|
|
116
|
+
## wait ran out and the press landed after it. A watch is armed before the call and collected after.
|
|
117
|
+
func watch_signal(params: Dictionary) -> Dictionary:
|
|
118
|
+
var node_path: String = str(params.get("path", ""))
|
|
119
|
+
var signal_name: String = str(params.get("signal", ""))
|
|
120
|
+
if node_path.is_empty() or signal_name.is_empty():
|
|
121
|
+
return {"type": "error", "message": "Node path and signal name required"}
|
|
122
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
123
|
+
if standing.has("message"):
|
|
124
|
+
return standing
|
|
125
|
+
var node: Node = standing["node"]
|
|
126
|
+
if not node.has_signal(signal_name):
|
|
127
|
+
return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
|
|
128
|
+
var watch: Watch = Watch.new(node, node_path, signal_name)
|
|
129
|
+
var listening: Error = node.connect(signal_name, watch.catcher._on_fired)
|
|
130
|
+
if listening != OK:
|
|
131
|
+
return {"type": "error", "message": "Could not listen to signal: " + signal_name}
|
|
132
|
+
while _watches.size() >= MOST_WATCHES:
|
|
133
|
+
var oldest: int = _watches.keys().min()
|
|
134
|
+
_let_go(oldest)
|
|
135
|
+
var id: int = _next_watch
|
|
136
|
+
_next_watch += 1
|
|
137
|
+
_watches[id] = watch
|
|
138
|
+
return {"type": "watching", "watch": id, "path": node_path, "signal": signal_name}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
## Answers at once with the first emission recorded since watch [param id] was armed, otherwise
|
|
142
|
+
## waits for the next until the time runs out. The watch goes either way.
|
|
143
|
+
func _collect(id: int, timeout_ms: int) -> Dictionary:
|
|
144
|
+
if not _watches.has(id):
|
|
145
|
+
return {
|
|
146
|
+
"type": "error",
|
|
147
|
+
"message":
|
|
148
|
+
(
|
|
149
|
+
(
|
|
150
|
+
"No watch %d is armed in this game: it was collected already, %d armed after it let it"
|
|
151
|
+
+ " go, or the game started again since"
|
|
152
|
+
)
|
|
153
|
+
% [id, MOST_WATCHES]
|
|
154
|
+
),
|
|
155
|
+
}
|
|
156
|
+
var watch: Watch = _watches[id]
|
|
157
|
+
var started: int = Time.get_ticks_msec()
|
|
158
|
+
var before: bool = watch.catcher.fired
|
|
159
|
+
while not watch.catcher.fired and Time.get_ticks_msec() - started < timeout_ms:
|
|
160
|
+
await _host.get_tree().process_frame
|
|
161
|
+
_let_go(id)
|
|
162
|
+
var answer: Dictionary = {
|
|
163
|
+
"type": "signal",
|
|
164
|
+
"path": watch.node_path,
|
|
165
|
+
"signal": watch.signal_name,
|
|
166
|
+
"watch": id,
|
|
167
|
+
"fired": watch.catcher.fired,
|
|
168
|
+
"times": watch.catcher.times,
|
|
169
|
+
"args": _values.serialize(watch.catcher.args),
|
|
170
|
+
"elapsed_ms": Time.get_ticks_msec() - started,
|
|
171
|
+
}
|
|
172
|
+
if before:
|
|
173
|
+
answer["fired_before_wait"] = true
|
|
174
|
+
return answer
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
func _let_go(id: int) -> void:
|
|
178
|
+
var watch: Watch = _watches.get(id)
|
|
179
|
+
var _gone: bool = _watches.erase(id)
|
|
180
|
+
if watch == null:
|
|
181
|
+
return
|
|
182
|
+
var held: Object = watch.node.get_ref()
|
|
183
|
+
var node: Node = held as Node
|
|
184
|
+
var callable: Callable = watch.catcher._on_fired
|
|
185
|
+
if node != null and node.is_connected(watch.signal_name, callable):
|
|
186
|
+
node.disconnect(watch.signal_name, callable)
|
|
95
187
|
|
|
96
188
|
|
|
97
189
|
## Waits until a property reads as the value given, or until something under the path says what
|
|
@@ -163,6 +255,9 @@ func wait_until(params: Dictionary) -> Dictionary:
|
|
|
163
255
|
var refused: String = _not_comparable(current, wanted, node_path, property)
|
|
164
256
|
if refused.is_empty():
|
|
165
257
|
refused = _cut_to_fit(params["value"], typeof(current), node_path, property)
|
|
258
|
+
var rounded: String = Values.inexact(params["value"], typeof(current))
|
|
259
|
+
if refused.is_empty() and not rounded.is_empty():
|
|
260
|
+
refused = "%s.%s holds int: %s." % [node_path, property, rounded]
|
|
166
261
|
var never_held: bool = typeof(wanted) != TYPE_NIL and typeof(wanted) != TYPE_OBJECT
|
|
167
262
|
if refused.is_empty() and declared == TYPE_OBJECT and never_held:
|
|
168
263
|
refused = (
|
|
@@ -492,8 +587,26 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
|
|
|
492
587
|
class SignalCatcher:
|
|
493
588
|
extends RefCounted
|
|
494
589
|
var fired: bool = false
|
|
590
|
+
## How many times it fired; [member args] holds what the first carried.
|
|
591
|
+
var times: int = 0
|
|
495
592
|
var args: Array = []
|
|
496
593
|
|
|
497
594
|
func _on_fired(...carried: Array) -> void:
|
|
595
|
+
if not fired:
|
|
596
|
+
args = carried
|
|
498
597
|
fired = true
|
|
499
|
-
|
|
598
|
+
times += 1
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
## A signal recorded from when it was armed until it is collected.
|
|
602
|
+
class Watch:
|
|
603
|
+
extends RefCounted
|
|
604
|
+
var node: WeakRef
|
|
605
|
+
var node_path: String
|
|
606
|
+
var signal_name: String
|
|
607
|
+
var catcher: SignalCatcher = SignalCatcher.new()
|
|
608
|
+
|
|
609
|
+
func _init(watched: Node, path: String, named: String) -> void:
|
|
610
|
+
node = weakref(watched)
|
|
611
|
+
node_path = path
|
|
612
|
+
signal_name = named
|