gdharness 0.9.0 → 0.9.2

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.
@@ -34,9 +34,10 @@ func get_tree(params: Dictionary) -> Dictionary:
34
34
  var max_depth: int = int(params.get("depth", 3))
35
35
  var include_properties: bool = bool(params.get("include_properties", false))
36
36
 
37
- var root: Node = _host.get_tree().root.get_node_or_null(root_path)
38
- if root == null:
39
- return {"type": "error", "message": "Node not found: " + root_path}
37
+ var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
38
+ if reached.has("message"):
39
+ return reached
40
+ var root: Node = reached["node"]
40
41
 
41
42
  return {"type": "tree", "root": _serialize_node_tree(root, 0, max_depth, include_properties)}
42
43
 
@@ -64,20 +65,28 @@ func find_nodes(params: Dictionary) -> Dictionary:
64
65
  if not wanted["script"].is_empty() and not wanted["script"].begins_with("res://"):
65
66
  wanted["script"] = "res://" + wanted["script"]
66
67
 
67
- var root: Node = _host.get_tree().root.get_node_or_null(root_path)
68
- if root == null:
69
- return {"type": "error", "message": "Node not found: " + root_path}
68
+ var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
69
+ if reached.has("message"):
70
+ return reached
71
+ var root: Node = reached["node"]
70
72
 
71
73
  var found: Array[Dictionary] = []
72
74
  var pending: Array[Node] = [root]
73
75
  var truncated: bool = false
76
+ # Counted while the tree is already being walked, for the answer below: how many nodes this
77
+ # find would have matched if the name had been read the way it was probably meant.
78
+ var literal: bool = _is_literal(wanted["name"])
79
+ var nearly: int = 0
74
80
  while not pending.is_empty():
75
81
  var node: Node = pending.pop_front()
76
- if _matches(node, wanted):
82
+ var rest: bool = _matches_apart_from_name(node, wanted)
83
+ if rest and _named(node, wanted["name"]):
77
84
  if found.size() >= limit:
78
85
  truncated = true
79
86
  break
80
87
  found.append(_found(node, wanted_property))
88
+ elif rest and literal and str(node.name).containsn(wanted["name"]):
89
+ nearly += 1
81
90
  # Internal children included, which they were not. A ConfirmationDialog builds its Yes and
82
91
  # its No as internal nodes, and a ScrollContainer its bars, so a find over a screen for
83
92
  # every Button came back without the two buttons the player is being asked to press:
@@ -88,7 +97,17 @@ func find_nodes(params: Dictionary) -> Dictionary:
88
97
  for index: int in range(children.size() - 1, -1, -1):
89
98
  pending.push_front(children[index])
90
99
 
