gdharness 0.6.7 → 0.6.9

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
@@ -15034,6 +15034,13 @@ function readParams(params, key) {
15034
15034
  }
15035
15035
 
15036
15036
  // src/tool-definitions.ts
15037
+ function opTakes(spec, op, name) {
15038
+ const ops = spec.parameters[name]?.ops;
15039
+ return ops === undefined || ops.includes(op);
15040
+ }
15041
+ function argumentsOf(spec, op) {
15042
+ return Object.keys(spec.parameters).filter((name) => opTakes(spec, op, name));
15043
+ }
15037
15044
  function toolSpec(name) {
15038
15045
  return SPECS_BY_NAME[name];
15039
15046
  }
@@ -15059,7 +15066,8 @@ function buildToolDefinitions() {
15059
15066
  };
15060
15067
  }
15061
15068
  for (const [name, schema] of Object.entries(spec.parameters)) {
15062
- properties[name] = schema;
15069
+ const { ops: _ops, ...carried } = schema;
15070
+ properties[name] = carried;
15063
15071
  }
15064
15072
  const required = [...spec.requires];
15065
15073
  if (spec.operations && !spec.defaultOperation) {
@@ -15749,35 +15757,46 @@ var init_tool_definitions = __esm(() => {
15749
15757
  projectPath: RUNNING_PROJECT_PATH,
15750
15758
  nodePath: {
15751
15759
  type: "string",
15760
+ ops: ["tree", "text", "find", "rect", "property"],
15752
15761
  description: "tree, find, text: where to start, default /root. rect: the node to place. property: the node to read."
15753
15762
  },
15754
15763
  property: {
15755
15764
  type: "string",
15765
+ ops: ["property", "find"],
15756
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."
15757
15767
  },
15758
- depth: { type: "number", description: "tree: levels to descend. Default 3." },
15768
+ depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15759
15769
  includeProperties: {
15760
15770
  type: "boolean",
15771
+ ops: ["tree"],
15761
15772
  description: "tree: include each node's properties. Default false."
15762
15773
  },
15763
15774
  className: {
15764
15775
  type: "string",
15776
+ ops: ["find"],
15765
15777
  description: "find: a native class, matching its subclasses too, or a class_name."
15766
15778
  },
15767
- script: { type: "string", description: "find: the script file the node carries." },
15779
+ script: { type: "string", ops: ["find"], description: "find: the script file the node carries." },
15768
15780
  namePattern: {
15769
15781
  type: "string",
15782
+ ops: ["find"],
15770
15783
  description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
15771
15784
  },
15772
- group: { type: "string", description: "find: a group the node is in." },
15773
- limit: { type: "number", description: "find: the most nodes to answer with. Default 100." },
15785
+ group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
15786
+ limit: {
15787
+ type: "number",
15788
+ ops: ["find", "text"],
15789
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with truncated saying whether there were more."
15790
+ },
15774
15791
  includeHidden: {
15775
15792
  type: "boolean",
15793
+ ops: ["text"],
15776
15794
  description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
15777
15795
  },
15778
15796
  metrics: {
15779
15797
  type: "array",
15780
15798
  items: { type: "string" },
15799
+ ops: ["metrics"],
15781
15800
  description: "metrics: which to read. Default all."
15782
15801
  }
15783
15802
  },
@@ -15785,7 +15804,7 @@ var init_tool_definitions = __esm(() => {
15785
15804
  operations: {
15786
15805
  tree: { summary: "the live scene tree", requires: [] },
15787
15806
  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",
15807
+ 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
15808
  requires: []
15790
15809
  },
15791
15810
  find: {
@@ -15810,10 +15829,14 @@ var init_tool_definitions = __esm(() => {
15810
15829
  parameters: {
15811
15830
  projectPath: RUNNING_PROJECT_PATH,
15812
15831
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
15813
- property: { type: "string" },
15814
- value: { description: "set: the value, fitted to the property's type." },
15815
- method: { type: "string" },
15816
- args: { type: "array", description: "call: the arguments, fitted to the method's parameter types." }
15832
+ property: { type: "string", ops: ["set"] },
15833
+ value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
15834
+ method: { type: "string", ops: ["call"] },
15835
+ args: {
15836
+ type: "array",
15837
+ ops: ["call"],
15838
+ description: "call: the arguments, fitted to the method's parameter types."
15839
+ }
15817
15840
  },
15818
15841
  requires: ["nodePath"],
15819
15842
  operations: {
@@ -15828,6 +15851,7 @@ var init_tool_definitions = __esm(() => {
15828
15851
  projectPath: RUNNING_PROJECT_PATH,
15829
15852
  viewportPath: {
15830
15853
  type: "string",
15854
+ ops: ["viewport"],
15831
15855
  description: "viewport: the Viewport node. Default the root viewport."
15832
15856
  },
15833
15857
  width: { type: "number", description: "Scale the image to this width." },
@@ -15847,38 +15871,64 @@ var init_tool_definitions = __esm(() => {
15847
15871
  projectPath: RUNNING_PROJECT_PATH,
15848
15872
  nodePath: {
15849
15873
  type: "string",
15874
+ ops: ["click", "choose"],
15850
15875
  description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
15851
15876
  },
15852
- action: { type: "string", description: "action: the InputMap action name." },
15877
+ action: { type: "string", ops: ["action"], description: "action: the InputMap action name." },
15853
15878
  pressed: {
15854
15879
  type: "boolean",
15855
- description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held."
15880
+ ops: ["action", "key", "mouse_click"],
15881
+ description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held. mouse_click is one raw event, so it is down unless you say false."
15856
15882
  },
15857
- strength: { type: "number", description: "action: 0 to 1. Default 1." },
15883
+ strength: { type: "number", ops: ["action"], description: "action: 0 to 1. Default 1." },
15858
15884
  keycode: {
15859
15885
  type: ["string", "number"],
15886
+ ops: ["key"],
15860
15887
  description: 'key: the key name, such as "Space" or "A", or its Godot keycode.'
15861
15888
  },
15862
15889
  text: {
15863
15890
  type: "string",
15891
+ ops: ["text", "choose"],
15864
15892
  description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15865
15893
  },
15866
15894
  index: {
15867
15895
  type: "number",
15896
+ ops: ["choose"],
15868
15897
  description: "choose: the item to take, by where it is in the list, when text will not do."
15869
15898
  },
15870
- shift: { type: "boolean" },
15871
- ctrl: { type: "boolean" },
15872
- alt: { type: "boolean" },
15873
- x: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
15874
- y: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
15899
+ shift: { type: "boolean", ops: ["key"] },
15900
+ ctrl: { type: "boolean", ops: ["key"] },
15901
+ alt: { type: "boolean", ops: ["key"] },
15902
+ x: {
15903
+ type: "number",
15904
+ ops: ["mouse_click", "mouse_motion"],
15905
+ description: "mouse_click, mouse_motion: window pixels."
15906
+ },
15907
+ y: {
15908
+ type: "number",
15909
+ ops: ["mouse_click", "mouse_motion"],
15910
+ description: "mouse_click, mouse_motion: window pixels."
15911
+ },
15875
15912
  button: {
15876
15913
  type: ["string", "number"],
15914
+ ops: ["click", "mouse_click"],
15877
15915
  description: "click, mouse_click: left, right, middle, wheel_up or wheel_down, or a button number. Default left."
15878
15916
  },
15879
- doubleClick: { type: "boolean", description: "click, mouse_click: default false." },
15880
- relativeX: { type: "number", description: "mouse_motion: movement since the last event." },
15881
- relativeY: { type: "number", description: "mouse_motion: movement since the last event." }
15917
+ doubleClick: {
15918
+ type: "boolean",
15919
+ ops: ["click", "mouse_click"],
15920
+ description: "click, mouse_click: default false."
15921
+ },
15922
+ relativeX: {
15923
+ type: "number",
15924
+ ops: ["mouse_motion"],
15925
+ description: "mouse_motion: movement since the last event."
15926
+ },
15927
+ relativeY: {
15928
+ type: "number",
15929
+ ops: ["mouse_motion"],
15930
+ description: "mouse_motion: movement since the last event."
15931
+ }
15882
15932
  },
15883
15933
  requires: [],
15884
15934
  operations: {
@@ -15893,7 +15943,7 @@ var init_tool_definitions = __esm(() => {
15893
15943
  action: { summary: "press an action, or hold it", requires: ["action"] },
15894
15944
  key: { summary: "press a key, or hold it", requires: ["keycode"] },
15895
15945
  text: {
15896
- summary: "type a string wherever the focus is, a character at a time",
15946
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in",
15897
15947
  requires: ["text"]
15898
15948
  },
15899
15949
  mouse_click: { summary: "one mouse button event at a position", requires: ["x", "y"] },
@@ -15905,14 +15955,19 @@ var init_tool_definitions = __esm(() => {
15905
15955
  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
15956
  parameters: {
15907
15957
  projectPath: RUNNING_PROJECT_PATH,
15908
- frames: { type: "number", description: "frames: how many to let pass, 1 to 600." },
15909
- nodePath: { type: "string", description: "signal, until: the node." },
15910
- signal: { type: "string", description: "signal: the signal name." },
15911
- property: { type: "string", description: "until: the property name." },
15912
- value: { description: "until: the value to wait for, fitted to the property's type." },
15958
+ frames: {
15959
+ type: "number",
15960
+ ops: ["frames"],
15961
+ description: "frames: how many to let pass, 1 to 600. More than that is refused."
15962
+ },
15963
+ nodePath: { type: "string", ops: ["signal", "until"], description: "signal, until: the node." },
15964
+ signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
15965
+ property: { type: "string", ops: ["until"], description: "until: the property name." },
15966
+ value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
15913
15967
  timeoutMs: {
15914
15968
  type: "number",
15915
- description: "signal, until: how long to wait before answering anyway. Default 5000."
15969
+ ops: ["signal", "until"],
15970
+ description: "signal, until: how long to wait before answering anyway, 1 to 120000. Default 5000."
15916
15971
  }
15917
15972
  },
15918
15973
  requires: [],
@@ -15959,6 +16014,7 @@ var init_tool_definitions = __esm(() => {
15959
16014
  parameters: {
15960
16015
  frameId: {
15961
16016
  type: "number",
16017
+ ops: ["variables"],
15962
16018
  description: "variables: which frame, from a stack answer. Default the innermost."
15963
16019
  }
15964
16020
  },
@@ -36262,6 +36318,15 @@ class GodotServer {
36262
36318
  };
36263
36319
  }
36264
36320
  }
36321
+ if (op !== null) {
36322
+ const elsewhere = Object.keys(args).filter((key) => key !== "op" && !opTakes(spec, op, key));
36323
+ if (elsewhere.length > 0) {
36324
+ return {
36325
+ ok: false,
36326
+ response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${op} takes: ${argumentsOf(spec, op).join(", ")}.`)
36327
+ };
36328
+ }
36329
+ }
36265
36330
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
36266
36331
  const missing = required.filter((field) => {
36267
36332
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
@@ -36386,7 +36451,8 @@ class GodotServer {
36386
36451
  return await this.handleRuntimeCommand("read_text", {
36387
36452
  projectPath: args["projectPath"],
36388
36453
  root: readNonEmptyString(args, "nodePath") ?? "/root",
36389
- include_hidden: readBoolean(args, "includeHidden") ?? false
36454
+ include_hidden: readBoolean(args, "includeHidden") ?? false,
36455
+ limit: readPositiveNumber(args, "limit") ?? 500
36390
36456
  });
36391
36457
  case "rect":
36392
36458
  return await this.handleRuntimeCommand("get_rect", {
@@ -37428,31 +37494,26 @@ class GodotServer {
37428
37494
  });
37429
37495
  }
37430
37496
  async handleRuntimeWait(op, args) {
37497
+ if (op === "frames") {
37498
+ const frames = readPositiveNumber(args, "frames") ?? 1;
37499
+ const waited = patienceForFrames(frames, this.runtimeTimeoutMs());
37500
+ return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
37501
+ }
37431
37502
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
37432
37503
  const nodePath = readNonEmptyString(args, "nodePath") ?? "";
37433
37504
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
37434
- switch (op) {
37435
- case "frames": {
37436
- const frames = readPositiveNumber(args, "frames") ?? 1;
37437
- const waited = patienceForFrames(frames, patience);
37438
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
37439
- }
37440
- case "signal":
37441
- return await this.handleRuntimeCommand("wait_signal", {
37442
- projectPath: args["projectPath"],
37443
- path: nodePath,
37444
- signal: readString(args, "signal") ?? "",
37445
- timeout_ms: timeoutMs
37446
- }, patience);
37447
- default:
37448
- return await this.handleRuntimeCommand("wait_until", {
37449
- projectPath: args["projectPath"],
37450
- path: nodePath,
37451
- property: readString(args, "property") ?? "",
37452
- value: args["value"],
37453
- timeout_ms: timeoutMs
37454
- }, patience);
37455
- }
37505
+ return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
37506
+ projectPath: args["projectPath"],
37507
+ path: nodePath,
37508
+ signal: readString(args, "signal") ?? "",
37509
+ timeout_ms: timeoutMs
37510
+ }, patience) : await this.handleRuntimeCommand("wait_until", {
37511
+ projectPath: args["projectPath"],
37512
+ path: nodePath,
37513
+ property: readString(args, "property") ?? "",
37514
+ value: args["value"],
37515
+ timeout_ms: timeoutMs
37516
+ }, patience);
37456
37517
  }
37457
37518
  }
37458
37519
  async function runGodotServer() {
@@ -38763,7 +38824,9 @@ function renderToolsMarkdown() {
38763
38824
  const needs = [...tool.requires, ...spec.requires];
38764
38825
  const isDefault = tool.defaultOperation === op ? " (default)" : "";
38765
38826
  const wants = needs.length > 0 ? ` Needs: ${needs.map((name) => `\`${name}\``).join(", ")}.` : "";
38766
- lines.push(`- \`op: ${op}\`${isDefault}: ${spec.summary}.${wants}`);
38827
+ const spare = argumentsOf(tool, op).filter((name) => !needs.includes(name) && name !== "projectPath");
38828
+ const takes = spare.length > 0 ? ` Takes: ${spare.map((name) => `\`${name}\``).join(", ")}.` : "";
38829
+ lines.push(`- \`op: ${op}\`${isDefault}: ${spec.summary}.${wants}${takes}`);
38767
38830
  }
38768
38831
  lines.push("");
38769
38832
  } else if (tool.requires.length > 0) {
@@ -38827,7 +38890,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38827
38890
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38828
38891
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
38829
38892
  | 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\` |
38893
+ | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\`; a submitted field has to be clicked again |
38831
38894
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
38832
38895
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
38833
38896
  | 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.
@@ -10,8 +10,8 @@ const Values = preload("runtime_values.gd")
10
10
  const FIND_LIMIT: int = 100
11
11
  const FIND_LIMIT_CEILING: int = 1000
12
12
 
13
- ## The most lines one read answers with. A screen is a few dozen; a thousand is a tree somebody
14
- ## pointed this at by mistake.
13
+ ## The most lines one read answers with, unless asked for fewer. A screen is a few dozen; a
14
+ ## thousand is a tree somebody pointed this at by mistake.
15
15
  const READ_LIMIT: int = 500
16
16
 
17
17
  var _host: Node
@@ -153,38 +153,62 @@ func _matches(
153
153
  ## A hidden node is left out and so is everything under it, because what a player reads is what is
154
154
  ## drawn. [param include_hidden] asks for the lot instead, which is what a caller checking that
155
155
  ## something is not showing wants.
156
+ ##
157
+ ## [param limit] is the first few lines rather than all of them, which is how the top of a screen
158
+ ## is read without the hall under it: the bar along the top of a guild is eleven lines and the
159
+ ## panel it sits on is two hundred.
156
160
  func read_text(params: Dictionary) -> Dictionary:
157
161
  var root_path: String = str(params.get("root", "/root"))
158
162
  var include_hidden: bool = bool(params.get("include_hidden", false))
163
+ var limit: int = clampi(int(params.get("limit", READ_LIMIT)), 1, READ_LIMIT)
159
164
 
160
165
  var root: Node = _host.get_tree().root.get_node_or_null(root_path)
161
166
  if root == null:
162
167
  return {"type": "error", "message": "Node not found: " + root_path}
163
168
 
164
- var lines: PackedStringArray = PackedStringArray()
165
- _read_into(root, include_hidden, lines)
169
+ # One line further than asked for, so that whether anything was left behind is read off the
170
+ # walk rather than guessed at from the count: a panel of exactly as many lines as the caller
171
+ # asked for is one they have read all of, and saying otherwise sends them back for nothing.
172
+ var read: PackedStringArray = PackedStringArray()
173
+ _read_into(root, include_hidden, limit + 1, read)
174
+ var more: bool = read.size() > limit
175
+ var lines: PackedStringArray = read.slice(0, limit) if more else read
166
176
  return {
167
177
  "type": "text",
168
178
  "root": root_path,
169
179
  "lines": lines,
170
180
  "count": lines.size(),
171
- "truncated": lines.size() >= READ_LIMIT,
181
+ "truncated": more,
172
182
  }
173
183
 
174
184
 
175
185
  ## Walks [param node] depth first, which is the order the screen is laid out in and the order a
176
186
  ## person reads it.
177
- func _read_into(node: Node, include_hidden: bool, into: PackedStringArray) -> void:
178
- if into.size() >= READ_LIMIT:
187
+ ##
188
+ ## Internal children as well, because the number in a SpinBox is one: the field a player reads it
189
+ ## in is a LineEdit the engine builds inside the box and leaves out of `get_children()`, so a
190
+ ## screen full of forms answered with every label on it and none of the values in it. The hidden
191
+ ## check covers a [Window] for the same walk: a dropdown's popup is a child that is not drawn
192
+ ## until it is opened, and reading a closed menu would put every item on the screen.
193
+ func _read_into(node: Node, include_hidden: bool, most: int, into: PackedStringArray) -> void:
194
+ if into.size() >= most:
179
195
  return
180
- var control: CanvasItem = node as CanvasItem
181
- if not include_hidden and control != null and not control.visible:
196
+ if not include_hidden and not _drawn(node):
182
197
  return
183
198
  var said: String = _said_by(node)
184
199
  if not said.is_empty():
185
200
  into.append(said)
186
- for child: Node in node.get_children():
187
- _read_into(child, include_hidden, into)
201
+ for child: Node in node.get_children(true):
202
+ _read_into(child, include_hidden, most, into)
203
+
204
+
205
+ ## Whether [param node] is on the screen at all, for the two kinds of thing that can be hidden.
206
+ static func _drawn(node: Node) -> bool:
207
+ var control: CanvasItem = node as CanvasItem
208
+ if control != null:
209
+ return control.visible
210
+ var window: Window = node as Window
211
+ return window == null or window.visible
188
212
 
189
213
 
190
214
  ## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
@@ -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
@@ -27633,6 +27633,13 @@ function installedAddonVersion(projectPath) {
27633
27633
  }
27634
27634
 
27635
27635
  // src/tool-definitions.ts
27636
+ function opTakes(spec, op, name) {
27637
+ const ops = spec.parameters[name]?.ops;
27638
+ return ops === undefined || ops.includes(op);
27639
+ }
27640
+ function argumentsOf(spec, op) {
27641
+ return Object.keys(spec.parameters).filter((name) => opTakes(spec, op, name));
27642
+ }
27636
27643
  var PROJECT_PATH = {
27637
27644
  type: "string",
27638
27645
  description: "Absolute path to the project directory, the one holding project.godot."
@@ -28303,35 +28310,46 @@ var TOOL_SPECS = [
28303
28310
  projectPath: RUNNING_PROJECT_PATH,
28304
28311
  nodePath: {
28305
28312
  type: "string",
28313
+ ops: ["tree", "text", "find", "rect", "property"],
28306
28314
  description: "tree, find, text: where to start, default /root. rect: the node to place. property: the node to read."
28307
28315
  },
28308
28316
  property: {
28309
28317
  type: "string",
28318
+ ops: ["property", "find"],
28310
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."
28311
28320
  },
28312
- depth: { type: "number", description: "tree: levels to descend. Default 3." },
28321
+ depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
28313
28322
  includeProperties: {
28314
28323
  type: "boolean",
28324
+ ops: ["tree"],
28315
28325
  description: "tree: include each node's properties. Default false."
28316
28326
  },
28317
28327
  className: {
28318
28328
  type: "string",
28329
+ ops: ["find"],
28319
28330
  description: "find: a native class, matching its subclasses too, or a class_name."
28320
28331
  },
28321
- script: { type: "string", description: "find: the script file the node carries." },
28332
+ script: { type: "string", ops: ["find"], description: "find: the script file the node carries." },
28322
28333
  namePattern: {
28323
28334
  type: "string",
28335
+ ops: ["find"],
28324
28336
  description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
28325
28337
  },
28326
- group: { type: "string", description: "find: a group the node is in." },
28327
- limit: { type: "number", description: "find: the most nodes to answer with. Default 100." },
28338
+ group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
28339
+ limit: {
28340
+ type: "number",
28341
+ ops: ["find", "text"],
28342
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with truncated saying whether there were more."
28343
+ },
28328
28344
  includeHidden: {
28329
28345
  type: "boolean",
28346
+ ops: ["text"],
28330
28347
  description: "text: read hidden nodes as well, for checking that something is not showing. Default false."
28331
28348
  },
28332
28349
  metrics: {
28333
28350
  type: "array",
28334
28351
  items: { type: "string" },
28352
+ ops: ["metrics"],
28335
28353
  description: "metrics: which to read. Default all."
28336
28354
  }
28337
28355
  },
@@ -28339,7 +28357,7 @@ var TOOL_SPECS = [
28339
28357
  operations: {
28340
28358
  tree: { summary: "the live scene tree", requires: [] },
28341
28359
  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",
28360
+ 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
28361
  requires: []
28344
28362
  },
28345
28363
  find: {
@@ -28364,10 +28382,14 @@ var TOOL_SPECS = [
28364
28382
  parameters: {
28365
28383
  projectPath: RUNNING_PROJECT_PATH,
28366
28384
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
28367
- property: { type: "string" },
28368
- value: { description: "set: the value, fitted to the property's type." },
28369
- method: { type: "string" },
28370
- args: { type: "array", description: "call: the arguments, fitted to the method's parameter types." }
28385
+ property: { type: "string", ops: ["set"] },
28386
+ value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
28387
+ method: { type: "string", ops: ["call"] },
28388
+ args: {
28389
+ type: "array",
28390
+ ops: ["call"],
28391
+ description: "call: the arguments, fitted to the method's parameter types."
28392
+ }
28371
28393
  },
28372
28394
  requires: ["nodePath"],
28373
28395
  operations: {
@@ -28382,6 +28404,7 @@ var TOOL_SPECS = [
28382
28404
  projectPath: RUNNING_PROJECT_PATH,
28383
28405
  viewportPath: {
28384
28406
  type: "string",
28407
+ ops: ["viewport"],
28385
28408
  description: "viewport: the Viewport node. Default the root viewport."
28386
28409
  },
28387
28410
  width: { type: "number", description: "Scale the image to this width." },
@@ -28401,38 +28424,64 @@ var TOOL_SPECS = [
28401
28424
  projectPath: RUNNING_PROJECT_PATH,
28402
28425
  nodePath: {
28403
28426
  type: "string",
28427
+ ops: ["click", "choose"],
28404
28428
  description: "click: the Control to click, at its centre, or the 3D node to click, where it is drawn. choose: the PopupMenu, or the OptionButton or MenuButton in front of one."
28405
28429
  },
28406
- action: { type: "string", description: "action: the InputMap action name." },
28430
+ action: { type: "string", ops: ["action"], description: "action: the InputMap action name." },
28407
28431
  pressed: {
28408
28432
  type: "boolean",
28409
- description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held."
28433
+ ops: ["action", "key", "mouse_click"],
28434
+ description: "action, key: leave it out and the press is a whole one, down and up a frame apart. true holds it down, false lets go of one being held. mouse_click is one raw event, so it is down unless you say false."
28410
28435
  },
28411
- strength: { type: "number", description: "action: 0 to 1. Default 1." },
28436
+ strength: { type: "number", ops: ["action"], description: "action: 0 to 1. Default 1." },
28412
28437
  keycode: {
28413
28438
  type: ["string", "number"],
28439
+ ops: ["key"],
28414
28440
  description: 'key: the key name, such as "Space" or "A", or its Godot keycode.'
28415
28441
  },
28416
28442
  text: {
28417
28443
  type: "string",
28444
+ ops: ["text", "choose"],
28418
28445
  description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
28419
28446
  },
28420
28447
  index: {
28421
28448
  type: "number",
28449
+ ops: ["choose"],
28422
28450
  description: "choose: the item to take, by where it is in the list, when text will not do."
28423
28451
  },
28424
- shift: { type: "boolean" },
28425
- ctrl: { type: "boolean" },
28426
- alt: { type: "boolean" },
28427
- x: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
28428
- y: { type: "number", description: "mouse_click, mouse_motion: window pixels." },
28452
+ shift: { type: "boolean", ops: ["key"] },
28453
+ ctrl: { type: "boolean", ops: ["key"] },
28454
+ alt: { type: "boolean", ops: ["key"] },
28455
+ x: {
28456
+ type: "number",
28457
+ ops: ["mouse_click", "mouse_motion"],
28458
+ description: "mouse_click, mouse_motion: window pixels."
28459
+ },
28460
+ y: {
28461
+ type: "number",
28462
+ ops: ["mouse_click", "mouse_motion"],
28463
+ description: "mouse_click, mouse_motion: window pixels."
28464
+ },
28429
28465
  button: {
28430
28466
  type: ["string", "number"],
28467
+ ops: ["click", "mouse_click"],
28431
28468
  description: "click, mouse_click: left, right, middle, wheel_up or wheel_down, or a button number. Default left."
28432
28469
  },
28433
- doubleClick: { type: "boolean", description: "click, mouse_click: default false." },
28434
- relativeX: { type: "number", description: "mouse_motion: movement since the last event." },
28435
- relativeY: { type: "number", description: "mouse_motion: movement since the last event." }
28470
+ doubleClick: {
28471
+ type: "boolean",
28472
+ ops: ["click", "mouse_click"],
28473
+ description: "click, mouse_click: default false."
28474
+ },
28475
+ relativeX: {
28476
+ type: "number",
28477
+ ops: ["mouse_motion"],
28478
+ description: "mouse_motion: movement since the last event."
28479
+ },
28480
+ relativeY: {
28481
+ type: "number",
28482
+ ops: ["mouse_motion"],
28483
+ description: "mouse_motion: movement since the last event."
28484
+ }
28436
28485
  },
28437
28486
  requires: [],
28438
28487
  operations: {
@@ -28447,7 +28496,7 @@ var TOOL_SPECS = [
28447
28496
  action: { summary: "press an action, or hold it", requires: ["action"] },
28448
28497
  key: { summary: "press a key, or hold it", requires: ["keycode"] },
28449
28498
  text: {
28450
- summary: "type a string wherever the focus is, a character at a time",
28499
+ summary: "type a string into the field being edited, a character at a time, and say what it landed in",
28451
28500
  requires: ["text"]
28452
28501
  },
28453
28502
  mouse_click: { summary: "one mouse button event at a position", requires: ["x", "y"] },
@@ -28459,14 +28508,19 @@ var TOOL_SPECS = [
28459
28508
  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
28509
  parameters: {
28461
28510
  projectPath: RUNNING_PROJECT_PATH,
28462
- frames: { type: "number", description: "frames: how many to let pass, 1 to 600." },
28463
- nodePath: { type: "string", description: "signal, until: the node." },
28464
- signal: { type: "string", description: "signal: the signal name." },
28465
- property: { type: "string", description: "until: the property name." },
28466
- value: { description: "until: the value to wait for, fitted to the property's type." },
28511
+ frames: {
28512
+ type: "number",
28513
+ ops: ["frames"],
28514
+ description: "frames: how many to let pass, 1 to 600. More than that is refused."
28515
+ },
28516
+ nodePath: { type: "string", ops: ["signal", "until"], description: "signal, until: the node." },
28517
+ signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
28518
+ property: { type: "string", ops: ["until"], description: "until: the property name." },
28519
+ value: { ops: ["until"], description: "until: the value to wait for, fitted to the property's type." },
28467
28520
  timeoutMs: {
28468
28521
  type: "number",
28469
- description: "signal, until: how long to wait before answering anyway. Default 5000."
28522
+ ops: ["signal", "until"],
28523
+ description: "signal, until: how long to wait before answering anyway, 1 to 120000. Default 5000."
28470
28524
  }
28471
28525
  },
28472
28526
  requires: [],
@@ -28513,6 +28567,7 @@ var TOOL_SPECS = [
28513
28567
  parameters: {
28514
28568
  frameId: {
28515
28569
  type: "number",
28570
+ ops: ["variables"],
28516
28571
  description: "variables: which frame, from a stack answer. Default the innermost."
28517
28572
  }
28518
28573
  },
@@ -28551,7 +28606,8 @@ function buildToolDefinitions() {
28551
28606
  };
28552
28607
  }
28553
28608
  for (const [name, schema] of Object.entries(spec.parameters)) {
28554
- properties[name] = schema;
28609
+ const { ops: _ops, ...carried } = schema;
28610
+ properties[name] = carried;
28555
28611
  }
28556
28612
  const required = [...spec.requires];
28557
28613
  if (spec.operations && !spec.defaultOperation) {
@@ -29042,6 +29098,15 @@ class GodotServer {
29042
29098
  };
29043
29099
  }
29044
29100
  }
29101
+ if (op !== null) {
29102
+ const elsewhere = Object.keys(args).filter((key) => key !== "op" && !opTakes(spec, op, key));
29103
+ if (elsewhere.length > 0) {
29104
+ return {
29105
+ ok: false,
29106
+ response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${op} takes: ${argumentsOf(spec, op).join(", ")}.`)
29107
+ };
29108
+ }
29109
+ }
29045
29110
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
29046
29111
  const missing = required.filter((field) => {
29047
29112
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
@@ -29166,7 +29231,8 @@ class GodotServer {
29166
29231
  return await this.handleRuntimeCommand("read_text", {
29167
29232
  projectPath: args["projectPath"],
29168
29233
  root: readNonEmptyString(args, "nodePath") ?? "/root",
29169
- include_hidden: readBoolean(args, "includeHidden") ?? false
29234
+ include_hidden: readBoolean(args, "includeHidden") ?? false,
29235
+ limit: readPositiveNumber(args, "limit") ?? 500
29170
29236
  });
29171
29237
  case "rect":
29172
29238
  return await this.handleRuntimeCommand("get_rect", {
@@ -30208,31 +30274,26 @@ class GodotServer {
30208
30274
  });
30209
30275
  }
30210
30276
  async handleRuntimeWait(op, args) {
30277
+ if (op === "frames") {
30278
+ const frames = readPositiveNumber(args, "frames") ?? 1;
30279
+ const waited = patienceForFrames(frames, this.runtimeTimeoutMs());
30280
+ return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
30281
+ }
30211
30282
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
30212
30283
  const nodePath = readNonEmptyString(args, "nodePath") ?? "";
30213
30284
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
30214
- switch (op) {
30215
- case "frames": {
30216
- const frames = readPositiveNumber(args, "frames") ?? 1;
30217
- const waited = patienceForFrames(frames, patience);
30218
- return await this.handleRuntimeCommand("wait_frames", { projectPath: args["projectPath"], frames }, waited);
30219
- }
30220
- case "signal":
30221
- return await this.handleRuntimeCommand("wait_signal", {
30222
- projectPath: args["projectPath"],
30223
- path: nodePath,
30224
- signal: readString(args, "signal") ?? "",
30225
- timeout_ms: timeoutMs
30226
- }, patience);
30227
- default:
30228
- return await this.handleRuntimeCommand("wait_until", {
30229
- projectPath: args["projectPath"],
30230
- path: nodePath,
30231
- property: readString(args, "property") ?? "",
30232
- value: args["value"],
30233
- timeout_ms: timeoutMs
30234
- }, patience);
30235
- }
30285
+ return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
30286
+ projectPath: args["projectPath"],
30287
+ path: nodePath,
30288
+ signal: readString(args, "signal") ?? "",
30289
+ timeout_ms: timeoutMs
30290
+ }, patience) : await this.handleRuntimeCommand("wait_until", {
30291
+ projectPath: args["projectPath"],
30292
+ path: nodePath,
30293
+ property: readString(args, "property") ?? "",
30294
+ value: args["value"],
30295
+ timeout_ms: timeoutMs
30296
+ }, patience);
30236
30297
  }
30237
30298
  }
30238
30299
  async function runGodotServer() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.6.7",
3
+ "version": "0.6.9",
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",