gdharness 1.3.8 → 1.3.10

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.
Files changed (78) hide show
  1. package/build/cli.js +4668 -3229
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +115 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +112 -6
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +19 -29
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +39 -6
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
  17. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  20. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  23. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
  25. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  26. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
  27. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  28. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  29. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
  30. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  31. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
  32. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  33. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  34. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  35. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  36. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  37. package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
  38. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  40. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
  41. package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
  42. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  43. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  44. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
  45. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
  46. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  47. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
  48. package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
  49. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  50. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  51. package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
  52. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  53. package/build/godot/operations/animation_reads.gd +115 -4
  54. package/build/godot/operations/audio_buses.gd +93 -7
  55. package/build/godot/operations/classdb_queries.gd +119 -7
  56. package/build/godot/operations/declared_values.gd +272 -0
  57. package/build/godot/operations/dependencies.gd +154 -41
  58. package/build/godot/operations/editor_tools.gd +29 -13
  59. package/build/godot/operations/export_presets.gd +602 -4
  60. package/build/godot/operations/file_reads.gd +115 -18
  61. package/build/godot/operations/gdscript_analysis.gd +14 -0
  62. package/build/godot/operations/gdscript_authoring.gd +12 -2
  63. package/build/godot/operations/godot_operations.gd +18 -2
  64. package/build/godot/operations/import_pipeline.gd +20 -221
  65. package/build/godot/operations/input_actions.gd +127 -51
  66. package/build/godot/operations/project_config.gd +113 -68
  67. package/build/godot/operations/project_diagnostics.gd +4 -0
  68. package/build/godot/operations/project_validation.gd +266 -0
  69. package/build/godot/operations/property_paths.gd +89 -16
  70. package/build/godot/operations/property_values.gd +180 -121
  71. package/build/godot/operations/reading.gd +112 -6
  72. package/build/godot/operations/scene_connections.gd +222 -0
  73. package/build/godot/operations/serialisation.gd +19 -29
  74. package/build/godot/operations/setting_reads.gd +121 -0
  75. package/build/godot/operations/value_shapes.gd +128 -1
  76. package/build/godot/tests/listed.gd +20 -13
  77. package/build/keeper.js +1402 -707
  78. package/package.json +1 -1
@@ -5,6 +5,7 @@ extends RefCounted
5
5
  ## stores, which runs no script of the scene's, so a read answers the same either way.
6
6
 
7
7
  const GdhFileReads = preload("file_reads.gd")
8
+ const GdhRead = preload("reading.gd")
8
9
  const GdhSerialisation = preload("serialisation.gd")
9
10
 
