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
|
-
|
|
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:
|
|
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
|
|
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: {
|
|
15838
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
|
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
|
|
15996
|
-
requires: ["nodePath"
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
#
|
|
396
|
-
|
|
397
|
-
|
|
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(
|
|
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
|
-
|
|
425
|
-
|
|
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(
|
|
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
|
|
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()
|
|
90
|
-
return {"type": "error", "message": "Node path
|
|
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
|
-
|
|
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:
|
|
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
|
|
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: {
|
|
28391
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
|
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
|
|
28549
|
-
requires: ["nodePath"
|
|
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
|
-
|
|
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.
|
|
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",
|