91
- return {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
100
+ var answer: Dictionary = {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
101
+ # Nothing found is the one answer that cannot be told apart from having asked the wrong
102
+ # question, and a name written without a wildcard is the way an agent writes "contains".
103
+ # Said only when it changes the answer, so a genuine nothing stays a plain nothing.
104
+ if found.is_empty() and nearly > 0:
105
+ var holding: String = "names contain" if nearly > 1 else "name contains"
106
+ answer["note"] = (
107
+ 'name is matched as a glob against the whole name; %d node %s "%s", which "*%s*" would find'
108
+ % [nearly, holding, wanted["name"], wanted["name"]]
109
+ )
110
+ return answer
92
111
 
93
112
 
94
113
  ## One match, with the named property on it when one was named.
@@ -102,15 +121,20 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
102
121
  var entry: Dictionary = _serialize_node(node, false)
103
122
  if wanted_property.is_empty():
104
123
  return entry
105
- var has: bool = false
106
- for prop: Dictionary in node.get_property_list():
107
- if str(prop["name"]) == wanted_property:
108
- has = true
109
- break
110
124
  entry["property"] = wanted_property
125
+ # Through a path as well, for the reason [method _reached] gives. A step that is not there on
126
+ # this node reads as not having the property rather than as a refusal, which is the rule this
127
+ # whole function is written to: a find matches nodes of several classes on purpose.
128
+ var reached: Dictionary = _reached(node, str(entry["path"]), wanted_property)
129
+ if reached.has("message"):
130
+ entry["has_property"] = false
131
+ return entry
132
+ var holder: Object = reached["holder"]
133
+ var named: String = reached["name"]
134
+ var has: bool = _has_property(holder, named)
111
135
  entry["has_property"] = has
112
136
  if has:
113
- entry["value"] = _values.serialize(node.get(wanted_property))
137
+ entry["value"] = _values.serialize(holder.get(named))
114
138
  return entry
115
139
 
116
140
 
@@ -123,11 +147,24 @@ static func _anything_asked(wanted: Dictionary[String, String]) -> bool:
123
147
  return false
124
148
 
125
149
 
126
- func _matches(node: Node, wanted: Dictionary[String, String]) -> bool:
150
+ ## Whether [param node] carries the name a find asked for. Empty matches everything, which is what
151
+ ## makes leaving a filter out the same as not having one.
152
+ static func _named(node: Node, pattern: String) -> bool:
153
+ return pattern.is_empty() or str(node.name).matchn(pattern)
154
+
155
+
156
+ ## Whether [param pattern] is a name written as a whole word rather than as a glob, which is how a
157
+ ## caller writes one when they mean "contains". [method String.matchn] answers nothing to it, and
158
+ ## nothing is also what a name that is simply not there answers.
159
+ static func _is_literal(pattern: String) -> bool:
160
+ return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
161
+
162
+
163
+ ## Every filter but the name, so a find that came back empty can say how many nodes the name was
164
+ ## the only thing standing between it and.
165
+ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
127
166
  if not wanted["group"].is_empty() and not node.is_in_group(wanted["group"]):
128
167
  return false
129
- if not wanted["name"].is_empty() and not str(node.name).matchn(wanted["name"]):
130
- return false
131
168
  # What this node says, rather than everything said underneath it. A row is then found by the
132
169
  # label in it, and the path answered is that label's, which is where the words a caller is
133
170
  # looking at actually are: matching every container above it would answer with the screen.
@@ -173,9 +210,10 @@ func read_text(params: Dictionary) -> Dictionary:
173
210
  var include_hidden: bool = bool(params.get("include_hidden", false))
174
211
  var limit: int = clampi(int(params.get("limit", READ_LIMIT)), 1, READ_LIMIT)
175
212
 
176
- var root: Node = _host.get_tree().root.get_node_or_null(root_path)
177
- if root == null:
178
- return {"type": "error", "message": "Node not found: " + root_path}
213
+ var reached: Dictionary = Values.node_at(_host.get_tree().root, root_path)
214
+ if reached.has("message"):
215
+ return reached
216
+ var root: Node = reached["node"]
179
217
 
180
218
  # One line further than asked for, so that whether anything was left behind is read off the
181
219
  # walk rather than guessed at from the count: a panel of exactly as many lines as the caller
@@ -240,9 +278,10 @@ func get_rect(params: Dictionary) -> Dictionary:
240
278
  if node_path.is_empty():
241
279
  return {"type": "error", "message": "Node path required"}
242
280
 
243
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
244
- if node == null:
245
- return {"type": "error", "message": "Node not found: " + node_path}
281
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
282
+ if standing.has("message"):
283
+ return standing
284
+ var node: Node = standing["node"]
246
285
 
247
286
  if node is Control:
248
287
  var control: Control = node
@@ -391,28 +430,71 @@ func get_property(params: Dictionary) -> Dictionary:
391
430
  if node_path.is_empty() or property.is_empty():
392
431
  return {"type": "error", "message": "Node path and property required"}
393
432
 
394
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
395
- if node == null:
396
- return {"type": "error", "message": "Node not found: " + node_path}
433
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
434
+ if standing.has("message"):
435
+ return standing
436
+ var node: Node = standing["node"]
397
437
 
438
+ var reached: Dictionary = _reached(node, node_path, property)
439
+ if reached.has("message"):
440
+ return reached
441
+
442
+ var holder: Object = reached["holder"]
443
+ var named: String = reached["name"]
398
444
  # Asked of the property list rather than read and compared to null, because a property the
399
- # node does not have and a property that is null both read as null.
400
- var known: bool = false
401
- for entry: Dictionary in node.get_property_list():
402
- if str(entry["name"]) == property:
403
- known = true
404
- break
405
- if not known:
406
- return {"type": "error", "message": "%s has no property %s" % [node_path, property]}
445
+ # holder does not have and a property that is null both read as null.
446
+ if not _has_property(holder, named):
447
+ return {"type": "error", "message": "%s has no property %s" % [reached["called"], named]}
407
448
 
408
449
  return {
409
450
  "type": "property",
410
451
  "path": node_path,
411
452
  "property": property,
412
- "value": _values.serialize(node.get(property)),
453
+ "value": _values.serialize(holder.get(named)),
413
454
  }
414
455
 
415
456
 
457
+ ## What [param reaching] names something on, which is the node itself until the name has a colon
458
+ ## in it, and the last name along that path.
459
+ ##
460
+ ## A game's state does not sit on nodes, it hangs off them: the speed of the clock is a property of
461
+ ## a [RefCounted] held by a [RefCounted] held by the root, and that is what an agent asks about.
462
+ ## What a game does hangs off them the same way, so the last name is a property to read, a property
463
+ ## to write or a method to call, and the walk to it is one walk.
464
+ ##
465
+ ## Colons, because that is the separator [method Object.get_indexed] already takes. Walked a step
466
+ ## at a time rather than handed to that method, which answers null for a path that goes wrong
467
+ ## halfway along and for one that ends on null.
468
+ ##
469
+ ## Answers with a `message` instead when a step along the way is not there or holds something that
470
+ ## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
471
+ ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
472
+ func _reached(node: Node, node_path: String, reaching: String) -> Dictionary:
473
+ var parts: PackedStringArray = reaching.split(":")
474
+ var holder: Object = node
475
+ var called: String = node_path
476
+ for step: int in parts.size() - 1:
477
+ var named: String = parts[step]
478
+ if not _has_property(holder, named):
479
+ return {"type": "error", "message": "%s has no property %s" % [called, named]}
480
+ var next: Variant = holder.get(named)
481
+ called = "%s:%s" % [called, named]
482
+ if not next is Object:
483
+ var wanted: String = parts[step + 1]
484
+ return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
485
+ holder = next
486
+ return {"holder": holder, "name": parts[parts.size() - 1], "called": called}
487
+
488
+
489
+ ## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
490
+ ## property read back as null answers it wrongly.
491
+ static func _has_property(holder: Object, named: String) -> bool:
492
+ for entry: Dictionary in holder.get_property_list():
493
+ if str(entry["name"]) == named:
494
+ return true
495
+ return false
496
+
497
+
416
498
  func set_property(params: Dictionary) -> Dictionary:
417
499
  var node_path: String = str(params.get("path", ""))
418
500
  var property: String = str(params.get("property", ""))
@@ -421,19 +503,29 @@ func set_property(params: Dictionary) -> Dictionary:
421
503
  if node_path.is_empty() or property.is_empty():
422
504
  return {"type": "error", "message": "Node path and property required"}
423
505
 
424
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
425
- if node == null:
426
- return {"type": "error", "message": "Node not found: " + node_path}
506
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
507
+ if standing.has("message"):
508
+ return standing
509
+ var node: Node = standing["node"]
427
510
 
428
- var old_value: Variant = node.get(property)
429
- node.set(property, _values.fitted(value, typeof(old_value)))
511
+ # Through a path as well, for the reason [method _reached] gives, and read back off the same
512
+ # holder afterwards: a set that does not take says so by answering with the old value, which is
513
+ # how a typed container refusing a write is told from one accepting it.
514
+ var reached: Dictionary = _reached(node, node_path, property)
515
+ if reached.has("message"):
516
+ return reached
517
+
518
+ var holder: Object = reached["holder"]
519
+ var named: String = reached["name"]
520
+ var old_value: Variant = holder.get(named)
521
+ holder.set(named, _values.fitted(value, typeof(old_value)))
430
522
 
431
523
  return {
432
524
  "type": "property_set",
433
525
  "path": node_path,
434
526
  "property": property,
435
527
  "old_value": _values.serialize(old_value),
436
- "new_value": _values.serialize(node.get(property))
528
+ "new_value": _values.serialize(holder.get(named))
437
529
  }
438
530
 
439
531
 
@@ -445,18 +537,28 @@ func call_method(params: Dictionary) -> Dictionary:
445
537
  if node_path.is_empty() or method.is_empty():
446
538
  return {"type": "error", "message": "Node path and method required"}
447
539
 
448
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
449
- if node == null:
450
- return {"type": "error", "message": "Node not found: " + node_path}
540
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
541
+ if standing.has("message"):
542
+ return standing
543
+ var node: Node = standing["node"]
544
+
545
+ # Through a path as well, for the reason [method _reached] gives. What a game does hangs off its
546
+ # nodes as much as its state does, so reading `_game:run:day` while being unable to call
547
+ # `_game:run:advance` answers half of what a node holds and refuses the other half.
548
+ var reached: Dictionary = _reached(node, node_path, method)
549
+ if reached.has("message"):
550
+ return reached
451
551
 
452
- if not node.has_method(method):
453
- return {"type": "error", "message": "Method not found: " + method}
552
+ var holder: Object = reached["holder"]
553
+ var named: String = reached["name"]
554
+ if not holder.has_method(named):
555
+ return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
454
556
 
455
557
  var deserialized_args: Array = []
456
558
  for index: int in args.size():
457
- deserialized_args.append(_values.fitted(args[index], _values.parameter_type(node, method, index)))
559
+ deserialized_args.append(_values.fitted(args[index], _values.parameter_type(holder, named, index)))
458
560
 
459
- var result: Variant = node.callv(method, deserialized_args)
561
+ var result: Variant = holder.callv(named, deserialized_args)
460
562
 
461
563
  return {"type": "method_result", "path": node_path, "method": method, "result": _values.serialize(result)}
462
564
 
@@ -42,6 +42,37 @@ func serialize(value: Variant) -> Variant:
42
42
  return call(serialiser, value) if not serialiser.is_empty() else value
43
43
 
44
44
 
45
+ ## The node [param path] names under [param root], or a refusal saying why there is not one.
46
+ ##
47
+ ## The other direction of what a node serialises as, and here for that reason: a node crosses the
48
+ ## wire as its path, so a path crossing back is a node or it is nothing. One answer for every op,
49
+ ## because each of them read a path for itself and they could disagree about what one means.
50
+ ##
51
+ ## A colon is the disagreement worth its own sentence. Godot reads everything after the first one
52
+ ## as subnames and [method Node.get_node_or_null] drops them, so "/root/Main:_game:run" was quietly
53
+ ## answered about "/root/Main", and an op that then said the path had no method `advance` was right
54
+ ## about an object nobody had asked for. Colons reach through what a node holds, and the arguments
55
+ ## that take them are the property and the method.
56
+ static func node_at(root: Node, path: String) -> Dictionary:
57
+ if path.is_empty():
58
+ return {"type": "error", "message": "Node path required"}
59
+
60
+ if path.contains(":"):
61
+ var shape: String = (
62
+ '"%s" names a node and colons reach past one: "%s" is the node,'
63
+ + ' and "%s" goes in the property or the method'
64
+ )
65
+ return {
66
+ "type": "error",
67
+ "message": shape % [path, path.get_slice(":", 0), path.substr(path.find(":") + 1)],
68
+ }
69
+
70
+ var node: Node = root.get_node_or_null(path)
71
+ if node == null:
72
+ return {"type": "error", "message": "Node not found: " + path}
73
+ return {"node": node}
74
+
75
+
45
76
  func _serialize_nil(_value: Variant) -> Variant:
46
77
  return null
47
78
 
@@ -53,9 +53,10 @@ func wait_signal(params: Dictionary) -> Dictionary:
53
53
  if node_path.is_empty() or signal_name.is_empty():
54
54
  return {"type": "error", "message": "Node path and signal name required"}
55
55
 
56
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
57
- if node == null:
58
- return {"type": "error", "message": "Node not found: " + node_path}
56
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
57
+ if standing.has("message"):
58
+ return standing
59
+ var node: Node = standing["node"]
59
60
  if not node.has_signal(signal_name):
60
61
  return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
61
62
 
@@ -90,8 +91,9 @@ func wait_until(params: Dictionary) -> Dictionary:
90
91
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
91
92
  if node_path.is_empty():
92
93
  return {"type": "error", "message": "Node path required"}
93
- if _host.get_tree().root.get_node_or_null(node_path) == null:
94
- return {"type": "error", "message": "Node not found: " + node_path}
94
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
95
+ if standing.has("message"):
96
+ return standing
95
97
  if not says.is_empty():
96
98
  return await _wait_until_said(node_path, says, timeout_ms)
97
99
  if property.is_empty():
@@ -99,7 +101,7 @@ func wait_until(params: Dictionary) -> Dictionary:
99
101
  if not params.has("value"):
100
102
  return {"type": "error", "message": "A value to wait for is required"}
101
103
 
102
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
104
+ var node: Node = standing["node"]
103
105
 
104
106
  var current: Variant = node.get(property)
105
107
  var wanted: Variant = _values.fitted(params["value"], typeof(current))