gdharness 0.8.0 → 0.9.1

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 CHANGED
@@ -15066,7 +15066,7 @@ function buildToolDefinitions() {
15066
15066
  };
15067
15067
  }
15068
15068
  for (const [name, schema] of Object.entries(spec.parameters)) {
15069
- const { ops: _ops, ...carried } = schema;
15069
+ const { ops: _ops, blank: _blank, ...carried } = schema;
15070
15070
  properties[name] = carried;
15071
15071
  }
15072
15072
  const required = [...spec.requires];
@@ -15258,7 +15258,8 @@ var init_tool_definitions = __esm(() => {
15258
15258
  projectPath: PROJECT_PATH,
15259
15259
  setting: { type: "string", description: 'Setting path, such as "display/window/size/viewport_width".' },
15260
15260
  value: {
15261
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}.'
15261
+ blank: true,
15262
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
15262
15263
  },
15263
15264
  name: { type: "string", description: "Autoload name." },
15264
15265
  path: { type: "string", description: "Autoload script or scene inside the project." },
@@ -15763,7 +15764,7 @@ var init_tool_definitions = __esm(() => {
15763
15764
  property: {
15764
15765
  type: "string",
15765
15766
  ops: ["property", "find"],
15766
- description: "property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label."
15767
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
15767
15768
  },
15768
15769
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15769
15770
  includeProperties: {
@@ -15780,7 +15781,7 @@ var init_tool_definitions = __esm(() => {
15780
15781
  namePattern: {
15781
15782
  type: "string",
15782
15783
  ops: ["find"],
15783
- description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
15784
+ description: 'find: a case-insensitive glob on the node name, such as "Enemy*". Matched against the whole name, so a bare word finds only a node called exactly that; an answer of none says how many names contain it and what glob would have found them.'
15784
15785
  },
15785
15786
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
15786
15787
  says: {
@@ -15821,7 +15822,7 @@ var init_tool_definitions = __esm(() => {
15821
15822
  requires: ["nodePath"]
15822
15823
  },
15823
15824
  property: {
15824
- summary: "what one property reads on a node, refusing a property the node does not have",
15825
+ summary: "what one property reads on a node, or through the objects it holds, refusing a name nothing along the way has",
15825
15826
  requires: ["nodePath", "property"]
15826
15827
  },
15827
15828
  metrics: { summary: "frame time, memory, draw calls and the rest", requires: [] }
@@ -15834,8 +15835,16 @@ var init_tool_definitions = __esm(() => {
15834
15835
  parameters: {
15835
15836
  projectPath: RUNNING_PROJECT_PATH,
15836
15837
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
15837
- property: { type: "string", ops: ["set"] },
15838
- value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
15838
+ property: {
15839
+ type: "string",
15840
+ ops: ["set"],
15841
+ description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15842
+ },
15843
+ value: {
15844
+ ops: ["set"],
15845
+ blank: true,
15846
+ description: `set: the value, fitted to the property's type. "" writes an empty string.`
15847
+ },
15839
15848
  method: { type: "string", ops: ["call"] },
15840
15849
  args: {
15841
15850
  type: "array",
@@ -15845,7 +15854,10 @@ var init_tool_definitions = __esm(() => {
15845
15854
  },
15846
15855
  requires: ["nodePath"],
15847
15856
  operations: {
15848
- set: { summary: "set a property", requires: ["property", "value"] },
15857
+ set: {
15858
+ summary: "set a property, on a node or on an object it holds",
15859
+ requires: ["property", "value"]
15860
+ },
15849
15861
  call: { summary: "call a method and return its result", requires: ["method"] }
15850
15862
  }
15851
15863
  },
@@ -15898,7 +15910,8 @@ var init_tool_definitions = __esm(() => {
15898
15910
  text: {
15899
15911
  type: "string",
15900
15912
  ops: ["text", "choose"],
15901
- description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15913
+ blank: true,
15914
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
15902
15915
  },
15903
15916
  replace: {
15904
15917
  type: "boolean",
@@ -15966,7 +15979,7 @@ var init_tool_definitions = __esm(() => {
15966
15979
  },
15967
15980
  {
15968
15981
  name: "runtime_wait",
15969
- description: "Lets the running game get on with it and answers when something has happened: a number of frames, a signal, or a property reaching a value. Needs the game running with the runtime addon.",
15982
+ description: "Lets the running game get on with it and answers when something has happened: a number of frames, a signal, a property reaching a value, or words appearing on a screen. Needs the game running with the runtime addon.",
15970
15983
  parameters: {
15971
15984
  projectPath: RUNNING_PROJECT_PATH,
15972
15985
  frames: {
@@ -15978,6 +15991,11 @@ var init_tool_definitions = __esm(() => {
15978
15991
  signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
15979
15992
  property: { type: "string", ops: ["until"], description: "until: the property name." },
15980
15993
  value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
15994
+ says: {
15995
+ type: "string",
15996
+ ops: ["until"],
15997
+ description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, hidden nodes included."
15998
+ },
15981
15999
  timeoutMs: {
15982
16000
  type: "number",
15983
16001
  ops: ["signal", "until"],
@@ -15992,8 +16010,8 @@ var init_tool_definitions = __esm(() => {
15992
16010
  requires: ["nodePath", "signal"]
15993
16011
  },
15994
16012
  until: {
15995
- summary: "wait for a property to read as a value and answer with what it read",
15996
- requires: ["nodePath", "property", "value"]
16013
+ summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
16014
+ requires: ["nodePath"]
15997
16015
  }
15998
16016
  }
15999
16017
  },
@@ -36355,7 +36373,11 @@ class GodotServer {
36355
36373
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
36356
36374
  const missing = required.filter((field) => {
36357
36375
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
36358
- return value === undefined || value === null || typeof value === "string" && value.trim() === "";
36376
+ if (value === undefined || value === null) {
36377
+ return true;
36378
+ }
36379
+ const blank = typeof value === "string" && value.trim() === "";
36380
+ return blank && spec.parameters[field]?.blank !== true;
36359
36381
  });
36360
36382
  if (missing.length > 0) {
36361
36383
  const where = op !== null ? `${spec.name} ${op}` : spec.name;
@@ -37542,6 +37564,7 @@ class GodotServer {
37542
37564
  path: nodePath,
37543
37565
  property: readString(args, "property") ?? "",
37544
37566
  value: args["value"],
37567
+ ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
37545
37568
  timeout_ms: timeoutMs
37546
37569
  }, patience);
37547
37570
  }
@@ -38920,6 +38943,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38920
38943
  | Find a control by the word on it | \`runtime_inspect find\` with \`says\`, rather than listing a screen and reading each one |
38921
38944
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38922
38945
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
38946
+ | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
38923
38947
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38924
38948
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
38925
38949
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
@@ -38927,7 +38951,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38927
38951
  | Answer a dialog | \`runtime_input click\` on its button, or \`key\` Escape to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
38928
38952
  | Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
38929
38953
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
38930
- | Wait for something | \`runtime_wait\`, never a sleep |
38954
+ | Wait for something | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels |
38931
38955
  | A picture, for a person who asked to see one | \`runtime_capture\` |
38932
38956
 
38933
38957
  These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
@@ -162,8 +162,9 @@ func inject_key(params: Dictionary) -> Dictionary:
162
162
  ## back now rather than echoed from the request, so a call that typed somewhere unhelpful says so.
163
163
  func inject_text(params: Dictionary) -> Dictionary:
164
164
  var text: String = String(params.get("text", ""))
165
- if text.is_empty():
166
- return {"type": "error", "message": "text required"}
165
+ var over: bool = bool(params.get("replace", false))
166
+ if text.is_empty() and not over:
167
+ return {"type": "error", "message": "text needs something to type, or replace to empty a field"}
167
168
 
168
169
  var viewport: Viewport = _host.get_tree().root
169
170
  var focused: Control = viewport.gui_get_focus_owner()
@@ -171,14 +172,13 @@ func inject_text(params: Dictionary) -> Dictionary:
171
172
  if not shut.is_empty():
172
173
  return {"type": "error", "message": shut}
173
174
 
174
- var replaced: bool = bool(params.get("replace", false)) and _select_everything(focused)
175
+ var replaced: bool = over and _select_everything(focused)
176
+ # Emptying a field is a real thing to ask for and the one shape of filling one in that types no
177
+ # characters. The selection is standing, so the key a player presses over one is what clears it.
178
+ if replaced and text.is_empty():
179
+ _press(viewport, _held_down(KEY_DELETE))
175
180
  for index: int in text.length():
176
- var down: InputEventKey = _typed(text.unicode_at(index))
177
- viewport.push_input(down)
178
- # The release as well, so nothing is left held down behind the caller.
179
- var up: InputEventKey = down.duplicate()
180
- up.pressed = false
181
- viewport.push_input(up)
181
+ _press(viewport, _typed(text.unicode_at(index)))
182
182
 
183
183
  # Where it went, which is the one thing a caller cannot see from here. Null is a game reading
184
184
  # keys for itself with nothing focused, which is a real thing to be typing at.
@@ -253,6 +253,24 @@ static func _shut_to_typing(focused: Control) -> String:
253
253
 
254
254
 
255
255
  ## The key press that produces [param glyph], as a keyboard would send it.
256
+ ## A whole press: down, then the release, so nothing is left held down behind the caller.
257
+ static func _press(viewport: Viewport, down: InputEventKey) -> void:
258
+ viewport.push_input(down)
259
+ var up: InputEventKey = down.duplicate()
260
+ up.pressed = false
261
+ viewport.push_input(up)
262
+
263
+
264
+ ## A key held down by its keycode, for the ones that stand for an edit rather than a character.
265
+ static func _held_down(code: Key) -> InputEventKey:
266
+ var event: InputEventKey = InputEventKey.new()
267
+ event.pressed = true
268
+ event.keycode = code
269
+ event.physical_keycode = code
270
+ event.key_label = code
271
+ return event
272
+
273
+
256
274
  func _typed(glyph: int) -> InputEventKey:
257
275
  var event: InputEventKey = InputEventKey.new()
258
276
  event.pressed = true
@@ -71,13 +71,20 @@ func find_nodes(params: Dictionary) -> Dictionary:
71
71
  var found: Array[Dictionary] = []
72
72
  var pending: Array[Node] = [root]
73
73
  var truncated: bool = false
74
+ # Counted while the tree is already being walked, for the answer below: how many nodes this
75
+ # find would have matched if the name had been read the way it was probably meant.
76
+ var literal: bool = _is_literal(wanted["name"])
77
+ var nearly: int = 0
74
78
  while not pending.is_empty():
75
79
  var node: Node = pending.pop_front()
76
- if _matches(node, wanted):
80
+ var rest: bool = _matches_apart_from_name(node, wanted)
81
+ if rest and _named(node, wanted["name"]):
77
82
  if found.size() >= limit:
78
83
  truncated = true
79
84
  break
80
85
  found.append(_found(node, wanted_property))
86
+ elif rest and literal and str(node.name).containsn(wanted["name"]):
87
+ nearly += 1
81
88
  # Internal children included, which they were not. A ConfirmationDialog builds its Yes and
82
89
  # its No as internal nodes, and a ScrollContainer its bars, so a find over a screen for
83
90
  # every Button came back without the two buttons the player is being asked to press:
@@ -88,7 +95,17 @@ func find_nodes(params: Dictionary) -> Dictionary:
88
95
  for index: int in range(children.size() - 1, -1, -1):
89
96
  pending.push_front(children[index])
90
97
 
91
- return {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
98
+ var answer: Dictionary = {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
99
+ # Nothing found is the one answer that cannot be told apart from having asked the wrong
100
+ # question, and a name written without a wildcard is the way an agent writes "contains".
101
+ # Said only when it changes the answer, so a genuine nothing stays a plain nothing.
102
+ if found.is_empty() and nearly > 0:
103
+ var holding: String = "names contain" if nearly > 1 else "name contains"
104
+ answer["note"] = (
105
+ 'name is matched as a glob against the whole name; %d node %s "%s", which "*%s*" would find'
106
+ % [nearly, holding, wanted["name"], wanted["name"]]
107
+ )
108
+ return answer
92
109
 
93
110
 
94
111
  ## One match, with the named property on it when one was named.
@@ -102,15 +119,20 @@ func _found(node: Node, wanted_property: String) -> Dictionary:
102
119
  var entry: Dictionary = _serialize_node(node, false)
103
120
  if wanted_property.is_empty():
104
121
  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
122
  entry["property"] = wanted_property
123
+ # Through a path as well, for the reason [method _reached] gives. A step that is not there on
124
+ # this node reads as not having the property rather than as a refusal, which is the rule this
125
+ # whole function is written to: a find matches nodes of several classes on purpose.
126
+ var reached: Dictionary = _reached(node, str(entry["path"]), wanted_property)
127
+ if reached.has("message"):
128
+ entry["has_property"] = false
129
+ return entry
130
+ var holder: Object = reached["holder"]
131
+ var named: String = reached["property"]
132
+ var has: bool = _has_property(holder, named)
111
133
  entry["has_property"] = has
112
134
  if has:
113
- entry["value"] = _values.serialize(node.get(wanted_property))
135
+ entry["value"] = _values.serialize(holder.get(named))
114
136
  return entry
115
137
 
116
138
 
@@ -123,15 +145,28 @@ static func _anything_asked(wanted: Dictionary[String, String]) -> bool:
123
145
  return false
124
146
 
125
147
 
126
- func _matches(node: Node, wanted: Dictionary[String, String]) -> bool:
148
+ ## Whether [param node] carries the name a find asked for. Empty matches everything, which is what
149
+ ## makes leaving a filter out the same as not having one.
150
+ static func _named(node: Node, pattern: String) -> bool:
151
+ return pattern.is_empty() or str(node.name).matchn(pattern)
152
+
153
+
154
+ ## Whether [param pattern] is a name written as a whole word rather than as a glob, which is how a
155
+ ## caller writes one when they mean "contains". [method String.matchn] answers nothing to it, and
156
+ ## nothing is also what a name that is simply not there answers.
157
+ static func _is_literal(pattern: String) -> bool:
158
+ return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
159
+
160
+
161
+ ## Every filter but the name, so a find that came back empty can say how many nodes the name was
162
+ ## the only thing standing between it and.
163
+ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
127
164
  if not wanted["group"].is_empty() and not node.is_in_group(wanted["group"]):
128
165
  return false
129
- if not wanted["name"].is_empty() and not str(node.name).matchn(wanted["name"]):
130
- return false
131
166
  # What this node says, rather than everything said underneath it. A row is then found by the
132
167
  # label in it, and the path answered is that label's, which is where the words a caller is
133
168
  # looking at actually are: matching every container above it would answer with the screen.
134
- if not wanted["says"].is_empty() and not _said_by(node).containsn(wanted["says"]):
169
+ if not wanted["says"].is_empty() and not said_by(node).containsn(wanted["says"]):
135
170
  return false
136
171
  var script: Variant = node.get_script()
137
172
  if not wanted["script"].is_empty():
@@ -206,7 +241,7 @@ func _read_into(node: Node, include_hidden: bool, most: int, into: PackedStringA
206
241
  return
207
242
  if not include_hidden and not _drawn(node):
208
243
  return
209
- var said: String = _said_by(node)
244
+ var said: String = said_by(node)
210
245
  if not said.is_empty():
211
246
  into.append(said)
212
247
  for child: Node in node.get_children(true):
@@ -224,7 +259,11 @@ static func _drawn(node: Node) -> bool:
224
259
 
225
260
  ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
226
261
  ## is every label, button and field the interface is built out of.
227
- static func _said_by(node: Node) -> String:
262
+ ##
263
+ ## Public because three questions are the same question: what a screen reads as, which nodes say a
264
+ ## given word, and whether anything has come to say it yet. Two copies of what a node says is how
265
+ ## the three of them come to disagree about a SpinBox.
266
+ static func said_by(node: Node) -> String:
228
267
  for property: Dictionary in node.get_property_list():
229
268
  if str(property.get("name", "")) == "text" and int(property.get("type", 0)) == TYPE_STRING:
230
269
  return str(node.get("text")).strip_edges()
@@ -391,24 +430,63 @@ func get_property(params: Dictionary) -> Dictionary:
391
430
  if node == null:
392
431
  return {"type": "error", "message": "Node not found: " + node_path}
393
432
 
433
+ var reached: Dictionary = _reached(node, node_path, property)
434
+ if reached.has("message"):
435
+ return reached
436
+
437
+ var holder: Object = reached["holder"]
438
+ var named: String = reached["property"]
394
439
  # Asked of the property list rather than read and compared to null, because a property the
395
- # node does not have and a property that is null both read as null.
396
- var known: bool = false
397
- for entry: Dictionary in node.get_property_list():
398
- if str(entry["name"]) == property:
399
- known = true
400
- break
401
- if not known:
402
- return {"type": "error", "message": "%s has no property %s" % [node_path, property]}
440
+ # holder does not have and a property that is null both read as null.
441
+ if not _has_property(holder, named):
442
+ return {"type": "error", "message": "%s has no property %s" % [reached["called"], named]}
403
443
 
404
444
  return {
405
445
  "type": "property",
406
446
  "path": node_path,
407
447
  "property": property,
408
- "value": _values.serialize(node.get(property)),
448
+ "value": _values.serialize(holder.get(named)),
409
449
  }
410
450
 
411
451
 
452
+ ## What [param property] is a property of, which is the node itself until the name has a colon in
453
+ ## it, and the last name along that path.
454
+ ##
455
+ ## A game's state does not sit on nodes, it hangs off them: the speed of the clock is a property of
456
+ ## a [RefCounted] held by a [RefCounted] held by the root, and that is what an agent asks about.
457
+ ## Colons, because that is the separator [method Object.get_indexed] already takes. Walked a step
458
+ ## at a time rather than handed to that method, which answers null for a path that goes wrong
459
+ ## halfway along and for one that ends on null.
460
+ ##
461
+ ## Answers with a `message` instead when a step along the way is not there or holds something that
462
+ ## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
463
+ ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
464
+ func _reached(node: Node, node_path: String, property: String) -> Dictionary:
465
+ var parts: PackedStringArray = property.split(":")
466
+ var holder: Object = node
467
+ var called: String = node_path
468
+ for step: int in parts.size() - 1:
469
+ var named: String = parts[step]
470
+ if not _has_property(holder, named):
471
+ return {"type": "error", "message": "%s has no property %s" % [called, named]}
472
+ var next: Variant = holder.get(named)
473
+ called = "%s:%s" % [called, named]
474
+ if not next is Object:
475
+ var wanted: String = parts[step + 1]
476
+ return {"type": "error", "message": "%s holds no object to read %s off" % [called, wanted]}
477
+ holder = next
478
+ return {"holder": holder, "property": parts[parts.size() - 1], "called": called}
479
+
480
+
481
+ ## Whether [param holder] declares [param named]. Its own function because three ops ask it and a
482
+ ## property read back as null answers it wrongly.
483
+ static func _has_property(holder: Object, named: String) -> bool:
484
+ for entry: Dictionary in holder.get_property_list():
485
+ if str(entry["name"]) == named:
486
+ return true
487
+ return false
488
+
489
+
412
490
  func set_property(params: Dictionary) -> Dictionary:
413
491
  var node_path: String = str(params.get("path", ""))
414
492
  var property: String = str(params.get("property", ""))
@@ -421,15 +499,24 @@ func set_property(params: Dictionary) -> Dictionary:
421
499
  if node == null:
422
500
  return {"type": "error", "message": "Node not found: " + node_path}
423
501
 
424
- var old_value: Variant = node.get(property)
425
- node.set(property, _values.fitted(value, typeof(old_value)))
502
+ # Through a path as well, for the reason [method _reached] gives, and read back off the same
503
+ # holder afterwards: a set that does not take says so by answering with the old value, which is
504
+ # how a typed container refusing a write is told from one accepting it.
505
+ var reached: Dictionary = _reached(node, node_path, property)
506
+ if reached.has("message"):
507
+ return reached
508
+
509
+ var holder: Object = reached["holder"]
510
+ var named: String = reached["property"]
511
+ var old_value: Variant = holder.get(named)
512
+ holder.set(named, _values.fitted(value, typeof(old_value)))
426
513
 
427
514
  return {
428
515
  "type": "property_set",
429
516
  "path": node_path,
430
517
  "property": property,
431
518
  "old_value": _values.serialize(old_value),
432
- "new_value": _values.serialize(node.get(property))
519
+ "new_value": _values.serialize(holder.get(named))
433
520
  }
434
521
 
435
522
 
@@ -3,6 +3,7 @@ extends RefCounted
3
3
  ## The commands that take time: they let the game run and answer when what was waited for has
4
4
  ## happened, or when the time ran out, so the caller never sleeps for a guessed length.
5
5
 
6
+ const Queries = preload("runtime_queries.gd")
6
7
  const Values = preload("runtime_values.gd")
7
8
 
8
9
  ## The longest one wait may last, whatever the request says: past this the server has long
@@ -78,22 +79,27 @@ func wait_signal(params: Dictionary) -> Dictionary:
78
79
  }
79
80
 
80
81
 
81
- ## Waits until a property reads as the value given, or the time runs out, and answers with
82
- ## what it read last either way.
82
+ ## Waits until a property reads as the value given, or until something under the path says what
83
+ ## was given, or the time runs out, and answers with what it read last either way.
83
84
  func wait_until(params: Dictionary) -> Dictionary:
84
85
  var node_path: String = str(params.get("path", ""))
85
86
  var property: String = str(params.get("property", ""))
87
+ var says: String = str(params.get("says", ""))
86
88
  var timeout_ms: int = int(params.get("timeout_ms", 5000))
87
89
  if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
88
90
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
89
- if node_path.is_empty() or property.is_empty():
90
- return {"type": "error", "message": "Node path and property required"}
91
+ if node_path.is_empty():
92
+ 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}
95
+ if not says.is_empty():
96
+ return await _wait_until_said(node_path, says, timeout_ms)
97
+ if property.is_empty():
98
+ return {"type": "error", "message": "A property and a value, or says, are required"}
91
99
  if not params.has("value"):
92
100
  return {"type": "error", "message": "A value to wait for is required"}
93
101
 
94
102
  var node: Node = _host.get_tree().root.get_node_or_null(node_path)
95
- if node == null:
96
- return {"type": "error", "message": "Node not found: " + node_path}
97
103
 
98
104
  var current: Variant = node.get(property)
99
105
  var wanted: Variant = _values.fitted(params["value"], typeof(current))
@@ -114,6 +120,45 @@ func wait_until(params: Dictionary) -> Dictionary:
114
120
  }
115
121
 
116
122
 
123
+ ## Waits until something under [param node_path] has [param said] written on it.
124
+ ##
125
+ ## A screen rather than one node, because a panel following a clock builds its labels again every
126
+ ## time it redraws and the engine names those `@Label@1163`. A wait holding one of them is waiting
127
+ ## on a node that was freed a frame later, and that is what it answered: the date along the top of a
128
+ ## hall could not be waited on at all. What a caller is watching for is a word arriving on a screen,
129
+ ## and the screen is the part that stays put.
130
+ func _wait_until_said(node_path: String, said: String, timeout_ms: int) -> Dictionary:
131
+ var started: int = Time.get_ticks_msec()
132
+ var found: bool = _anything_says(node_path, said)
133
+ while not found and Time.get_ticks_msec() - started < timeout_ms:
134
+ await _host.get_tree().process_frame
135
+ found = _anything_says(node_path, said)
136
+
137
+ return {
138
+ "type": "condition",
139
+ "path": node_path,
140
+ "says": said,
141
+ "met": found,
142
+ "elapsed_ms": Time.get_ticks_msec() - started,
143
+ }
144
+
145
+
146
+ ## Whether anything under [param node_path] says [param said], the node itself included. Hidden
147
+ ## nodes count, for the reason a find answers off them: a caller may be waiting for a dialog that
148
+ ## is built before it is shown.
149
+ func _anything_says(node_path: String, said: String) -> bool:
150
+ var root: Node = _host.get_tree().root.get_node_or_null(node_path)
151
+ if root == null:
152
+ return false
153
+ var pending: Array[Node] = [root]
154
+ while not pending.is_empty():
155
+ var node: Node = pending.pop_back()
156
+ if Queries.said_by(node).containsn(said):
157
+ return true
158
+ pending.append_array(node.get_children(true))
159
+ return false
160
+
161
+
117
162
  func _signal_arity(node: Node, signal_name: String) -> int:
118
163
  for entry: Dictionary in node.get_signal_list():
119
164
  if entry.get("name", "") == signal_name:
package/build/index.js CHANGED
@@ -27811,7 +27811,8 @@ var TOOL_SPECS = [
27811
27811
  projectPath: PROJECT_PATH,
27812
27812
  setting: { type: "string", description: 'Setting path, such as "display/window/size/viewport_width".' },
27813
27813
  value: {
27814
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}.'
27814
+ blank: true,
27815
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
27815
27816
  },
27816
27817
  name: { type: "string", description: "Autoload name." },
27817
27818
  path: { type: "string", description: "Autoload script or scene inside the project." },
@@ -28316,7 +28317,7 @@ var TOOL_SPECS = [
28316
28317
  property: {
28317
28318
  type: "string",
28318
28319
  ops: ["property", "find"],
28319
- description: "property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label."
28320
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
28320
28321
  },
28321
28322
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
28322
28323
  includeProperties: {
@@ -28333,7 +28334,7 @@ var TOOL_SPECS = [
28333
28334
  namePattern: {
28334
28335
  type: "string",
28335
28336
  ops: ["find"],
28336
- description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
28337
+ description: 'find: a case-insensitive glob on the node name, such as "Enemy*". Matched against the whole name, so a bare word finds only a node called exactly that; an answer of none says how many names contain it and what glob would have found them.'
28337
28338
  },
28338
28339
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
28339
28340
  says: {
@@ -28374,7 +28375,7 @@ var TOOL_SPECS = [
28374
28375
  requires: ["nodePath"]
28375
28376
  },
28376
28377
  property: {
28377
- summary: "what one property reads on a node, refusing a property the node does not have",
28378
+ summary: "what one property reads on a node, or through the objects it holds, refusing a name nothing along the way has",
28378
28379
  requires: ["nodePath", "property"]
28379
28380
  },
28380
28381
  metrics: { summary: "frame time, memory, draw calls and the rest", requires: [] }
@@ -28387,8 +28388,16 @@ var TOOL_SPECS = [
28387
28388
  parameters: {
28388
28389
  projectPath: RUNNING_PROJECT_PATH,
28389
28390
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
28390
- property: { type: "string", ops: ["set"] },
28391
- value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
28391
+ property: {
28392
+ type: "string",
28393
+ ops: ["set"],
28394
+ description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
28395
+ },
28396
+ value: {
28397
+ ops: ["set"],
28398
+ blank: true,
28399
+ description: `set: the value, fitted to the property's type. "" writes an empty string.`
28400
+ },
28392
28401
  method: { type: "string", ops: ["call"] },
28393
28402
  args: {
28394
28403
  type: "array",
@@ -28398,7 +28407,10 @@ var TOOL_SPECS = [
28398
28407
  },
28399
28408
  requires: ["nodePath"],
28400
28409
  operations: {
28401
- set: { summary: "set a property", requires: ["property", "value"] },
28410
+ set: {
28411
+ summary: "set a property, on a node or on an object it holds",
28412
+ requires: ["property", "value"]
28413
+ },
28402
28414
  call: { summary: "call a method and return its result", requires: ["method"] }
28403
28415
  }
28404
28416
  },
@@ -28451,7 +28463,8 @@ var TOOL_SPECS = [
28451
28463
  text: {
28452
28464
  type: "string",
28453
28465
  ops: ["text", "choose"],
28454
- description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
28466
+ blank: true,
28467
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
28455
28468
  },
28456
28469
  replace: {
28457
28470
  type: "boolean",
@@ -28519,7 +28532,7 @@ var TOOL_SPECS = [
28519
28532
  },
28520
28533
  {
28521
28534
  name: "runtime_wait",
28522
- description: "Lets the running game get on with it and answers when something has happened: a number of frames, a signal, or a property reaching a value. Needs the game running with the runtime addon.",
28535
+ description: "Lets the running game get on with it and answers when something has happened: a number of frames, a signal, a property reaching a value, or words appearing on a screen. Needs the game running with the runtime addon.",
28523
28536
  parameters: {
28524
28537
  projectPath: RUNNING_PROJECT_PATH,
28525
28538
  frames: {
@@ -28531,6 +28544,11 @@ var TOOL_SPECS = [
28531
28544
  signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
28532
28545
  property: { type: "string", ops: ["until"], description: "until: the property name." },
28533
28546
  value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
28547
+ says: {
28548
+ type: "string",
28549
+ ops: ["until"],
28550
+ description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, hidden nodes included."
28551
+ },
28534
28552
  timeoutMs: {
28535
28553
  type: "number",
28536
28554
  ops: ["signal", "until"],
@@ -28545,8 +28563,8 @@ var TOOL_SPECS = [
28545
28563
  requires: ["nodePath", "signal"]
28546
28564
  },
28547
28565
  until: {
28548
- summary: "wait for a property to read as a value and answer with what it read",
28549
- requires: ["nodePath", "property", "value"]
28566
+ summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
28567
+ requires: ["nodePath"]
28550
28568
  }
28551
28569
  }
28552
28570
  },
@@ -28620,7 +28638,7 @@ function buildToolDefinitions() {
28620
28638
  };
28621
28639
  }
28622
28640
  for (const [name, schema] of Object.entries(spec.parameters)) {
28623
- const { ops: _ops, ...carried } = schema;
28641
+ const { ops: _ops, blank: _blank, ...carried } = schema;
28624
28642
  properties[name] = carried;
28625
28643
  }
28626
28644
  const required = [...spec.requires];
@@ -29136,7 +29154,11 @@ class GodotServer {
29136
29154
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
29137
29155
  const missing = required.filter((field) => {
29138
29156
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
29139
- return value === undefined || value === null || typeof value === "string" && value.trim() === "";
29157
+ if (value === undefined || value === null) {
29158
+ return true;
29159
+ }
29160
+ const blank = typeof value === "string" && value.trim() === "";
29161
+ return blank && spec.parameters[field]?.blank !== true;
29140
29162
  });
29141
29163
  if (missing.length > 0) {
29142
29164
  const where = op !== null ? `${spec.name} ${op}` : spec.name;
@@ -30323,6 +30345,7 @@ class GodotServer {
30323
30345
  path: nodePath,
30324
30346
  property: readString(args, "property") ?? "",
30325
30347
  value: args["value"],
30348
+ ...readNonEmptyString(args, "says") === undefined ? {} : { says: readNonEmptyString(args, "says") },
30326
30349
  timeout_ms: timeoutMs
30327
30350
  }, patience);
30328
30351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",