gdharness 0.6.6 → 0.6.8

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
@@ -15785,7 +15785,7 @@ var init_tool_definitions = __esm(() => {
15785
15785
  operations: {
15786
15786
  tree: { summary: "the live scene tree", requires: [] },
15787
15787
  text: {
15788
- summary: "every line of text under nodePath, in the order somebody reads the screen, leaving out what is hidden and everything under it",
15788
+ summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
15789
15789
  requires: []
15790
15790
  },
15791
15791
  find: {
@@ -15893,7 +15893,7 @@ var init_tool_definitions = __esm(() => {
15893
15893
  action: { summary: "press an action, or hold it", requires: ["action"] },
15894
15894
  key: { summary: "press a key, or hold it", requires: ["keycode"] },
15895
15895
  text: {
15896
- summary: "type a string wherever the focus is, a character at a time",
15896
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in",
15897
15897
  requires: ["text"]
15898
15898
  },
15899
15899
  mouse_click: { summary: "one mouse button event at a position", requires: ["x", "y"] },
@@ -15905,14 +15905,17 @@ var init_tool_definitions = __esm(() => {
15905
15905
  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.",
15906
15906
  parameters: {
15907
15907
  projectPath: RUNNING_PROJECT_PATH,
15908
- frames: { type: "number", description: "frames: how many to let pass, 1 to 600." },
15908
+ frames: {
15909
+ type: "number",
15910
+ description: "frames: how many to let pass, 1 to 600. More than that is refused."
15911
+ },
15909
15912
  nodePath: { type: "string", description: "signal, until: the node." },
15910
15913
  signal: { type: "string", description: "signal: the signal name." },
15911
15914
  property: { type: "string", description: "until: the property name." },
15912
15915
  value: { description: "until: the value to wait for, fitted to the property's type." },
15913
15916
  timeoutMs: {
15914
15917
  type: "number",
15915
- description: "signal, until: how long to wait before answering anyway. Default 5000."
15918
+ description: "signal, until: how long to wait before answering anyway, 1 to 120000. Default 5000."
15916
15919
  }
15917
15920
  },
15918
15921
  requires: [],
@@ -38827,7 +38830,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38827
38830
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38828
38831
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
38829
38832
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38830
- | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` |
38833
+ | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\`; a submitted field has to be clicked again |
38831
38834
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
38832
38835
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
38833
38836
  | Answer a dialog, or press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
@@ -161,6 +161,11 @@ func inject_text(params: Dictionary) -> Dictionary:
161
161
  return {"type": "error", "message": "text required"}
162
162
 
163
163
  var viewport: Viewport = _host.get_tree().root
164
+ var focused: Control = viewport.gui_get_focus_owner()
165
+ var shut: String = _shut_to_typing(focused)
166
+ if not shut.is_empty():
167
+ return {"type": "error", "message": shut}
168
+
164
169
  for index: int in text.length():
165
170
  var down: InputEventKey = _typed(text.unicode_at(index))
166
171
  viewport.push_input(down)
@@ -169,7 +174,41 @@ func inject_text(params: Dictionary) -> Dictionary:
169
174
  up.pressed = false
170
175
  viewport.push_input(up)
171
176
 
172
- return {"type": "input_injected", "input_type": "text", "text": text, "characters": text.length()}
177
+ # Where it went, which is the one thing a caller cannot see from here. Null is a game reading
178
+ # keys for itself with nothing focused, which is a real thing to be typing at.
179
+ var into: Variant = null
180
+ if focused != null:
181
+ into = str(focused.get_path())
182
+
183
+ return {
184
+ "type": "input_injected",
185
+ "input_type": "text",
186
+ "text": text,
187
+ "characters": text.length(),
188
+ "into": into,
189
+ }
190
+
191
+
192
+ ## Why nothing typed would reach [param focused], or "" when it would.
193
+ ##
194
+ ## Having the focus is not the same as being edited. Since Godot 4.4 a field is focused and shut
195
+ ## until something opens it, which is what a click does and what submitting undoes: press Enter in
196
+ ## a box and it keeps the focus and drops every key that arrives afterwards. Typing into one
197
+ ## answered that the characters had gone in and put nothing anywhere, which is the shape a refusal
198
+ ## exists to prevent. Measured on a spin box in a real game: `has_focus` true, `is_editing` false,
199
+ ## three characters reported and the field unchanged.
200
+ ##
201
+ ## Only a [LineEdit] is refused: it is the one control the engine will say this about, since
202
+ ## [TextEdit] has no editing state of its own. A game reading keys for itself is typed at with
203
+ ## nothing focused at all, and that is not this tool's business to judge.
204
+ static func _shut_to_typing(focused: Control) -> String:
205
+ var field: LineEdit = focused as LineEdit
206
+ if field == null or field.is_editing():
207
+ return ""
208
+ return (
209
+ "%s has the focus and is not being edited, so nothing typed lands in it. Click it first."
210
+ % focused.get_path()
211
+ )
173
212
 
174
213
 
175
214
  ## The key press that produces [param glyph], as a keyboard would send it.
@@ -299,6 +338,24 @@ static func _centre_of(control: Control) -> Vector2:
299
338
  ## the control within the outer one, so the outer has to be asked after the inner has finished
300
339
  ## moving it. A control with no ScrollContainer over it moves nothing and answers false, which is
301
340
  ## what keeps the refusal below saying the right thing about a control that is simply off screen.
341
+ ## Whether [param centre] is somewhere a click can reach it: inside the viewport, and inside every
342
+ ## ScrollContainer between the control and the root, each of which clips what it holds.
343
+ static func _in_sight(control: Control, viewport: Viewport, centre: Vector2) -> bool:
344
+ if not viewport.get_visible_rect().has_point(centre):
345
+ return false
346
+ var walking: Node = control.get_parent()
347
+ while walking != null:
348
+ var holder: ScrollContainer = walking as ScrollContainer
349
+ # Carried into the same space the centre is in, which is the canvas rather than the
350
+ # container's own: the two are only the same while nothing above it is transformed.
351
+ if holder != null:
352
+ var seen: Rect2 = holder.get_global_transform_with_canvas() * Rect2(Vector2.ZERO, holder.size)
353
+ if not seen.has_point(centre):
354
+ return false
355
+ walking = walking.get_parent()
356
+ return true
357
+
358
+
302
359
  static func _scroll_into_view(control: Control) -> bool:
303
360
  var moved: bool = false
304
361
  var walking: Node = control.get_parent()
@@ -341,12 +398,17 @@ func click(params: Dictionary) -> Dictionary:
341
398
  var viewport: Viewport = _clicking_viewport(control)
342
399
  var centre: Vector2 = _centre_of(control)
343
400
 
344
- # A control below the fold of a ScrollContainer is not out of reach, it is one scroll away,
401
+ # A control out of sight inside a ScrollContainer is not out of reach, it is one scroll away,
345
402
  # which is what a person does without thinking about it before they click. Refusing it
346
403
  # instead sent callers to emit the button's own signal, which presses nothing, runs none of
347
404
  # the input path and reports success.
405
+ #
406
+ # Out of sight against what it is clipped to rather than against the viewport: a row scrolled
407
+ # off the top of its container is inside the viewport, behind whatever is drawn up there, so
408
+ # the click went to that instead and said so. Measured on a hall whose staff panel had been
409
+ # scrolled past: the button was at y 69 and its container started at y 166.
348
410
  var scrolled: bool = false
349
- if not viewport.get_visible_rect().has_point(centre):
411
+ if not _in_sight(control, viewport, centre):
350
412
  scrolled = _scroll_into_view(control)
351
413
  if scrolled:
352
414
  # A container moves its child on the next layout pass rather than inside the call.
@@ -174,19 +174,33 @@ func read_text(params: Dictionary) -> Dictionary:
174
174
 
175
175
  ## Walks [param node] depth first, which is the order the screen is laid out in and the order a
176
176
  ## person reads it.
177
+ ##
178
+ ## Internal children as well, because the number in a SpinBox is one: the field a player reads it
179
+ ## in is a LineEdit the engine builds inside the box and leaves out of `get_children()`, so a
180
+ ## screen full of forms answered with every label on it and none of the values in it. The hidden
181
+ ## check covers a [Window] for the same walk: a dropdown's popup is a child that is not drawn
182
+ ## until it is opened, and reading a closed menu would put every item on the screen.
177
183
  func _read_into(node: Node, include_hidden: bool, into: PackedStringArray) -> void:
178
184
  if into.size() >= READ_LIMIT:
179
185
  return
180
- var control: CanvasItem = node as CanvasItem
181
- if not include_hidden and control != null and not control.visible:
186
+ if not include_hidden and not _drawn(node):
182
187
  return
183
188
  var said: String = _said_by(node)
184
189
  if not said.is_empty():
185
190
  into.append(said)
186
- for child: Node in node.get_children():
191
+ for child: Node in node.get_children(true):
187
192
  _read_into(child, include_hidden, into)
188
193
 
189
194
 
195
+ ## Whether [param node] is on the screen at all, for the two kinds of thing that can be hidden.
196
+ static func _drawn(node: Node) -> bool:
197
+ var control: CanvasItem = node as CanvasItem
198
+ if control != null:
199
+ return control.visible
200
+ var window: Window = node as Window
201
+ return window == null or window.visible
202
+
203
+
190
204
  ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
191
205
  ## is every label, button and field the interface is built out of.
192
206
  static func _said_by(node: Node) -> String:
@@ -9,6 +9,9 @@ const Values = preload("runtime_values.gd")
9
9
  ## since given up on the reply.
10
10
  const CEILING_MSEC: int = 120000
11
11
 
12
+ ## The most frames one wait may cover, which is about half a minute of a game drawing slowly.
13
+ const MOST_FRAMES: int = 600
14
+
12
15
  var _host: Node
13
16
  var _values: Values
14
17
 
@@ -18,8 +21,21 @@ func _init(host: Node, values: Values) -> void:
18
21
  _values = values
19
22
 
20
23
 
24
+ ## Refuses [param asked] rather than bringing it inside [param least] to [param most].
25
+ ##
26
+ ## A number quietly brought inside the range is a wait that did not last as long as the caller
27
+ ## believes and a timeout that gave up sooner: asking for 900 frames and waiting 600 reads as 900
28
+ ## frames of the game having passed, and everything measured off it is out by that much.
29
+ static func _out_of_range(named: String, asked: int, least: int, most: int) -> Dictionary:
30
+ return {
31
+ "type": "error", "message": "%s is %d, and %s takes %d to %d." % [named, asked, named, least, most]
32
+ }
33
+
34
+
21
35
  func wait_frames(params: Dictionary) -> Dictionary:
22
- var frames: int = clampi(int(params.get("frames", 1)), 1, 600)
36
+ var frames: int = int(params.get("frames", 1))
37
+ if frames < 1 or frames > MOST_FRAMES:
38
+ return _out_of_range("frames", frames, 1, MOST_FRAMES)
23
39
  var started: int = Time.get_ticks_msec()
24
40
  for _frame: int in frames:
25
41
  await _host.get_tree().process_frame
@@ -30,7 +46,9 @@ func wait_frames(params: Dictionary) -> Dictionary:
30
46
  func wait_signal(params: Dictionary) -> Dictionary:
31
47
  var node_path: String = str(params.get("path", ""))
32
48
  var signal_name: String = str(params.get("signal", ""))
33
- var timeout_ms: int = clampi(int(params.get("timeout_ms", 5000)), 1, CEILING_MSEC)
49
+ var timeout_ms: int = int(params.get("timeout_ms", 5000))
50
+ if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
51
+ return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
34
52
  if node_path.is_empty() or signal_name.is_empty():
35
53
  return {"type": "error", "message": "Node path and signal name required"}
36
54
 
@@ -65,7 +83,9 @@ func wait_signal(params: Dictionary) -> Dictionary:
65
83
  func wait_until(params: Dictionary) -> Dictionary:
66
84
  var node_path: String = str(params.get("path", ""))
67
85
  var property: String = str(params.get("property", ""))
68
- var timeout_ms: int = clampi(int(params.get("timeout_ms", 5000)), 1, CEILING_MSEC)
86
+ var timeout_ms: int = int(params.get("timeout_ms", 5000))
87
+ if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
88
+ return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
69
89
  if node_path.is_empty() or property.is_empty():
70
90
  return {"type": "error", "message": "Node path and property required"}
71
91
  if not params.has("value"):
package/build/index.js CHANGED
@@ -28339,7 +28339,7 @@ var TOOL_SPECS = [
28339
28339
  operations: {
28340
28340
  tree: { summary: "the live scene tree", requires: [] },
28341
28341
  text: {
28342
- summary: "every line of text under nodePath, in the order somebody reads the screen, leaving out what is hidden and everything under it",
28342
+ summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
28343
28343
  requires: []
28344
28344
  },
28345
28345
  find: {
@@ -28447,7 +28447,7 @@ var TOOL_SPECS = [
28447
28447
  action: { summary: "press an action, or hold it", requires: ["action"] },
28448
28448
  key: { summary: "press a key, or hold it", requires: ["keycode"] },
28449
28449
  text: {
28450
- summary: "type a string wherever the focus is, a character at a time",
28450
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in",
28451
28451
  requires: ["text"]
28452
28452
  },
28453
28453
  mouse_click: { summary: "one mouse button event at a position", requires: ["x", "y"] },
@@ -28459,14 +28459,17 @@ var TOOL_SPECS = [
28459
28459
  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.",
28460
28460
  parameters: {
28461
28461
  projectPath: RUNNING_PROJECT_PATH,
28462
- frames: { type: "number", description: "frames: how many to let pass, 1 to 600." },
28462
+ frames: {
28463
+ type: "number",
28464
+ description: "frames: how many to let pass, 1 to 600. More than that is refused."
28465
+ },
28463
28466
  nodePath: { type: "string", description: "signal, until: the node." },
28464
28467
  signal: { type: "string", description: "signal: the signal name." },
28465
28468
  property: { type: "string", description: "until: the property name." },
28466
28469
  value: { description: "until: the value to wait for, fitted to the property's type." },
28467
28470
  timeoutMs: {
28468
28471
  type: "number",
28469
- description: "signal, until: how long to wait before answering anyway. Default 5000."
28472
+ description: "signal, until: how long to wait before answering anyway, 1 to 120000. Default 5000."
28470
28473
  }
28471
28474
  },
28472
28475
  requires: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
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",