10
11
  const LOOP_MODE_NAMES: Dictionary[int, String] = {
@@ -28,6 +29,11 @@ const SWITCH_MODE_NAMES: Dictionary[int, String] = {
28
29
  AnimationNodeStateMachineTransition.SWITCH_MODE_SYNC: "sync",
29
30
  AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END: "at_end",
30
31
  }
32
+ const ADVANCE_MODE_NAMES: Dictionary[int, String] = {
33
+ AnimationNodeStateMachineTransition.ADVANCE_MODE_DISABLED: "disabled",
34
+ AnimationNodeStateMachineTransition.ADVANCE_MODE_ENABLED: "enabled",
35
+ AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO: "auto",
36
+ }
31
37
 
32
38
 
33
39
  ## [method animations] of the AnimationPlayer at [param player_path] in the file [param scene_path].
@@ -47,8 +53,21 @@ static func scene_states(scene_path: String, tree_path: String, state_machine_pa
47
53
  if made[0] == null:
48
54
  return made[1]
49
55
  var tree: AnimationTree = made[0]
50
- var answer: Dictionary = states(tree, state_machine_path)
56
+ # The player the tree plays from, read from the file the same way, since a bare tree has no
57
+ # siblings to find it among.
58
+ # A path above the root names no node the file has, and finds none, as the editor finds none.
59
+ var player: AnimationPlayer = null
60
+ var player_path: String = ""
61
+ if not str(tree.anim_player).is_empty():
62
+ player_path = (tree_path + "/" + str(tree.anim_player)).simplify_path()
63
+ if player_path.is_empty():
64
+ player_path = "."
65
+ var found: Array = _bare(scene_path, player_path, "AnimationPlayer")
66
+ player = found[0]
67
+ var answer: Dictionary = states(tree, state_machine_path, player, player_path)
51
68
  tree.free()
69
+ if player != null:
70
+ player.free()
52
71
  return answer
53
72
 
54
73
 
@@ -200,8 +219,28 @@ static func no_machine(tree: AnimationTree, state_machine_path: String) -> Strin
200
219
  return "No state machine at " + state_machine_path
201
220
 
202
221
 
222
+ ## Why a state playing [param animation_name] from [param player], at [param player_path] from the
223
+ ## scene root as playerNodePath takes it, plays nothing yet. anim_player is relative to the tree
224
+ ## ("../Anim"), which no argument takes.
225
+ static func missing_animation(player: AnimationPlayer, player_path: String, animation_name: String) -> String:
226
+ var said: String = (
227
+ "The AnimationPlayer at %s has no animation %s yet, so the state plays nothing until it has one."
228
+ % [player_path, animation_name]
229
+ )
230
+ var close: PackedStringArray = GdhRead.nearest(animation_name, player.get_animation_list())
231
+ if close.is_empty():
232
+ return said
233
+ return (
234
+ said + " The nearest %s %s." % ["it has is" if close.size() == 1 else "it has are", ", ".join(close)]
235
+ )
236
+
237
+
203
238
  ## The states and transitions of the state machine at [param state_machine_path] in [param tree].
204
- static func states(tree: AnimationTree, state_machine_path: String) -> Dictionary:
239
+ ## A state playing an animation says whether [param player], at [param player_path], has it, as
240
+ ## add_state answers: a state naming one it lacks plays nothing, and says so only in a log.
241
+ static func states(
242
+ tree: AnimationTree, state_machine_path: String, player: AnimationPlayer, player_path: String
243
+ ) -> Dictionary:
205
244
  var sm: AnimationNodeStateMachine = state_machine(tree, state_machine_path)
206
245
  if sm == null:
207
246
  return {"ok": false, "error": no_machine(tree, state_machine_path)}
@@ -212,6 +251,10 @@ static func states(tree: AnimationTree, state_machine_path: String) -> Dictionar
212
251
  if node is AnimationNodeAnimation:
213
252
  var plays: AnimationNodeAnimation = node
214
253
  entry["animationName"] = str(plays.animation)
254
+ var found: bool = player != null and player.has_animation(plays.animation)
255
+ entry["animationFound"] = found
256
+ if player != null and not found:
257
+ entry["animationMissing"] = missing_animation(player, player_path, str(plays.animation))
215
258
  listed.append(entry)
216
259
  var transitions: Array[Dictionary] = []
217
260
  for index: int in sm.get_transition_count():
@@ -220,8 +263,76 @@ static func states(tree: AnimationTree, state_machine_path: String) -> Dictionar
220
263
  "from": str(sm.get_transition_from(index)),
221
264
  "to": str(sm.get_transition_to(index)),
222
265
  "transitionType": SWITCH_MODE_NAMES.get(transition.switch_mode, str(transition.switch_mode)),
266
+ "advanceMode": ADVANCE_MODE_NAMES.get(transition.advance_mode, str(transition.advance_mode)),
223
267
  }
224
- if not str(transition.advance_condition).is_empty():
225
- shaped["advanceCondition"] = str(transition.advance_condition)
268
+ var condition: String = str(transition.advance_condition)
269
+ if not condition.is_empty():
270
+ shaped["advanceCondition"] = condition
271
+ # A condition is checked only on an auto transition, and the default is enabled: a machine
272
+ # with its conditions set true stayed where it was, and nothing said why.
273
+ if not condition.is_empty() and transition.advance_mode != transition.ADVANCE_MODE_AUTO:
274
+ shaped["conditionNote"] = (
275
+ (
276
+ "advanceMode is %s, so %s never advances it: the engine checks a condition only on an"
277
+ + " auto transition%s. disconnect_states and connect_states again with the condition"
278
+ + " write it as auto"
279
+ )
280
+ % [
281
+ shaped["advanceMode"],
282
+ condition,
283
+ (
284
+ ", and travel() still takes this one"
285
+ if transition.advance_mode == transition.ADVANCE_MODE_ENABLED
286
+ else ""
287
+ )
288
+ ]
289
+ )
226
290
  transitions.append(shaped)
227
291
  return {"ok": true, "states": listed, "transitions": transitions}
292
+
293
+
294
+ ## The states of the AnimationTrees under [param root] that play [param animation_name] from
295
+ ## [param player], as [[node, {animTreePath, state}]], a state named by the path of the nodes leading
296
+ ## to it.
297
+ static func states_playing(root: Node, player: AnimationPlayer, animation_name: String) -> Array:
298
+ var found: Array = []
299
+ var trees: Array[Node] = root.find_children("*", "AnimationTree", true, false)
300
+ if root is AnimationTree:
301
+ trees.append(root)
302
+ for node: Node in trees:
303
+ var tree: AnimationTree = node
304
+ if tree.tree_root == null or tree.get_node_or_null(tree.anim_player) != player:
305
+ continue
306
+ var tree_path: String = str(root.get_path_to(tree))
307
+ _collect_playing(tree.tree_root, "", tree_path, animation_name, found)
308
+ return found
309
+
310
+
311
+ static func _collect_playing(
312
+ node: AnimationNode, at: String, tree_path: String, animation_name: String, found: Array
313
+ ) -> void:
314
+ if node is AnimationNodeAnimation:
315
+ var plays: AnimationNodeAnimation = node
316
+ if plays.animation == StringName(animation_name):
317
+ found.append([plays, {"animTreePath": tree_path, "state": "." if at.is_empty() else at}])
318
+ return
319
+ var children: Dictionary = {}
320
+ if node is AnimationNodeStateMachine:
321
+ var machine: AnimationNodeStateMachine = node
322
+ for state: StringName in machine.get_node_list():
323
+ children[str(state)] = machine.get_node(state)
324
+ elif node is AnimationNodeBlendTree:
325
+ var blend: AnimationNodeBlendTree = node
326
+ for input: StringName in blend.get_node_list():
327
+ children[str(input)] = blend.get_node(input)
328
+ elif node is AnimationNodeBlendSpace1D:
329
+ var line: AnimationNodeBlendSpace1D = node
330
+ for point: int in line.get_blend_point_count():
331
+ children[str(point)] = line.get_blend_point_node(point)
332
+ elif node is AnimationNodeBlendSpace2D:
333
+ var plane: AnimationNodeBlendSpace2D = node
334
+ for point: int in plane.get_blend_point_count():
335
+ children[str(point)] = plane.get_blend_point_node(point)
336
+ for step: String in children:
337
+ var child: AnimationNode = children[step]
338
+ _collect_playing(child, step if at.is_empty() else at + "/" + step, tree_path, animation_name, found)
@@ -19,7 +19,7 @@ func create_audio_bus(params: Dictionary) -> Dictionary:
19
19
  return _log.failure("bus_name is required")
20
20
  var parent_idx: int = GdhRead.as_int(params.get("parent_bus_index", 0))
21
21
  if parent_idx < 0 or parent_idx >= AudioServer.bus_count:
22
- return _log.failure("No bus at index " + str(parent_idx))
22
+ return _log.failure(_no_index(parent_idx))
23
23
  # Sends are by name, so a second bus with one would leave every send to it meaning either.
24
24
  var existing: int = AudioServer.get_bus_index(bus_name)
25
25
  if existing != -1:
@@ -50,10 +50,7 @@ func remove_audio_bus(params: Dictionary) -> Dictionary:
50
50
  return _log.failure("bus_name is required")
51
51
  var index: int = AudioServer.get_bus_index(bus_name)
52
52
  if index == -1:
53
- var names: Array[String] = []
54
- for i: int in range(AudioServer.bus_count):
55
- names.append(AudioServer.get_bus_name(i))
56
- return _log.failure("No bus named %s. The buses are %s." % [bus_name, ", ".join(names)])
53
+ return _log.failure("No bus named %s. The buses are %s." % [bus_name, ", ".join(_names())])
57
54
  if index == 0:
58
55
  return _log.failure("%s is the bus every other ends in, so it cannot be removed." % bus_name)
59
56
 
@@ -105,7 +102,7 @@ func set_audio_bus_effect(params: Dictionary) -> Dictionary:
105
102
  var effect_type: String = str(params.get("effect_type", ""))
106
103
  var enabled: bool = GdhRead.as_bool(params.get("enabled", true), true)
107
104
  if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
108
- return _log.failure("No bus at index " + str(bus_idx))
105
+ return _log.failure(_no_index(bus_idx))
109
106
 
110
107
  var effect: AudioEffect = _effect_named(effect_type)
111
108
  if effect == null:
@@ -153,7 +150,7 @@ func set_audio_bus_volume(params: Dictionary) -> Dictionary:
153
150
  var bus_idx: int = GdhRead.as_int(params.get("bus_index", 0))
154
151
  var volume_db: float = GdhRead.as_float(params.get("volume_db", 0.0))
155
152
  if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
156
- return _log.failure("No bus at index " + str(bus_idx))
153
+ return _log.failure(_no_index(bus_idx))
157
154
 
158
155
  AudioServer.set_bus_volume_db(bus_idx, volume_db)
159
156
 
@@ -163,6 +160,95 @@ func set_audio_bus_volume(params: Dictionary) -> Dictionary:
163
160
  return {"bus": _bus(bus_idx), "layout": layout}
164
161
 
165
162
 
163
+ ## Renames a bus, and every send to it with it: the engine renames the one bus and leaves a bus that
164
+ ## sent to it sending to a name nothing has, which it reads as Master. The scenes naming it are the
165
+ ## server's to rewrite, since they are files rather than anything this process holds.
166
+ func rename_audio_bus(params: Dictionary) -> Dictionary:
167
+ var bus_name: String = str(params.get("bus_name", ""))
168
+ var new_name: String = str(params.get("new_bus_name", ""))
169
+ if bus_name.is_empty() or new_name.is_empty():
170
+ return _log.failure("bus_name and new_bus_name are required")
171
+ var index: int = AudioServer.get_bus_index(bus_name)
172
+ if index == -1:
173
+ return _log.failure("No bus named %s. The buses are %s." % [bus_name, ", ".join(_names())])
174
+ if index == 0:
175
+ return _log.failure(
176
+ "%s is the bus every other ends in, which the editor keeps under its name." % bus_name
177
+ )
178
+ var existing: int = AudioServer.get_bus_index(new_name)
179
+ if existing != -1:
180
+ return _log.failure("A bus named " + new_name + " already exists, at index " + str(existing))
181
+ AudioServer.set_bus_name(index, new_name)
182
+ var resent: Array[String] = []
183
+ for i: int in range(AudioServer.bus_count):
184
+ if AudioServer.get_bus_send(i) == bus_name:
185
+ AudioServer.set_bus_send(i, new_name)
186
+ resent.append(AudioServer.get_bus_name(i))
187
+
188
+ var layout: String = _save_layout()
189
+ if layout.is_empty():
190
+ return {}
191
+ var answer: Dictionary = {
192
+ "renamed": {"from": bus_name, "to": new_name}, "bus": _bus(index), "layout": layout
193
+ }
194
+ if not resent.is_empty():
195
+ answer["resent"] = resent
196
+ return answer
197
+
198
+
199
+ ## Points a bus's send at another bus. Only one before it: the editor offers no other, and the
200
+ ## engine takes any name, a later bus, the bus itself or one that does not exist, without a word.
201
+ func set_audio_bus_send(params: Dictionary) -> Dictionary:
202
+ var bus_idx: int = GdhRead.as_int(params.get("bus_index", 0))
203
+ var send: String = str(params.get("send_to", ""))
204
+ if bus_idx < 0 or bus_idx >= AudioServer.bus_count:
205
+ return _log.failure(_no_index(bus_idx))
206
+ var bus_name: String = AudioServer.get_bus_name(bus_idx)
207
+ if bus_idx == 0:
208
+ return _log.failure("%s sends nowhere: it is the bus every other ends in." % bus_name)
209
+ var target: int = AudioServer.get_bus_index(send)
210
+ if target == -1:
211
+ return _log.failure("No bus named %s. The buses are %s." % [send, ", ".join(_names())])
212
+ if target == bus_idx:
213
+ return _log.failure(
214
+ "%s cannot send to itself; it can send to %s." % [bus_name, ", ".join(_names().slice(0, bus_idx))]
215
+ )
216
+ if target > bus_idx:
217
+ return (
218
+ _log
219
+ . failure(
220
+ (
221
+ (
222
+ "%s is bus %d and %s is bus %d: a bus sends only to one before it, which keeps sound from "
223
+ + "going round in a loop, so %s can send to %s."
224
+ )
225
+ % [bus_name, bus_idx, send, target, bus_name, ", ".join(_names().slice(0, bus_idx))]
226
+ )
227
+ )
228
+ )
229
+ AudioServer.set_bus_send(bus_idx, send)
230
+
231
+ var layout: String = _save_layout()
232
+ if layout.is_empty():
233
+ return {}
234
+ return {"bus": _bus(bus_idx), "layout": layout}
235
+
236
+
237
+ func _names() -> Array[String]:
238
+ var names: Array[String] = []
239
+ for i: int in range(AudioServer.bus_count):
240
+ names.append(AudioServer.get_bus_name(i))
241
+ return names
242
+
243
+
244
+ ## The refusal for a bus index nothing is at, with each bus at its index, so the caller has one.
245
+ func _no_index(index: int) -> String:
246
+ var held: PackedStringArray = []
247
+ for i: int in range(AudioServer.bus_count):
248
+ var _added: bool = held.append("%d %s" % [i, AudioServer.get_bus_name(i)])
249
+ return "No bus at index %d; the buses are %s." % [index, ", ".join(held)]
250
+
251
+
166
252
  func _bus(index: int) -> Dictionary:
167
253
  var effects: Array[Dictionary] = []
168
254
  for e: int in range(AudioServer.get_bus_effect_count(index)):
@@ -93,6 +93,19 @@ func query_classes(params: Dictionary) -> Dictionary:
93
93
 
94
94
  filtered_classes.append(class_name_str)
95
95
 
96
+ var declared: Dictionary = _declared_classes(params)
97
+ var own: Array[Dictionary] = []
98
+ for name: String in _sorted_names(declared):
99
+ if instantiable_only and not _instantiable(name, declared):
100
+ continue
101
+ if not filter.is_empty() and not name.to_lower().contains(filter.to_lower()):
102
+ continue
103
+ if not bases.is_empty():
104
+ var engine_base: String = _engine_base_of(name, declared)
105
+ if engine_base.is_empty() or not _under_any(engine_base, bases):
106
+ continue
107
+ own.append(_project_entry(name, declared))
108
+
96
109
  _log.info(
97
110
  "Found " + str(filtered_classes.size()) + " classes (out of " + str(all_classes.size()) + " total)"
98
111
  )
@@ -103,7 +116,8 @@ func query_classes(params: Dictionary) -> Dictionary:
103
116
  "filter": filter,
104
117
  "category": category,
105
118
  "instantiable_only": instantiable_only,
106
- "classes": filtered_classes
119
+ "classes": filtered_classes,
120
+ "project_classes": own,
107
121
  }
