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.
@@ -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: String = _base_of(path, header, skipped)
97
- if base.is_empty():
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: the nearest ancestor with a class_name, else the native
113
- # class at the top of the chain. A script that extends by path is followed to its own header.
114
- func _base_of(path: String, header: Dictionary, skipped: Array[Dictionary]) -> String:
115
- var visited: Array[String] = [path]
116
- var current: Dictionary = header
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
- var extends_what: String = str(current.get("extends", ""))
119
- if extends_what.is_empty():
130
+ if extends_what == null:
120
131
  return "RefCounted"
121
- if not (extends_what.begins_with('"') or extends_what.begins_with("'")):
122
- return extends_what
123
- var parent_path: String = extends_what.substr(1, extends_what.length() - 2)
124
- if not parent_path.begins_with("res://"):
125
- parent_path = visited[visited.size() - 1].get_base_dir().path_join(parent_path)
126
- if not FileAccess.file_exists(parent_path):
127
- skipped.append({"path": path, "reason": "extends a script that does not exist: " + parent_path})
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
- if parent_path in visited:
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
- visited.append(parent_path)
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 declarations at the top of a script: class_name, what it extends (a class name, or a
141
- # quoted path), and the @tool, @abstract and @icon annotations. Reading stops at the first
142
- # statement that is none of those, which is where the body begins.
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+(\\S+)")
150
- while not file.eof_reached():
151
- var rest: String = file.get_line().strip_edges()
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
- if not declared.get_string(2).is_empty():
171
- header["extends"] = declared.get_string(2)
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
- methods.append(
322
- {
323
- "name": m.get("name", ""),
324
- "args": args,
325
- "return":
323
+ (
324
+ methods
325
+ . append(
326
326
  {
327
- "type": return_info.get("type", 0),
328
- "type_name": _type_name(return_info, "void"),
329
- "class_name": return_info.get("class_name", "")
330
- },
331
- "flags": m.get("flags", 0),
332
- "default_args": m.get("default_args", [])
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.pendingRequests.size,
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 shownSymbol(entry) {
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 = shownPosition(whole["end"]);
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.line },
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: { effect: "destructive", summary: "save the scene as it is in the editor", requires: [] },
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: { effect: "read", summary: "read a node's properties", requires: ["nodePath"] },
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 alternative = askedAs !== undefined && Object.hasOwn(EDITOR_FREE_INSTEAD, askedAs) ? EDITOR_FREE_INSTEAD[askedAs] : undefined;
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 ${toolName}, 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"]);
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 ${toolName} with an error: ${message}`);
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.29",
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",