gdharness 1.2.29 → 1.2.31
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 +80 -24
- package/build/godot/addons/gdharness_editor/property_values.gd +80 -54
- package/build/godot/addons/gdharness_editor/serialisation.gd +46 -0
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +78 -3
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +19 -4
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +139 -91
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +5 -4
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +23 -47
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +1 -13
- package/build/godot/addons/gdharness_runtime/serialisation.gd +46 -0
- package/build/godot/operations/audio_buses.gd +14 -1
- package/build/godot/operations/class_cache.gd +146 -35
- package/build/godot/operations/classdb_queries.gd +19 -12
- package/build/godot/operations/project_config.gd +16 -6
- package/build/godot/operations/serialisation.gd +46 -0
- package/build/index.js +80 -24
- package/package.json +1 -1
|
@@ -93,13 +93,13 @@ func _entry_for(path: String, skipped: Array[Dictionary]) -> Dictionary:
|
|
|
93
93
|
if declared.is_empty():
|
|
94
94
|
return {}
|
|
95
95
|
|
|
96
|
-
var base:
|
|
97
|
-
if base
|
|
96
|
+
var base: Variant = _base_of(path, header, skipped)
|
|
97
|
+
if base == null:
|
|
98
98
|
return {}
|
|
99
99
|
|
|
100
100
|
# The field order is the editor's, so a rebuilt file reads as one the editor wrote.
|
|
101
101
|
return {
|
|
102
|
-
"base": StringName(base),
|
|
102
|
+
"base": StringName(str(base)),
|
|
103
103
|
"class": StringName(declared),
|
|
104
104
|
"icon": str(header.get("icon", "")),
|
|
105
105
|
"is_abstract": Read.as_bool(header.get("abstract", false)),
|
|
@@ -109,46 +109,157 @@ func _entry_for(path: String, skipped: Array[Dictionary]) -> Dictionary:
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
# What the editor records as the base
|
|
113
|
-
#
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
# What the editor records as the base, worked out as the engine's own class scan works it out,
|
|
113
|
+
# measured on 4.7.2 against `--import`, so a rebuilt list is the one the editor would write and a
|
|
114
|
+
# rebuild over a current list changes nothing:
|
|
115
|
+
#
|
|
116
|
+
# - nothing extended: RefCounted;
|
|
117
|
+
# - one name, a global class or a native one: that name, unresolved;
|
|
118
|
+
# - a dotted name, `Heard.Named`, an inner class of a global class: nothing, as the scan does not
|
|
119
|
+
# look names up;
|
|
120
|
+
# - a path: what that script's class records, after walking into the inner classes named after
|
|
121
|
+
# the path, `"res://heard.gd".Named`, so a parent with a class_name is passed over for its own
|
|
122
|
+
# base. A path the engine cannot open records nothing, and it opens a relative one from the
|
|
123
|
+
# project root rather than from the script's folder.
|
|
124
|
+
#
|
|
125
|
+
# Null for a script that extends itself, which is left out of the list and named in [param skipped].
|
|
126
|
+
func _base_of(path: String, header: Dictionary, skipped: Array[Dictionary]) -> Variant:
|
|
127
|
+
var visited: Array[String] = [path + ":"]
|
|
128
|
+
var extends_what: Variant = header.get("extends", null)
|
|
117
129
|
while true:
|
|
118
|
-
|
|
119
|
-
if extends_what.is_empty():
|
|
130
|
+
if extends_what == null:
|
|
120
131
|
return "RefCounted"
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
var
|
|
124
|
-
if
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
var parts: Dictionary = _extends_parts(str(extends_what))
|
|
133
|
+
var parent_path: String = parts["path"]
|
|
134
|
+
var names: Array[String] = parts["names"]
|
|
135
|
+
if parent_path.is_empty():
|
|
136
|
+
return names[0] if names.size() == 1 else ""
|
|
137
|
+
var opened: String = _as_the_engine_opens(parent_path)
|
|
138
|
+
if not FileAccess.file_exists(opened):
|
|
128
139
|
return ""
|
|
129
|
-
|
|
140
|
+
var reached: String = opened + ":" + ".".join(PackedStringArray(names))
|
|
141
|
+
if reached in visited:
|
|
130
142
|
skipped.append({"path": path, "reason": "extends itself through " + parent_path})
|
|
143
|
+
return null
|
|
144
|
+
visited.append(reached)
|
|
145
|
+
var lines: PackedStringArray = FileAccess.get_file_as_string(opened).split("\n")
|
|
146
|
+
if names.is_empty():
|
|
147
|
+
extends_what = _header_in(lines).get("extends", null)
|
|
148
|
+
continue
|
|
149
|
+
var inner: Dictionary = _inner_class(lines, names)
|
|
150
|
+
if inner.is_empty():
|
|
131
151
|
return ""
|
|
132
|
-
|
|
133
|
-
current = _header_of(parent_path)
|
|
134
|
-
var parent_name: String = str(current.get("class_name", ""))
|
|
135
|
-
if not parent_name.is_empty():
|
|
136
|
-
return parent_name
|
|
152
|
+
extends_what = inner.get("extends", null)
|
|
137
153
|
return ""
|
|
138
154
|
|
|
139
155
|
|
|
140
|
-
# The
|
|
141
|
-
#
|
|
142
|
-
#
|
|
156
|
+
# The path and the class names an `extends` names, from the text after the keyword:
|
|
157
|
+
# `"res://heard.gd".Named` is the path and ["Named"], `Heard.Named` no path and two names. The
|
|
158
|
+
# reading stops at whatever follows, a colon or a comment.
|
|
159
|
+
static func _extends_parts(text: String) -> Dictionary:
|
|
160
|
+
var rest: String = text.strip_edges()
|
|
161
|
+
var path: String = ""
|
|
162
|
+
if rest.begins_with('"') or rest.begins_with("'"):
|
|
163
|
+
var closing: int = rest.find(rest[0], 1)
|
|
164
|
+
if closing == -1:
|
|
165
|
+
return {"path": "", "names": [] as Array[String]}
|
|
166
|
+
path = rest.substr(1, closing - 1)
|
|
167
|
+
rest = rest.substr(closing + 1)
|
|
168
|
+
var names: Array[String] = []
|
|
169
|
+
var name_at: RegEx = Patterns.compiled("^([A-Za-z_][A-Za-z0-9_]*)")
|
|
170
|
+
var dotted: bool = not path.is_empty()
|
|
171
|
+
while true:
|
|
172
|
+
if dotted:
|
|
173
|
+
if not rest.begins_with("."):
|
|
174
|
+
break
|
|
175
|
+
rest = rest.substr(1)
|
|
176
|
+
dotted = true
|
|
177
|
+
var found: RegExMatch = name_at.search(rest)
|
|
178
|
+
if found == null:
|
|
179
|
+
break
|
|
180
|
+
names.append(found.get_string(1))
|
|
181
|
+
rest = rest.substr(found.get_end())
|
|
182
|
+
return {"path": path, "names": names}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# The file the engine's class scan reads for an `extends` path. It opens the path as written, and
|
|
186
|
+
# the engine runs from the project root, so a relative path is read from there.
|
|
187
|
+
static func _as_the_engine_opens(path: String) -> String:
|
|
188
|
+
if path.is_absolute_path():
|
|
189
|
+
return path
|
|
190
|
+
var absolute: String = ProjectSettings.globalize_path("res://").path_join(path).simplify_path()
|
|
191
|
+
return ProjectSettings.localize_path(absolute)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# The inner class [param names] reaches in a script of [param lines], one class inside the
|
|
195
|
+
# previous, as {"found": true, "extends": the text after its keyword} with no "extends" for one that
|
|
196
|
+
# extends nothing, or {} when one of them is not there.
|
|
197
|
+
func _inner_class(lines: PackedStringArray, names: Array[String]) -> Dictionary:
|
|
198
|
+
var class_line: RegEx = Patterns.compiled("^class\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*(.*)$")
|
|
199
|
+
var inline_extends: RegEx = Patterns.compiled("^extends\\s+(.*)$")
|
|
200
|
+
var start: int = 0
|
|
201
|
+
var end: int = lines.size()
|
|
202
|
+
var depth: int = 0
|
|
203
|
+
var found: Dictionary = {}
|
|
204
|
+
for name: String in names:
|
|
205
|
+
var at: int = -1
|
|
206
|
+
found = {"found": true}
|
|
207
|
+
for index: int in range(start, end):
|
|
208
|
+
var text: String = lines[index].strip_edges()
|
|
209
|
+
if text.is_empty() or text.begins_with("#") or _indent(lines[index]) != depth:
|
|
210
|
+
continue
|
|
211
|
+
var declared: RegExMatch = class_line.search(text)
|
|
212
|
+
if declared == null or declared.get_string(1) != name:
|
|
213
|
+
continue
|
|
214
|
+
at = index
|
|
215
|
+
var inline: RegExMatch = inline_extends.search(declared.get_string(2).strip_edges())
|
|
216
|
+
if inline != null:
|
|
217
|
+
found["extends"] = inline.get_string(1)
|
|
218
|
+
break
|
|
219
|
+
if at == -1:
|
|
220
|
+
return {}
|
|
221
|
+
start = at + 1
|
|
222
|
+
var body_end: int = start
|
|
223
|
+
var body_depth: int = -1
|
|
224
|
+
for index: int in range(start, end):
|
|
225
|
+
var text: String = lines[index].strip_edges()
|
|
226
|
+
if not text.is_empty() and not text.begins_with("#"):
|
|
227
|
+
var width: int = _indent(lines[index])
|
|
228
|
+
if width <= depth:
|
|
229
|
+
break
|
|
230
|
+
if body_depth == -1:
|
|
231
|
+
body_depth = width
|
|
232
|
+
body_end = index + 1
|
|
233
|
+
end = body_end
|
|
234
|
+
depth = body_depth
|
|
235
|
+
if not found.has("extends"):
|
|
236
|
+
var body: Dictionary = _header_in(lines.slice(start, end))
|
|
237
|
+
if body.has("extends"):
|
|
238
|
+
found["extends"] = body["extends"]
|
|
239
|
+
return found
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
static func _indent(line: String) -> int:
|
|
243
|
+
return line.length() - line.lstrip(" \t").length()
|
|
244
|
+
|
|
245
|
+
|
|
143
246
|
func _header_of(path: String) -> Dictionary:
|
|
247
|
+
if not FileAccess.file_exists(path):
|
|
248
|
+
return {}
|
|
249
|
+
return _header_in(FileAccess.get_file_as_string(path).split("\n"))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# The declarations at the top of a script: class_name, what it extends (the text after the
|
|
253
|
+
# keyword, a class name or a quoted path, either followed by inner class names), and the @tool,
|
|
254
|
+
# @abstract and @icon annotations. Reading stops at the first statement that is none of those,
|
|
255
|
+
# which is where the body begins.
|
|
256
|
+
func _header_in(lines: PackedStringArray) -> Dictionary:
|
|
144
257
|
var header: Dictionary = {}
|
|
145
|
-
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
|
146
|
-
if not file:
|
|
147
|
-
return header
|
|
148
258
|
var annotation: RegEx = Patterns.compiled('^@([a-z_]+)(?:\\(\\s*(?:"([^"]*)")?[^)]*\\))?\\s*')
|
|
149
|
-
var extends_line: RegEx = Patterns.compiled("^extends\\s+(
|
|
150
|
-
|
|
151
|
-
|
|
259
|
+
var extends_line: RegEx = Patterns.compiled("^extends\\s+(.*)$")
|
|
260
|
+
var same_line: RegEx = Patterns.compiled("^\\s+extends\\s+(.*)$")
|
|
261
|
+
for line: String in lines:
|
|
262
|
+
var rest: String = line.strip_edges()
|
|
152
263
|
# Annotations may share a line with what they annotate, as in `@abstract class_name X`.
|
|
153
264
|
var annotated: RegExMatch = annotation.search(rest)
|
|
154
265
|
while annotated != null:
|
|
@@ -167,15 +278,15 @@ func _header_of(path: String) -> Dictionary:
|
|
|
167
278
|
var declared: RegExMatch = Patterns.declared_class(rest)
|
|
168
279
|
if declared != null:
|
|
169
280
|
header["class_name"] = declared.get_string(1)
|
|
170
|
-
|
|
171
|
-
|
|
281
|
+
var after: RegExMatch = same_line.search(rest.substr(declared.get_end(1)))
|
|
282
|
+
if after != null:
|
|
283
|
+
header["extends"] = after.get_string(1)
|
|
172
284
|
elif rest.begins_with("extends"):
|
|
173
285
|
var parent: RegExMatch = extends_line.search(rest)
|
|
174
286
|
if parent != null:
|
|
175
287
|
header["extends"] = parent.get_string(1)
|
|
176
288
|
else:
|
|
177
289
|
break
|
|
178
|
-
file.close()
|
|
179
290
|
return header
|
|
180
291
|
|
|
181
292
|
|
|
@@ -2,6 +2,7 @@ extends RefCounted
|
|
|
2
2
|
|
|
3
3
|
const Read = preload("reading.gd")
|
|
4
4
|
const Log = preload("logger.gd")
|
|
5
|
+
const Serialisation = preload("serialisation.gd")
|
|
5
6
|
|
|
6
7
|
# The base classes each category name stands for. Kept to the list the tool's schema offers, which
|
|
7
8
|
# a case holds: the schema offered physics3d and ui, which this refused, and physics answered the 3D
|
|
@@ -22,6 +23,7 @@ const CATEGORY_BASES: Dictionary = {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
var _log: Log
|
|
26
|
+
var _values: Serialisation = Serialisation.new()
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
func _init(p_log: Log) -> void:
|
|
@@ -318,19 +320,24 @@ func _methods_of(class_name_str: String, include_inherited: bool) -> Array[Dicti
|
|
|
318
320
|
}
|
|
319
321
|
)
|
|
320
322
|
var return_info: Dictionary = m.get("return", {})
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
"args": args,
|
|
325
|
-
"return":
|
|
323
|
+
(
|
|
324
|
+
methods
|
|
325
|
+
. append(
|
|
326
326
|
{
|
|
327
|
-
"
|
|
328
|
-
"
|
|
329
|
-
"
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
327
|
+
"name": m.get("name", ""),
|
|
328
|
+
"args": args,
|
|
329
|
+
"return":
|
|
330
|
+
{
|
|
331
|
+
"type": return_info.get("type", 0),
|
|
332
|
+
"type_name": _type_name(return_info, "void"),
|
|
333
|
+
"class_name": return_info.get("class_name", "")
|
|
334
|
+
},
|
|
335
|
+
"flags": m.get("flags", 0),
|
|
336
|
+
# As values, tagged the way every other answer tags one: JSON writes a Vector2i it is
|
|
337
|
+
# handed as its text, so TileMapLayer.set_cell's default atlas coords read "(-1, -1)".
|
|
338
|
+
"default_args": _values.serialize_value(m.get("default_args", []))
|
|
339
|
+
}
|
|
340
|
+
)
|
|
334
341
|
)
|
|
335
342
|
|
|
336
343
|
return methods
|
|
@@ -117,12 +117,7 @@ func set_project_setting(params: Dictionary) -> Dictionary:
|
|
|
117
117
|
var wanted: int = _wanted_type(setting_path, old_value if had_value else null)
|
|
118
118
|
if wanted > TYPE_NIL and final_value != null and typeof(final_value) != wanted:
|
|
119
119
|
if not _converts_faithfully(final_value, wanted):
|
|
120
|
-
return _log.failure(
|
|
121
|
-
(
|
|
122
|
-
"%s is declared as %s, and %s (%s) cannot be written as that without losing something."
|
|
123
|
-
% [setting_path, type_string(wanted), str(final_value), type_string(typeof(final_value))]
|
|
124
|
-
)
|
|
125
|
-
)
|
|
120
|
+
return _log.failure(_cannot_hold(setting_path, wanted, final_value))
|
|
126
121
|
final_value = type_convert(final_value, wanted)
|
|
127
122
|
|
|
128
123
|
var answer: Dictionary = {
|
|
@@ -174,6 +169,21 @@ func _converts_faithfully(value: Variant, to: int) -> bool:
|
|
|
174
169
|
return type_convert(type_convert(value, to), typeof(value)) == value
|
|
175
170
|
|
|
176
171
|
|
|
172
|
+
# Why [param value] cannot be written to a setting declared as [param wanted], in the words the
|
|
173
|
+
# editor's property writes use for the same two cases. One sentence for both read "without losing
|
|
174
|
+
# something" about a word given for a number, where nothing would be lost: it cannot become one.
|
|
175
|
+
static func _cannot_hold(setting_path: String, wanted: int, value: Variant) -> String:
|
|
176
|
+
var numbers: Array[int] = [TYPE_INT, TYPE_FLOAT]
|
|
177
|
+
var declared: String = "%s is declared as %s" % [setting_path, type_string(wanted)]
|
|
178
|
+
var cost: String = " Setting it would save a different value than the one asked for."
|
|
179
|
+
if wanted == TYPE_INT and typeof(value) == TYPE_FLOAT:
|
|
180
|
+
return "%s, which holds whole numbers, and %s would be cut to fit.%s" % [declared, str(value), cost]
|
|
181
|
+
if wanted in numbers and typeof(value) in numbers:
|
|
182
|
+
return "%s, and %s is not the same number as one.%s" % [declared, str(value), cost]
|
|
183
|
+
var given: String = type_string(typeof(value))
|
|
184
|
+
return "%s, and %s (%s) cannot become one." % [declared, JSON.stringify(value), given]
|
|
185
|
+
|
|
186
|
+
|
|
177
187
|
# Add an autoload singleton
|
|
178
188
|
func add_autoload(params: Dictionary) -> Dictionary:
|
|
179
189
|
var name: String = str(params.get("name", ""))
|
|
@@ -246,6 +246,52 @@ func deserialize_value(value: Variant) -> Variant:
|
|
|
246
246
|
return call(builder, fields)
|
|
247
247
|
|
|
248
248
|
|
|
249
|
+
## The components a path reads off [param value], by the names GDScript gives them, or none for a
|
|
250
|
+
## value that has none: a vector's axes, a colour's channels, a rectangle's corners and size, a
|
|
251
|
+
## transform's basis and origin. A value of one of these is a copy wherever it is read, so a write
|
|
252
|
+
## into a component is a write into the copy until it is put back where it was read from.
|
|
253
|
+
static func components_of(value: Variant) -> PackedStringArray:
|
|
254
|
+
match typeof(value):
|
|
255
|
+
TYPE_VECTOR2, TYPE_VECTOR2I:
|
|
256
|
+
return PackedStringArray(["x", "y"])
|
|
257
|
+
TYPE_VECTOR3, TYPE_VECTOR3I:
|
|
258
|
+
return PackedStringArray(["x", "y", "z"])
|
|
259
|
+
TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
|
|
260
|
+
return PackedStringArray(["x", "y", "z", "w"])
|
|
261
|
+
TYPE_COLOR:
|
|
262
|
+
return PackedStringArray(
|
|
263
|
+
[
|
|
264
|
+
"r",
|
|
265
|
+
"g",
|
|
266
|
+
"b",
|
|
267
|
+
"a",
|
|
268
|
+
"r8",
|
|
269
|
+
"g8",
|
|
270
|
+
"b8",
|
|
271
|
+
"a8",
|
|
272
|
+
"h",
|
|
273
|
+
"s",
|
|
274
|
+
"v",
|
|
275
|
+
"ok_hsl_h",
|
|
276
|
+
"ok_hsl_s",
|
|
277
|
+
"ok_hsl_l"
|
|
278
|
+
]
|
|
279
|
+
)
|
|
280
|
+
TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
|
|
281
|
+
return PackedStringArray(["position", "size", "end"])
|
|
282
|
+
TYPE_PLANE:
|
|
283
|
+
return PackedStringArray(["normal", "x", "y", "z", "d"])
|
|
284
|
+
TYPE_BASIS:
|
|
285
|
+
return PackedStringArray(["x", "y", "z"])
|
|
286
|
+
TYPE_TRANSFORM2D:
|
|
287
|
+
return PackedStringArray(["x", "y", "origin"])
|
|
288
|
+
TYPE_TRANSFORM3D:
|
|
289
|
+
return PackedStringArray(["basis", "origin"])
|
|
290
|
+
TYPE_PROJECTION:
|
|
291
|
+
return PackedStringArray(["x", "y", "z", "w"])
|
|
292
|
+
return PackedStringArray()
|
|
293
|
+
|
|
294
|
+
|
|
249
295
|
## Whether [param entries] is a list of [key, value] pairs, as a Dictionary tag carries them.
|
|
250
296
|
static func pairs(entries: Variant) -> bool:
|
|
251
297
|
if not entries is Array:
|
package/build/index.js
CHANGED
|
@@ -29053,6 +29053,9 @@ class GodotBridge extends EventEmitter {
|
|
|
29053
29053
|
const info = this.connectionInfo;
|
|
29054
29054
|
return this.socket?.readyState === import_websocket.default.OPEN && info !== null && (info.greeted !== true || info.identifying === true);
|
|
29055
29055
|
}
|
|
29056
|
+
requestsInFlight() {
|
|
29057
|
+
return [...this.pendingRequests.values()].filter((pending) => pending.counted).map((pending) => ({ tool: pending.toolName, startedAt: pending.startedAt })).sort((a, b) => a.startedAt - b.startedAt);
|
|
29058
|
+
}
|
|
29056
29059
|
getStatus() {
|
|
29057
29060
|
const identifying = this.connectionInfo?.identifying === true;
|
|
29058
29061
|
const editor = identifying ? null : this.connectionInfo;
|
|
@@ -29074,18 +29077,19 @@ class GodotBridge extends EventEmitter {
|
|
|
29074
29077
|
syncsBreakpoints: editor?.syncsBreakpoints,
|
|
29075
29078
|
turnedAway: this.turnedAway ?? undefined,
|
|
29076
29079
|
identifying: identifying ? true : undefined,
|
|
29077
|
-
pendingRequests: this.
|
|
29080
|
+
pendingRequests: this.requestsInFlight().length,
|
|
29078
29081
|
queuedResources: this.resourceQueues.size,
|
|
29079
29082
|
listeningSince: this.listeningSince ?? undefined,
|
|
29080
29083
|
portWanted: this.wantedPort !== 0 && this.boundPort !== null && this.boundPort !== this.wantedPort ? this.wantedPort : undefined
|
|
29081
29084
|
};
|
|
29082
29085
|
}
|
|
29083
|
-
invokeTool(toolName, args) {
|
|
29086
|
+
invokeTool(toolName, args, options = {}) {
|
|
29087
|
+
const counted = options.counted ?? true;
|
|
29084
29088
|
const resourceKey = this.getResourceKey(args);
|
|
29085
29089
|
if (!resourceKey) {
|
|
29086
|
-
return this.invokeToolDirect(toolName, args);
|
|
29090
|
+
return this.invokeToolDirect(toolName, args, counted);
|
|
29087
29091
|
}
|
|
29088
|
-
return this.enqueueResourceRequest(resourceKey, () => this.invokeToolDirect(toolName, args, resourceKey));
|
|
29092
|
+
return this.enqueueResourceRequest(resourceKey, () => this.invokeToolDirect(toolName, args, counted, resourceKey));
|
|
29089
29093
|
}
|
|
29090
29094
|
getRequestPathname(url) {
|
|
29091
29095
|
try {
|
|
@@ -29237,7 +29241,7 @@ class GodotBridge extends EventEmitter {
|
|
|
29237
29241
|
return;
|
|
29238
29242
|
}
|
|
29239
29243
|
}
|
|
29240
|
-
invokeToolDirect(toolName, args, resourceKey) {
|
|
29244
|
+
invokeToolDirect(toolName, args, counted, resourceKey) {
|
|
29241
29245
|
if (!this.isConnected()) {
|
|
29242
29246
|
return Promise.reject(new Error("Godot is not connected"));
|
|
29243
29247
|
}
|
|
@@ -29259,7 +29263,8 @@ class GodotBridge extends EventEmitter {
|
|
|
29259
29263
|
resolve,
|
|
29260
29264
|
reject,
|
|
29261
29265
|
startedAt: Date.now(),
|
|
29262
|
-
resourceKey
|
|
29266
|
+
resourceKey,
|
|
29267
|
+
counted
|
|
29263
29268
|
});
|
|
29264
29269
|
this.emitBridgeEvent("tool_start", {
|
|
29265
29270
|
tool: toolName,
|
|
@@ -31352,11 +31357,26 @@ function shownDiagnostic(entry) {
|
|
|
31352
31357
|
...code === undefined || code === null || code === 0 || code === "" ? {} : { code }
|
|
31353
31358
|
};
|
|
31354
31359
|
}
|
|
31355
|
-
function
|
|
31360
|
+
function linesIn(content) {
|
|
31361
|
+
const lines = content.split(`
|
|
31362
|
+
`);
|
|
31363
|
+
return Math.max(1, content.endsWith(`
|
|
31364
|
+
`) ? lines.length - 1 : lines.length);
|
|
31365
|
+
}
|
|
31366
|
+
function lastLineOf(whole, lastOfDocument) {
|
|
31367
|
+
const end = shownPosition(whole["end"]);
|
|
31368
|
+
if (end === null) {
|
|
31369
|
+
return null;
|
|
31370
|
+
}
|
|
31371
|
+
const start = shownPosition(whole["start"]);
|
|
31372
|
+
const exclusive = end.column === 1 && (start === null || end.line > start.line);
|
|
31373
|
+
return Math.min(exclusive ? end.line - 1 : end.line, lastOfDocument);
|
|
31374
|
+
}
|
|
31375
|
+
function shownSymbol(entry, lastOfDocument) {
|
|
31356
31376
|
const symbol = asRecord(entry);
|
|
31357
31377
|
const whole = asRecord(symbol["range"] ?? asRecord(symbol["location"])["range"]);
|
|
31358
31378
|
const at = shownPosition(asRecord(symbol["selectionRange"] ?? whole)["start"]);
|
|
31359
|
-
const last =
|
|
31379
|
+
const last = lastLineOf(whole, lastOfDocument);
|
|
31360
31380
|
const children = Array.isArray(symbol["children"]) ? symbol["children"] : [];
|
|
31361
31381
|
const { documentation, native_class: nativeClass, deprecated, detail } = symbol;
|
|
31362
31382
|
return {
|
|
@@ -31364,11 +31384,11 @@ function shownSymbol(entry) {
|
|
|
31364
31384
|
kind: named(symbol["kind"], SYMBOL_KINDS),
|
|
31365
31385
|
...typeof detail === "string" && detail !== "" ? { detail } : {},
|
|
31366
31386
|
...at === null ? {} : { line: at.line, column: at.column },
|
|
31367
|
-
...last === null ? {} : { lastLine: last
|
|
31387
|
+
...last === null ? {} : { lastLine: last },
|
|
31368
31388
|
...typeof documentation === "string" && documentation !== "" ? { documentation } : {},
|
|
31369
31389
|
...typeof nativeClass === "string" && nativeClass !== "" ? { nativeClass } : {},
|
|
31370
31390
|
...deprecated === true ? { deprecated } : {},
|
|
31371
|
-
...children.length > 0 ? { children: children.map(shownSymbol) } : {}
|
|
31391
|
+
...children.length > 0 ? { children: children.map((child) => shownSymbol(child, lastOfDocument)) } : {}
|
|
31372
31392
|
};
|
|
31373
31393
|
}
|
|
31374
31394
|
var MOST_COMPLETIONS = 100;
|
|
@@ -31473,7 +31493,7 @@ async function handleLSPTool(client, toolName, args) {
|
|
|
31473
31493
|
}
|
|
31474
31494
|
case "lsp_get_symbols": {
|
|
31475
31495
|
const symbols = await client.getDocumentSymbols(scriptPath, content);
|
|
31476
|
-
return asToolResponse({ symbols: symbols.map(shownSymbol) });
|
|
31496
|
+
return asToolResponse({ symbols: symbols.map((symbol) => shownSymbol(symbol, linesIn(content))) });
|
|
31477
31497
|
}
|
|
31478
31498
|
default:
|
|
31479
31499
|
return asToolResponse({ error: `Unknown LSP tool: ${toolName}` });
|
|
@@ -34298,7 +34318,7 @@ var NODE_PATH = {
|
|
|
34298
34318
|
};
|
|
34299
34319
|
var PROPERTIES = {
|
|
34300
34320
|
type: "object",
|
|
34301
|
-
description: 'Properties to set, keyed by Godot property name. Vectors, colours and the like may be written as {"x": 1, "y": 2} or tagged {"_type": "Vector2", "x": 1, "y": 2}, which is the form a read answers with, so a value read off one node can be written straight to another. A property holding a node takes the path to it from the node being set, such as "../Player". Each value is read back once set, and one the engine changed or refused is refused with what it holds.',
|
|
34321
|
+
description: 'Properties to set, keyed by Godot property name. Vectors, colours and the like may be written as {"x": 1, "y": 2} or tagged {"_type": "Vector2", "x": 1, "y": 2}, which is the form a read answers with, so a value read off one node can be written straight to another. A key may also be a colon path into one of the components of a vector, a colour, a rectangle or a transform, as GDScript names them, so one axis is written alone: "position:x", "modulate:a", "region_rect:size:y"; a component the value does not have is refused, naming the ones it has. A property holding a node takes the path to it from the node being set, such as "../Player". Each value is read back once set, and one the engine changed or refused is refused with what it holds.',
|
|
34302
34322
|
additionalProperties: true
|
|
34303
34323
|
};
|
|
34304
34324
|
var XY = {
|
|
@@ -34753,7 +34773,11 @@ var TOOL_SPECS = [
|
|
|
34753
34773
|
requires: ["projectPath", "scenePath"],
|
|
34754
34774
|
operations: {
|
|
34755
34775
|
create: { effect: "destructive", summary: "a new scene with one root node", requires: [] },
|
|
34756
|
-
save: {
|
|
34776
|
+
save: {
|
|
34777
|
+
effect: "destructive",
|
|
34778
|
+
summary: "save the scene as it is in the editor, or as the file loads when the editor does not have it open; from says which",
|
|
34779
|
+
requires: []
|
|
34780
|
+
},
|
|
34757
34781
|
save_as: { effect: "destructive", summary: "save a copy under newPath", requires: ["newPath"] }
|
|
34758
34782
|
},
|
|
34759
34783
|
defaultOperation: "create"
|
|
@@ -34801,7 +34825,11 @@ var TOOL_SPECS = [
|
|
|
34801
34825
|
requires: ["projectPath", "scenePath"],
|
|
34802
34826
|
operations: {
|
|
34803
34827
|
add: { effect: "additive", summary: "add a node of any class", requires: ["nodeType", "nodeName"] },
|
|
34804
|
-
get: {
|
|
34828
|
+
get: {
|
|
34829
|
+
effect: "read",
|
|
34830
|
+
summary: "read a node's properties, and a TileMapLayer's or TileMap's cells as set_tilemap_cells takes them, the first 1000 with cellCount for all",
|
|
34831
|
+
requires: ["nodePath"]
|
|
34832
|
+
},
|
|
34805
34833
|
set: {
|
|
34806
34834
|
effect: "destructive",
|
|
34807
34835
|
summary: "set properties on a node",
|
|
@@ -34828,7 +34856,7 @@ var TOOL_SPECS = [
|
|
|
34828
34856
|
{
|
|
34829
34857
|
name: "scene_signal",
|
|
34830
34858
|
title: "Scene signals",
|
|
34831
|
-
description: "Signal connections in a scene file. Each connection answers with its flags as the number and as flagNames; persist is on every connection, since a scene saves no other kind. Needs the editor connected.",
|
|
34859
|
+
description: "Signal connections in a scene file. Each connection answers with its flags as the number and as flagNames; persist is on every connection, since a scene saves no other kind. A connect of a pair already connected says alreadyConnected, with flagsBefore when it changed the flags. Needs the editor connected.",
|
|
34832
34860
|
parameters: {
|
|
34833
34861
|
projectPath: PROJECT_PATH2,
|
|
34834
34862
|
scenePath: SCENE_PATH,
|
|
@@ -35562,7 +35590,7 @@ var TOOL_SPECS = [
|
|
|
35562
35590
|
value: {
|
|
35563
35591
|
ops: ["set"],
|
|
35564
35592
|
blank: true,
|
|
35565
|
-
description: `set: the value, fitted to the property's type. A vector, colour or the like may be written as its fields, {"x": 140, "y": 300}, or tagged, {"_type": "Vector2", "x": 140, "y": 300}, which is the form a read answers with. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. One declared as Variant or untyped takes anything, as the engine does: a value of the kind it holds is fitted to it, an int staying an int, and any other is stored as sent. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key, as is an object of another script than the one the list holds, whether or not that script has a class_name.`
|
|
35593
|
+
description: `set: the value, fitted to the property's type. A number the property would have to cut is refused rather than truncated: a fraction into an int, a number other than 0 or 1 into a bool. A vector, colour or the like may be written as its fields, {"x": 140, "y": 300}, or tagged, {"_type": "Vector2", "x": 140, "y": 300}, which is the form a read answers with. "" writes an empty string. A property typed as or holding an object takes a path naming one the game holds, the way call's args do, and holds that instance, or null, which the text null also means, to empty it. One declared as Variant or untyped takes anything, as the engine does: a value of the kind it holds is fitted to it, an int staying an int, and any other is stored as sent, a fraction into one holding an int included. A typed list or map, such as Array[int] or Dictionary[String, int], takes a JSON list or object with each element fitted to its element type, and an element that cannot become one is refused by its index or key, as is an object of another script than the one the list holds, whether or not that script has a class_name.`
|
|
35566
35594
|
},
|
|
35567
35595
|
method: {
|
|
35568
35596
|
type: "string",
|
|
@@ -35572,7 +35600,7 @@ var TOOL_SPECS = [
|
|
|
35572
35600
|
args: {
|
|
35573
35601
|
type: "array",
|
|
35574
35602
|
ops: ["call"],
|
|
35575
|
-
description: `call: the arguments, fitted to the method's parameter types. A vector, colour or the like may be written as its fields, {"x": 140, "y": 300}, or tagged, {"_type": "Vector2", "x": 140, "y": 300}. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths. The method itself takes its arguments here only: brackets on it holding arguments are refused.`
|
|
35603
|
+
description: `call: the arguments, fitted to the method's parameter types. A number a parameter would have to cut is refused rather than truncated: a fraction for an int, a number other than 0 or 1 for a bool. A vector, colour or the like may be written as its fields, {"x": 140, "y": 300}, or tagged, {"_type": "Vector2", "x": 140, "y": 300}. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths. The method itself takes its arguments here only: brackets on it holding arguments are refused.`
|
|
35576
35604
|
},
|
|
35577
35605
|
properties: {
|
|
35578
35606
|
type: ["boolean", "array"],
|
|
@@ -38206,7 +38234,7 @@ class GodotServer {
|
|
|
38206
38234
|
const { op: _op, ...arguments_ } = args;
|
|
38207
38235
|
const bridge = async (command, extra = {}) => {
|
|
38208
38236
|
const contained = this.containProjectFiles({ ...arguments_, ...extra });
|
|
38209
|
-
return contained.ok ? await this.handleViaBridge(command, contained.value, tool) : contained.response;
|
|
38237
|
+
return contained.ok ? await this.handleViaBridge(command, contained.value, op === "" ? tool : `${tool} ${op}`) : contained.response;
|
|
38210
38238
|
};
|
|
38211
38239
|
switch (tool) {
|
|
38212
38240
|
case "project_info":
|
|
@@ -38936,8 +38964,30 @@ class GodotServer {
|
|
|
38936
38964
|
]
|
|
38937
38965
|
};
|
|
38938
38966
|
}
|
|
38967
|
+
const dropped = await this.optionsTheImporterDropped(project.value.path, params, written.payload);
|
|
38968
|
+
if (dropped !== null) {
|
|
38969
|
+
return dropped;
|
|
38970
|
+
}
|
|
38939
38971
|
return this.jsonTextResponse({ ...written.payload, reimport: applied.payload });
|
|
38940
38972
|
}
|
|
38973
|
+
async optionsTheImporterDropped(projectPath, params, written) {
|
|
38974
|
+
const listed = written["updated_options"];
|
|
38975
|
+
const updated = Array.isArray(listed) ? listed.map(String) : [];
|
|
38976
|
+
const { options: _options, ...where } = params;
|
|
38977
|
+
const reread = await this.operation("get_import_options", where, projectPath);
|
|
38978
|
+
if (!reread.ok || updated.length === 0) {
|
|
38979
|
+
return null;
|
|
38980
|
+
}
|
|
38981
|
+
const held = reread.payload["params"];
|
|
38982
|
+
const kept = held !== null && typeof held === "object" ? Object.keys(held) : [];
|
|
38983
|
+
const gone = updated.filter((name) => !kept.includes(name));
|
|
38984
|
+
if (gone.length === 0) {
|
|
38985
|
+
return null;
|
|
38986
|
+
}
|
|
38987
|
+
const applied = updated.filter((name) => kept.includes(name));
|
|
38988
|
+
const named = gone.join(", ");
|
|
38989
|
+
return this.createErrorResponse(`${named} ${gone.length === 1 ? "is not an option" : "are not options"} of this resource's importer: the reimport left ${gone.length === 1 ? "it" : "them"} out of the import file, so ${gone.length === 1 ? "it changed" : "they changed"} nothing.${applied.length === 0 ? "" : ` ${applied.join(", ")} ${applied.length === 1 ? "was" : "were"} applied.`} Its options are: ${kept.join(", ")}.`, ["project_import options lists every option with the value it holds"]);
|
|
38990
|
+
}
|
|
38941
38991
|
async reimport(projectPath, resourcePath, force, timeoutMs) {
|
|
38942
38992
|
const ask = resourcePath === undefined ? { includeUpToDate: force } : { resourcePath };
|
|
38943
38993
|
const before = await this.operation("get_import_status", ask, projectPath);
|
|
@@ -40037,7 +40087,7 @@ class GodotServer {
|
|
|
40037
40087
|
}
|
|
40038
40088
|
try {
|
|
40039
40089
|
const askedAt = Date.now();
|
|
40040
|
-
const asked = this.godotBridge.invokeTool("playing_status", {});
|
|
40090
|
+
const asked = this.godotBridge.invokeTool("playing_status", {}, { counted: false });
|
|
40041
40091
|
const answer = await Promise.race([asked, delay4(PLAYING_STATUS_MS).then(() => null)]);
|
|
40042
40092
|
if (answer === null) {
|
|
40043
40093
|
this.logDebug(`playing_status did not answer within ${PLAYING_STATUS_MS}ms`);
|
|
@@ -40062,7 +40112,7 @@ class GodotServer {
|
|
|
40062
40112
|
}
|
|
40063
40113
|
try {
|
|
40064
40114
|
const answer = await Promise.race([
|
|
40065
|
-
this.godotBridge.invokeTool("scan_status", {}),
|
|
40115
|
+
this.godotBridge.invokeTool("scan_status", {}, { counted: false }),
|
|
40066
40116
|
delay4(PLAYING_STATUS_MS).then(() => null)
|
|
40067
40117
|
]);
|
|
40068
40118
|
if (answer === null) {
|
|
@@ -40130,10 +40180,14 @@ class GodotServer {
|
|
|
40130
40180
|
this.getEditorStatusPayload(),
|
|
40131
40181
|
this.editorServersListening()
|
|
40132
40182
|
]);
|
|
40183
|
+
const answeredAt = Date.now();
|
|
40184
|
+
const inFlight = this.godotBridge.requestsInFlight().map((request) => ({ tool: request.tool, forMs: answeredAt - request.startedAt }));
|
|
40133
40185
|
return this.jsonTextResponse({
|
|
40134
40186
|
editor: {
|
|
40135
40187
|
...editorStatus,
|
|
40136
40188
|
...servers,
|
|
40189
|
+
pendingRequests: inFlight.length,
|
|
40190
|
+
pendingRequestTools: inFlight.length === 0 ? undefined : inFlight,
|
|
40137
40191
|
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort,
|
|
40138
40192
|
scan
|
|
40139
40193
|
},
|
|
@@ -42721,14 +42775,16 @@ class GodotServer {
|
|
|
42721
42775
|
if (!(answered instanceof BridgeTimeout)) {
|
|
42722
42776
|
return answered;
|
|
42723
42777
|
}
|
|
42724
|
-
return this.createErrorResponse(`The editor took ${toolName} in and did not answer within ${String(answered.waitedMs / 1000)}s. An editor importing files answers nothing until the import is over, and a hidden one imports on the thread that answers.`, [
|
|
42778
|
+
return this.createErrorResponse(`The editor took ${askedAs ?? toolName} in and did not answer within ${String(answered.waitedMs / 1000)}s. An editor importing files answers nothing until the import is over, and a hidden one imports on the thread that answers.`, [
|
|
42725
42779
|
"editor_status says whether it is scanning or importing",
|
|
42726
42780
|
"The editor still carries the request out once it is free, so read what it changed before asking again"
|
|
42727
42781
|
]);
|
|
42728
42782
|
}
|
|
42729
42783
|
async askViaBridge(toolName, args, askedAs) {
|
|
42784
|
+
const named = askedAs ?? toolName;
|
|
42730
42785
|
if (!this.godotBridge.isConnected()) {
|
|
42731
|
-
const
|
|
42786
|
+
const tool = askedAs?.split(" ")[0];
|
|
42787
|
+
const alternative = tool !== undefined && Object.hasOwn(EDITOR_FREE_INSTEAD, tool) ? EDITOR_FREE_INSTEAD[tool] : undefined;
|
|
42732
42788
|
const refused = await this.noEditorYet(args, alternative === undefined ? [] : [alternative]);
|
|
42733
42789
|
if (refused !== null) {
|
|
42734
42790
|
return refused;
|
|
@@ -42752,9 +42808,9 @@ class GodotServer {
|
|
|
42752
42808
|
if (message.includes(`Unknown tool: ${toolName}`)) {
|
|
42753
42809
|
const status = this.godotBridge.getStatus();
|
|
42754
42810
|
const remedy = addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk());
|
|
42755
|
-
return this.createErrorResponse(`The editor's gdharness addon is older than this ${SERVER_VERSION} server and has no ${
|
|
42811
|
+
return this.createErrorResponse(`The editor's gdharness addon is older than this ${SERVER_VERSION} server and has no ${named}, so it cannot answer this call. ${remedy ?? "Restart it with editor_launch restart to load the addon the project holds."}`, ["editor_status says which addon the editor holds"]);
|
|
42756
42812
|
}
|
|
42757
|
-
return this.createErrorResponse(`The editor answered ${
|
|
42813
|
+
return this.createErrorResponse(`The editor answered ${named} with an error: ${message}`);
|
|
42758
42814
|
}
|
|
42759
42815
|
}
|
|
42760
42816
|
runtimeTimeoutMs() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.31",
|
|
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",
|