108
122
 
109
123
 
@@ -194,6 +208,8 @@ static func _names(members: Array[Dictionary]) -> Array[String]:
194
208
  return names
195
209
 
196
210
 
211
+ ## What the project's scripts declare on disk, class to its path, what it extends and whether it is
212
+ ## abstract, as the server read them.
197
213
  static func _declared_classes(params: Dictionary) -> Dictionary:
198
214
  var given: Variant = params.get("project_classes", {})
199
215
  if given is Dictionary:
@@ -202,12 +218,75 @@ static func _declared_classes(params: Dictionary) -> Dictionary:
202
218
  return {}
203
219
 
204
220
 
221
+ static func _entry_of(name: String, declared: Dictionary) -> Dictionary:
222
+ var entry: Variant = declared.get(name, {})
223
+ if entry is Dictionary:
224
+ var fields: Dictionary = entry
225
+ return fields
226
+ return {}
227
+
228
+
229
+ static func _sorted_names(declared: Dictionary) -> Array[String]:
230
+ var names: Array[String] = []
231
+ for name: Variant in declared.keys():
232
+ names.append(str(name))
233
+ names.sort()
234
+ return names
235
+
236
+
237
+ ## [param name]'s ancestors, nearest first: the project classes it extends, then the engine's. A
238
+ ## base that is neither ends the line, as it is written.
239
+ static func _ancestors_of(name: String, declared: Dictionary) -> Array[String]:
240
+ var ancestors: Array[String] = []
241
+ var current: String = name
242
+ while declared.has(current):
243
+ var base: String = str(_entry_of(current, declared).get("extends", ""))
244
+ if base.is_empty() or base == name or ancestors.has(base):
245
+ return ancestors
246
+ ancestors.append(base)
247
+ current = base
248
+ while ClassDB.class_exists(current):
249
+ var parent: String = ClassDB.get_parent_class(current)
250
+ if parent.is_empty():
251
+ break
252
+ ancestors.append(parent)
253
+ current = parent
254
+ return ancestors
255
+
256
+
257
+ ## The engine class [param name] is built on, or empty when its line reaches none.
258
+ static func _engine_base_of(name: String, declared: Dictionary) -> String:
259
+ if not declared.has(name):
260
+ return name if ClassDB.class_exists(name) else ""
261
+ for each: String in _ancestors_of(name, declared):
262
+ if ClassDB.class_exists(each):
263
+ return each
264
+ return ""
265
+
266
+
267
+ static func _instantiable(name: String, declared: Dictionary) -> bool:
268
+ if GdhRead.as_bool(_entry_of(name, declared).get("abstract", false)):
269
+ return false
270
+ var engine_base: String = _engine_base_of(name, declared)
271
+ return not engine_base.is_empty() and ClassDB.can_instantiate(engine_base)
272
+
273
+
274
+ static func _project_entry(name: String, declared: Dictionary) -> Dictionary:
275
+ var entry: Dictionary = _entry_of(name, declared)
276
+ return {
277
+ "name": name,
278
+ "path": str(entry.get("path", "")),
279
+ "extends": str(entry.get("extends", "")),
280
+ "abstract": GdhRead.as_bool(entry.get("abstract", false)),
281
+ }
282
+
283
+
205
284
  ## Why [param wanted] has no answer here. A class_name a project script declares is not in ClassDB,
206
285
  ## and "Class not found" for one read as though the project had no such class. [param declared] is
207
286
  ## what the project's scripts declare on disk, class to path, rather than the engine's list, which is
208
287
  ## the class cache: that lags the scripts, and a class written a moment ago was refused as declared
209
288
  ## nowhere.
210
- func _not_found(wanted: String, declared: Dictionary) -> Dictionary:
289
+ func _not_found(wanted: String, declared: Dictionary, answers_own: bool = false) -> Dictionary:
211
290
  if declared.has(wanted):
212
291
  return _log.failure(
213
292
  (
@@ -216,7 +295,7 @@ func _not_found(wanted: String, declared: Dictionary) -> Dictionary:
216
295
  + "the engine's ClassDB only. script_info structure on that script, with "
217
296
  + "includeInherited, answers what it declares and what it inherits."
218
297
  )
219
- % [wanted, str(declared[wanted])]
298
+ % [wanted, str(_entry_of(wanted, declared).get("path", ""))]
220
299
  )
221
300
  )
222
301
  # The usual miss is a spelling, one letter off a class the engine has or one the project declares.
@@ -245,13 +324,18 @@ func _not_found(wanted: String, declared: Dictionary) -> Dictionary:
245
324
  )
246
325
  if not own.is_empty():
247
326
  offered += (
248
- " The nearest %s a project script declares %s %s, which script_info structure reads."
327
+ " The nearest %s a project script declares %s %s%s."
249
328
  % [
250
329
  "class" if own.size() == 1 else "classes",
251
330
  "is" if own.size() == 1 else "are",
252
331
  ", ".join(own),
332
+ "" if answers_own else ", which script_info structure reads",
253
333
  ]
254
334
  )
335
+ if answers_own:
336
+ return _log.failure(
337
+ "Class not found: %s.%s Neither the engine nor a project script declares it." % [wanted, offered]
338
+ )
255
339
  return _log.failure(
256
340
  (
257
341
  (
@@ -340,8 +424,34 @@ func inspect_inheritance(params: Dictionary) -> Dictionary:
340
424
 
341
425
  _log.info("Inspecting inheritance for: " + class_name_str)
342
426
 
343
- if not ClassDB.class_exists(class_name_str):
344
- return _not_found(class_name_str, _declared_classes(params))
427
+ var declared: Dictionary = _declared_classes(params)
428
+ var own_class: bool = declared.has(class_name_str)
429
+ if not own_class and not ClassDB.class_exists(class_name_str):
430
+ return _not_found(class_name_str, declared, true)
431
+
432
+ # A class a project script declares can stand only under another or under an engine class, so
433
+ # both answers carry the project's own beneath the class asked about.
434
+ var project_children: Array[Dictionary] = []
435
+ var project_descendants: Array[Dictionary] = []
436
+ for name: String in _sorted_names(declared):
437
+ var line: Array[String] = _ancestors_of(name, declared)
438
+ if name == class_name_str or not line.has(class_name_str):
439
+ continue
440
+ project_descendants.append(_project_entry(name, declared))
441
+ if line[0] == class_name_str:
442
+ project_children.append(_project_entry(name, declared))
443
+ if own_class:
444
+ var own_line: Array[String] = _ancestors_of(class_name_str, declared)
445
+ return {
446
+ "class_name": class_name_str,
447
+ "declared_in": str(_entry_of(class_name_str, declared).get("path", "")),
448
+ "parent_class": "" if own_line.is_empty() else own_line[0],
449
+ "ancestors": own_line,
450
+ "abstract": GdhRead.as_bool(_entry_of(class_name_str, declared).get("abstract", false)),
451
+ "can_instantiate": _instantiable(class_name_str, declared),
452
+ "project_children": project_children,
453
+ "project_descendants": project_descendants,
454
+ }
345
455
 
346
456
  var ancestors: Array[String] = []
347
457
  var current: String = class_name_str
@@ -386,7 +496,9 @@ func inspect_inheritance(params: Dictionary) -> Dictionary:
386
496
  "direct_children": direct_children,
387
497
  "all_descendants_count": all_descendants.size(),
388
498
  "all_descendants": all_descendants,
389
- "can_instantiate": ClassDB.can_instantiate(class_name_str)
499
+ "can_instantiate": ClassDB.can_instantiate(class_name_str),
500
+ "project_children": project_children,
501
+ "project_descendants": project_descendants,
390
502
  }
391
503
 
392
504