gdharness 1.3.1 → 1.3.3
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 +369 -97
- package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -9
- package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
- package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/file_reads.gd +5 -0
- package/build/godot/addons/gdharness_editor/file_walk.gd +134 -0
- package/build/godot/addons/gdharness_editor/file_walk.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/property_values.gd +161 -17
- package/build/godot/addons/gdharness_editor/reading.gd +37 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +3 -1
- package/build/godot/addons/gdharness_editor/serialisation.gd +4 -0
- package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +367 -23
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +133 -39
- package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +20 -3
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +17 -13
- package/build/godot/addons/gdharness_editor/value_shapes.gd +7 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +37 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +27 -1
- package/build/godot/addons/gdharness_runtime/serialisation.gd +4 -0
- package/build/godot/addons/gdharness_runtime/value_shapes.gd +7 -0
- package/build/godot/operations/animation_reads.gd +11 -9
- package/build/godot/operations/classdb_queries.gd +13 -3
- package/build/godot/operations/declared_values.gd +116 -0
- package/build/godot/operations/dependencies.gd +109 -5
- package/build/godot/operations/editor_tools.gd +3 -0
- package/build/godot/operations/export_presets.gd +4 -6
- package/build/godot/operations/file_reads.gd +5 -0
- package/build/godot/operations/file_walk.gd +35 -0
- package/build/godot/operations/gdscript_analysis.gd +7 -2
- package/build/godot/operations/gdscript_authoring.gd +105 -2
- package/build/godot/operations/godot_operations.gd +2 -0
- package/build/godot/operations/import_pipeline.gd +39 -0
- package/build/godot/operations/input_actions.gd +73 -1
- package/build/godot/operations/project_config.gd +59 -3
- package/build/godot/operations/project_diagnostics.gd +27 -2
- package/build/godot/operations/property_values.gd +161 -17
- package/build/godot/operations/reading.gd +37 -0
- package/build/godot/operations/resource_files.gd +4 -1
- package/build/godot/operations/script_check.gd +16 -0
- package/build/godot/operations/serialisation.gd +4 -0
- package/build/godot/operations/value_shapes.gd +7 -0
- package/package.json +1 -1
|
@@ -111,6 +111,24 @@ func add_input_action(params: Dictionary) -> Dictionary:
|
|
|
111
111
|
var deadzone: float = GdhRead.as_float(params.get("deadzone", 0.5), 0.5)
|
|
112
112
|
|
|
113
113
|
_log.info("Adding input action: " + action_name)
|
|
114
|
+
# The editor's Input Map refuses these, since project.godot keys an action by its name and
|
|
115
|
+
# the characters below are its own syntax there; a name holding one is written escaped and
|
|
116
|
+
# reads back as a name nobody typed.
|
|
117
|
+
for index: int in action_name.length():
|
|
118
|
+
var code: int = action_name.unicode_at(index)
|
|
119
|
+
if code < 32 or action_name[index] in ["/", ":", "=", "\\", '"']:
|
|
120
|
+
return (
|
|
121
|
+
_log
|
|
122
|
+
. failure(
|
|
123
|
+
(
|
|
124
|
+
(
|
|
125
|
+
"%s is not an action name the engine's Input Map takes: a name cannot hold / : = \\ "
|
|
126
|
+
+ "or a double quote, or a control character. jump, move_left or ui_menu are names it takes."
|
|
127
|
+
)
|
|
128
|
+
% JSON.stringify(action_name)
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
)
|
|
114
132
|
_log.debug("Events: " + JSON.stringify(events))
|
|
115
133
|
_log.debug("Deadzone: " + str(deadzone))
|
|
116
134
|
|
|
@@ -130,7 +148,7 @@ func add_input_action(params: Dictionary) -> Dictionary:
|
|
|
130
148
|
return _log.failure("A key event needs keycode")
|
|
131
149
|
var key_value: int = _keycode_value(keycode)
|
|
132
150
|
if key_value == KEY_NONE:
|
|
133
|
-
return _log.failure(
|
|
151
|
+
return _log.failure(_unknown_key(keycode))
|
|
134
152
|
|
|
135
153
|
var event_config: Dictionary = {"class_name": "InputEventKey", "keycode": key_value}
|
|
136
154
|
if fields.get("ctrl", false):
|
|
@@ -229,6 +247,42 @@ static func _shaped(event: InputEvent) -> Dictionary:
|
|
|
229
247
|
return shaped
|
|
230
248
|
|
|
231
249
|
|
|
250
|
+
## The input actions project.godot holds, each with its deadzone and events in the form
|
|
251
|
+
## add_input_action answers with. An engine ui_ action is listed only when the project rebinds it,
|
|
252
|
+
## as engineAction, since the engine's own eighty are there in every project.
|
|
253
|
+
func list_input_actions(_params: Dictionary) -> Dictionary:
|
|
254
|
+
var actions: Array[Dictionary] = []
|
|
255
|
+
for info: Dictionary in ProjectSettings.get_property_list():
|
|
256
|
+
var setting: String = str(info.get("name", ""))
|
|
257
|
+
if not setting.begins_with("input/"):
|
|
258
|
+
continue
|
|
259
|
+
var engine_default: Variant = ProjectSettings.property_get_revert(setting)
|
|
260
|
+
var held: Variant = ProjectSettings.get_setting(setting)
|
|
261
|
+
if engine_default != null and held == engine_default:
|
|
262
|
+
continue
|
|
263
|
+
var action: Dictionary = held if held is Dictionary else {}
|
|
264
|
+
var events: Array[Dictionary] = []
|
|
265
|
+
var listed: Variant = action.get("events", [])
|
|
266
|
+
if listed is Array:
|
|
267
|
+
var bound: Array = listed
|
|
268
|
+
for event: Variant in bound:
|
|
269
|
+
if event is InputEvent:
|
|
270
|
+
var shaped: InputEvent = event
|
|
271
|
+
events.append(_shaped(shaped))
|
|
272
|
+
(
|
|
273
|
+
actions
|
|
274
|
+
. append(
|
|
275
|
+
{
|
|
276
|
+
"name": setting.trim_prefix("input/"),
|
|
277
|
+
"deadzone": GdhRead.as_float(action.get("deadzone", 0.5), 0.5),
|
|
278
|
+
"events": events,
|
|
279
|
+
"engineAction": engine_default != null,
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return {"actions": actions, "count": actions.size()}
|
|
284
|
+
|
|
285
|
+
|
|
232
286
|
# Remove an input action the project defines, which add_input_action had no way back from.
|
|
233
287
|
func remove_input_action(params: Dictionary) -> Dictionary:
|
|
234
288
|
var action_name: String = str(params.get("action_name", ""))
|
|
@@ -305,6 +359,24 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
|
|
|
305
359
|
|
|
306
360
|
|
|
307
361
|
# The keycode for a key name as the tool spells it, or KEY_NONE for a name the table lacks.
|
|
362
|
+
## The refusal for [param key_name], which names no key: the names nearest it, then every name taken,
|
|
363
|
+
## since a name the caller has to guess at is the same guess again.
|
|
364
|
+
static func _unknown_key(key_name: String) -> String:
|
|
365
|
+
var every: PackedStringArray = []
|
|
366
|
+
var named: PackedStringArray = []
|
|
367
|
+
for name: String in KEY_NAMES:
|
|
368
|
+
var _every: bool = every.append(name)
|
|
369
|
+
if name.length() > 1 and not (name.begins_with("F") and name.substr(1).is_valid_int()):
|
|
370
|
+
var _named: bool = named.append(name)
|
|
371
|
+
var nearest: PackedStringArray = GdhRead.nearest(key_name, every)
|
|
372
|
+
var said: String = "Unknown key: %s." % key_name
|
|
373
|
+
if nearest.size() == 1:
|
|
374
|
+
said += " The nearest key name is %s." % nearest[0]
|
|
375
|
+
elif not nearest.is_empty():
|
|
376
|
+
said += " The nearest key names are %s." % ", ".join(nearest)
|
|
377
|
+
return said + " A key is named by its letter, its digit, F1 to F12, or one of: %s." % ", ".join(named)
|
|
378
|
+
|
|
379
|
+
|
|
308
380
|
func _keycode_value(key_name: String) -> int:
|
|
309
381
|
var wanted: String = key_name.to_lower()
|
|
310
382
|
for name: String in KEY_NAMES:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
extends RefCounted
|
|
2
2
|
|
|
3
|
+
const GdhFileWalk = preload("file_walk.gd")
|
|
3
4
|
const GdhRead = preload("reading.gd")
|
|
4
5
|
const GdhLog = preload("logger.gd")
|
|
5
6
|
const GdhProjectFile = preload("project_file.gd")
|
|
@@ -125,6 +126,12 @@ func set_project_setting(params: Dictionary) -> Dictionary:
|
|
|
125
126
|
if not _converts_faithfully(final_value, wanted):
|
|
126
127
|
return _log.failure(_cannot_hold(setting_path, wanted, final_value))
|
|
127
128
|
final_value = type_convert(final_value, wanted)
|
|
129
|
+
# JSON carries every number as a float and a new setting has no declared type to fit one to, so a
|
|
130
|
+
# whole number was saved as 800.0 beside an answer saying 800.
|
|
131
|
+
if not had_value and wanted == TYPE_NIL and final_value is float:
|
|
132
|
+
var number: float = final_value
|
|
133
|
+
if number == floorf(number) and absf(number) < 9.0e15:
|
|
134
|
+
final_value = int(number)
|
|
128
135
|
|
|
129
136
|
var answer: Dictionary = {
|
|
130
137
|
"setting_path": setting_path,
|
|
@@ -141,6 +148,17 @@ func set_project_setting(params: Dictionary) -> Dictionary:
|
|
|
141
148
|
answer["unchanged"] = true
|
|
142
149
|
return answer
|
|
143
150
|
|
|
151
|
+
var near: PackedStringArray = []
|
|
152
|
+
if not had_value:
|
|
153
|
+
near = _settings_near(setting_path)
|
|
154
|
+
if not near.is_empty():
|
|
155
|
+
answer["note"] = (
|
|
156
|
+
(
|
|
157
|
+
"No setting %s existed, so this wrote a new one, which the engine reads only where"
|
|
158
|
+
+ " something asks for it by that name. The nearest %s %s."
|
|
159
|
+
)
|
|
160
|
+
% [setting_path, "setting is" if near.size() == 1 else "settings are", ", ".join(near)]
|
|
161
|
+
)
|
|
144
162
|
ProjectSettings.set_setting(setting_path, final_value)
|
|
145
163
|
var err: Error = GdhProjectFile.save()
|
|
146
164
|
if err != OK:
|
|
@@ -148,6 +166,18 @@ func set_project_setting(params: Dictionary) -> Dictionary:
|
|
|
148
166
|
return answer
|
|
149
167
|
|
|
150
168
|
|
|
169
|
+
# The settings in the same section as [param setting_path] named nearest it, for a new one that is
|
|
170
|
+
# likely a misspelt one: a setting nothing declares is written all the same, and is never read.
|
|
171
|
+
static func _settings_near(setting_path: String) -> PackedStringArray:
|
|
172
|
+
var section: String = setting_path.get_slice("/", 0) + "/"
|
|
173
|
+
var names: PackedStringArray = []
|
|
174
|
+
for info: Dictionary in ProjectSettings.get_property_list():
|
|
175
|
+
var name: String = str(info.get("name", ""))
|
|
176
|
+
if name.begins_with(section) and name != setting_path:
|
|
177
|
+
var _added: bool = names.append(name)
|
|
178
|
+
return GdhRead.nearest(setting_path, names)
|
|
179
|
+
|
|
180
|
+
|
|
151
181
|
# The type a setting wants, or TYPE_NIL where the engine names none and there is nothing to go on.
|
|
152
182
|
#
|
|
153
183
|
# JSON carries one number type, so every number arrives as a float and an int setting was written
|
|
@@ -215,7 +245,9 @@ func add_autoload(params: Dictionary) -> Dictionary:
|
|
|
215
245
|
_log.info("Adding autoload: " + name + " -> " + path)
|
|
216
246
|
|
|
217
247
|
if not FileAccess.file_exists(path):
|
|
218
|
-
return _log.failure(
|
|
248
|
+
return _log.failure(
|
|
249
|
+
GdhFileWalk.new().with_nearest_files("Autoload file does not exist: " + path, path)
|
|
250
|
+
)
|
|
219
251
|
|
|
220
252
|
# Through ProjectSettings so the file is saved the way the editor saves it, which includes
|
|
221
253
|
# putting Godot's header back and dropping any comment somebody wrote into the file by hand.
|
|
@@ -225,7 +257,17 @@ func add_autoload(params: Dictionary) -> Dictionary:
|
|
|
225
257
|
# An asterisk in front of the path makes the autoload's name a global variable, the editor's
|
|
226
258
|
# "Global Variable" column. Without it the node still comes up under the root; only the name
|
|
227
259
|
# stops resolving.
|
|
228
|
-
|
|
260
|
+
var entry: String = ("*" if global else "") + path
|
|
261
|
+
if was_updated and str(ProjectSettings.get_setting(setting)) == entry:
|
|
262
|
+
return {
|
|
263
|
+
"name": name,
|
|
264
|
+
"path": path,
|
|
265
|
+
"enabled": true,
|
|
266
|
+
"global": global,
|
|
267
|
+
"action": "unchanged",
|
|
268
|
+
"saved": false
|
|
269
|
+
}
|
|
270
|
+
ProjectSettings.set_setting(setting, entry)
|
|
229
271
|
var err: Error = GdhProjectFile.save()
|
|
230
272
|
if err != OK:
|
|
231
273
|
return _log.failure("Failed to save project.godot: " + error_string(err))
|
|
@@ -236,6 +278,7 @@ func add_autoload(params: Dictionary) -> Dictionary:
|
|
|
236
278
|
"enabled": true,
|
|
237
279
|
"global": global,
|
|
238
280
|
"action": "updated" if was_updated else "added",
|
|
281
|
+
"saved": true,
|
|
239
282
|
}
|
|
240
283
|
|
|
241
284
|
|
|
@@ -295,7 +338,20 @@ func set_main_scene(params: Dictionary) -> Dictionary:
|
|
|
295
338
|
_log.info("Setting main scene: " + scene_path)
|
|
296
339
|
|
|
297
340
|
if not FileAccess.file_exists(scene_path):
|
|
298
|
-
return _log.failure(
|
|
341
|
+
return _log.failure(
|
|
342
|
+
GdhFileWalk.new().with_nearest_files("Scene file does not exist: " + scene_path, scene_path)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# The engine starts the game by instancing the main scene, so a script or any other resource set
|
|
346
|
+
# there was saved and answered as set, and the game then would not start.
|
|
347
|
+
if not ResourceLoader.exists(scene_path, "PackedScene"):
|
|
348
|
+
var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
|
|
349
|
+
var kind: String = loaded.get_class() if loaded != null else ""
|
|
350
|
+
var said: String = (
|
|
351
|
+
"%s is %s, not a scene, and the main scene has to be one"
|
|
352
|
+
% [scene_path, "a " + kind if not kind.is_empty() else "no resource the engine loads"]
|
|
353
|
+
)
|
|
354
|
+
return _log.failure(GdhFileWalk.new().with_nearest_files(said, scene_path.get_basename() + ".tscn"))
|
|
299
355
|
|
|
300
356
|
var old_main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
|
|
301
357
|
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
extends RefCounted
|
|
2
2
|
|
|
3
|
+
const GdhDependencies = preload("dependencies.gd")
|
|
3
4
|
const GdhFileWalk = preload("file_walk.gd")
|
|
4
5
|
const GdhImportPipeline = preload("import_pipeline.gd")
|
|
5
6
|
const GdhLog = preload("logger.gd")
|
|
7
|
+
const GdhRead = preload("reading.gd")
|
|
8
|
+
const GdhScriptCheck = preload("script_check.gd")
|
|
6
9
|
|
|
7
10
|
var _log: GdhLog
|
|
8
11
|
var _files: GdhFileWalk = GdhFileWalk.new()
|
|
@@ -15,7 +18,7 @@ func _init(p_log: GdhLog) -> void:
|
|
|
15
18
|
# Get comprehensive project health check with scoring
|
|
16
19
|
func get_project_health(params: Dictionary) -> Dictionary:
|
|
17
20
|
var check_categories: Array = params.get(
|
|
18
|
-
"categories", ["structure", "resources", "scripts", "scenes", "config"]
|
|
21
|
+
"categories", ["structure", "resources", "scripts", "references", "scenes", "config"]
|
|
19
22
|
)
|
|
20
23
|
|
|
21
24
|
_log.info("Performing project health check")
|
|
@@ -87,12 +90,34 @@ func get_project_health(params: Dictionary) -> Dictionary:
|
|
|
87
90
|
if todo_count > 0:
|
|
88
91
|
recommendations.append("%d scripts have TODO or FIXME comments to look at" % todo_count)
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
# Compiled by the server before this runs, since an engine stops at the first script that
|
|
94
|
+
# fails, and handed in. Without it the check read only comments and said "passed" beside
|
|
95
|
+
# scripts the engine refused.
|
|
96
|
+
var compiled: Dictionary = params.get("compiled", {})
|
|
97
|
+
var failures: Array = compiled.get("failures", [])
|
|
98
|
+
for failure: Dictionary in failures:
|
|
99
|
+
details.append(GdhScriptCheck.failure_line(failure))
|
|
100
|
+
deductions += 10
|
|
101
|
+
|
|
102
|
+
var scripts: Dictionary = {
|
|
91
103
|
"name": "Scripts",
|
|
92
104
|
"passed": details.is_empty(),
|
|
93
105
|
"details": details,
|
|
94
106
|
"total_scripts": script_files.size()
|
|
95
107
|
}
|
|
108
|
+
if compiled.has("checked"):
|
|
109
|
+
scripts["compiled"] = GdhRead.as_int(compiled["checked"])
|
|
110
|
+
else:
|
|
111
|
+
scripts["not_compiled"] = str(compiled.get("not_checked", "no compile was asked for"))
|
|
112
|
+
checks["scripts"] = scripts
|
|
113
|
+
|
|
114
|
+
# 3b. References
|
|
115
|
+
if "references" in check_categories:
|
|
116
|
+
var details: Array[String] = []
|
|
117
|
+
for entry: Dictionary in GdhDependencies.new(_log).missing_references():
|
|
118
|
+
details.append(GdhDependencies.missing_line(entry))
|
|
119
|
+
deductions += 5
|
|
120
|
+
checks["references"] = {"name": "References", "passed": details.is_empty(), "details": details}
|
|
96
121
|
|
|
97
122
|
# 4. Scenes Check
|
|
98
123
|
if "scenes" in check_categories:
|
|
@@ -14,6 +14,7 @@ extends RefCounted
|
|
|
14
14
|
## write accepts. What is here on top of it is the part only a property knows: the type wanted, so a
|
|
15
15
|
## dictionary shaped like a vector or a bare pair of numbers can be read as one.
|
|
16
16
|
|
|
17
|
+
const GdhDeclared = preload("declared_values.gd")
|
|
17
18
|
const GdhRead = preload("reading.gd")
|
|
18
19
|
const GdhSerialisation = preload("serialisation.gd")
|
|
19
20
|
const GdhShapes = preload("value_shapes.gd")
|
|
@@ -78,6 +79,11 @@ var _refused: String = ""
|
|
|
78
79
|
# offsets. An answer naming only what was given leaves the caller to guess the rest.
|
|
79
80
|
var _also_changed: Dictionary = {}
|
|
80
81
|
|
|
82
|
+
# The values the last write_all wrote outside what their property declares, by name, as a sentence
|
|
83
|
+
# saying so: a number past an export range, or one no enum value has. The engine stores either, and
|
|
84
|
+
# the inspector would not have let it be entered, so a write answered as made said nothing of it.
|
|
85
|
+
var _outside_declared: Dictionary = {}
|
|
86
|
+
|
|
81
87
|
|
|
82
88
|
## Turns what arrived over the wire into the Godot value a property wants.
|
|
83
89
|
##
|
|
@@ -177,25 +183,38 @@ func cannot_hold(
|
|
|
177
183
|
## caller returns the refusal instead of saving.
|
|
178
184
|
func write_all(target: Object, properties: Dictionary) -> String:
|
|
179
185
|
_also_changed = {}
|
|
186
|
+
_outside_declared = {}
|
|
180
187
|
var named: Dictionary = {}
|
|
181
188
|
for key: Variant in properties:
|
|
182
189
|
named[str(key).get_slice(":", 0)] = true
|
|
183
190
|
var before: Dictionary = _stored(target, named)
|
|
184
191
|
var given: Dictionary = {}
|
|
192
|
+
var declarations: Dictionary = {}
|
|
193
|
+
# A refusal of one of several leaves the others unwritten too, which a refusal naming only the
|
|
194
|
+
# one it refused left a caller to guess.
|
|
195
|
+
var unwritten: String = (
|
|
196
|
+
"" if properties.size() < 2 else " None of the %d properties given was written." % properties.size()
|
|
197
|
+
)
|
|
185
198
|
for key: Variant in properties:
|
|
186
199
|
var property: String = str(key)
|
|
187
200
|
var declared: Dictionary = declaration(target, property)
|
|
188
201
|
if declared.is_empty() and ":" in property:
|
|
189
202
|
declared = _component_slot(target, property)
|
|
190
203
|
if declared.has("refused"):
|
|
191
|
-
return str(declared["refused"])
|
|
204
|
+
return str(declared["refused"]) + unwritten
|
|
205
|
+
if declared.is_empty() and target is ShaderMaterial and property.begins_with("shader_parameter/"):
|
|
206
|
+
return _no_uniform(target as ShaderMaterial, property) + unwritten
|
|
192
207
|
if declared.is_empty():
|
|
193
|
-
return "%s has no property %s" % [target
|
|
208
|
+
return "%s has no property %s." % [class_called(target), property] + unwritten
|
|
194
209
|
var value: Array = value_for(target, declared, properties[key])
|
|
195
210
|
if not value[0]:
|
|
196
|
-
return str(value[1])
|
|
211
|
+
return str(value[1]) + unwritten
|
|
212
|
+
var outside: String = GdhDeclared.outside(target, declared, value[1], shown(value[1]))
|
|
213
|
+
if not outside.is_empty():
|
|
214
|
+
_outside_declared[property] = outside
|
|
197
215
|
_write(target, property, value[1])
|
|
198
216
|
given[property] = value[1]
|
|
217
|
+
declarations[property] = declared
|
|
199
218
|
|
|
200
219
|
for property: String in given:
|
|
201
220
|
if not same(read_at(target, property), given[property]):
|
|
@@ -203,13 +222,8 @@ func write_all(target: Object, properties: Dictionary) -> String:
|
|
|
203
222
|
for property: String in given:
|
|
204
223
|
var held: Variant = read_at(target, property)
|
|
205
224
|
if not same(held, given[property]):
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
"%s.%s was given %s and holds %s after it was set: the engine changed or refused the "
|
|
209
|
-
+ "value, so nothing was written."
|
|
210
|
-
)
|
|
211
|
-
% [target.get_class(), property, shown(given[property]), shown(held)]
|
|
212
|
-
)
|
|
225
|
+
var declared: Dictionary = declarations[property]
|
|
226
|
+
return _not_held(target, property, given[property], held, declared)
|
|
213
227
|
for property: String in before:
|
|
214
228
|
var now: Variant = target.get(property)
|
|
215
229
|
if not same(now, before[property]):
|
|
@@ -217,11 +231,83 @@ func write_all(target: Object, properties: Dictionary) -> String:
|
|
|
217
231
|
return ""
|
|
218
232
|
|
|
219
233
|
|
|
234
|
+
## Why [param property] of [param target], declared as [param declared], holds [param held] after
|
|
235
|
+
## it was given [param value]. A text property with a list of names holds only one of them, and its
|
|
236
|
+
## getter answers its default for any other. A player's bus is one, though the engine fills its list
|
|
237
|
+
## only in the inspector, so the buses are read from the AudioServer, which holds the project's
|
|
238
|
+
## layout; "the engine changed or refused the value" left a caller to guess the bus did not exist.
|
|
239
|
+
func _not_held(
|
|
240
|
+
target: Object, property: String, value: Variant, held: Variant, declared: Dictionary
|
|
241
|
+
) -> String:
|
|
242
|
+
var said: String = (
|
|
243
|
+
"%s.%s was given %s and holds %s after it was set"
|
|
244
|
+
% [target.get_class(), property, shown(value), shown(held)]
|
|
245
|
+
)
|
|
246
|
+
var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
|
|
247
|
+
var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
|
|
248
|
+
var names: PackedStringArray = []
|
|
249
|
+
if hint == PROPERTY_HINT_ENUM and (type == TYPE_STRING or type == TYPE_STRING_NAME):
|
|
250
|
+
names = PackedStringArray(GdhDeclared.enum_values(str(declared.get("hint_string", ""))).keys())
|
|
251
|
+
var bus: bool = property == "bus" and target.get_class().begins_with("AudioStreamPlayer")
|
|
252
|
+
if bus:
|
|
253
|
+
names = []
|
|
254
|
+
for index: int in AudioServer.bus_count:
|
|
255
|
+
var _added: bool = names.append(AudioServer.get_bus_name(index))
|
|
256
|
+
if names.is_empty() or names.has(str(value)):
|
|
257
|
+
return said + ": the engine changed or refused the value, so nothing was written."
|
|
258
|
+
var close: PackedStringArray = GdhRead.nearest(str(value), names)
|
|
259
|
+
var nearest: String = (
|
|
260
|
+
"%s %s" % ["is" if close.size() == 1 else "are", ", ".join(close)] if not close.is_empty() else ""
|
|
261
|
+
)
|
|
262
|
+
if bus:
|
|
263
|
+
return (
|
|
264
|
+
(
|
|
265
|
+
"%s: it takes the name of one of the project's audio buses, and %s is none of them; %s. "
|
|
266
|
+
+ "project_info include audio_buses lists them, and project_settings add_audio_bus adds "
|
|
267
|
+
+ "one. Nothing was written."
|
|
268
|
+
)
|
|
269
|
+
% [
|
|
270
|
+
said,
|
|
271
|
+
shown(value),
|
|
272
|
+
"the buses are " + ", ".join(names) if nearest.is_empty() else "the nearest " + nearest
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
return (
|
|
276
|
+
"%s: it takes one of the names it lists, and %s is none of them; %s. Nothing was written."
|
|
277
|
+
% [
|
|
278
|
+
said,
|
|
279
|
+
shown(value),
|
|
280
|
+
"it lists " + ", ".join(names) if nearest.is_empty() else "the nearest it lists " + nearest
|
|
281
|
+
]
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
|
|
286
|
+
## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
|
|
287
|
+
## material.
|
|
288
|
+
static func _no_uniform(material: ShaderMaterial, property: String) -> String:
|
|
289
|
+
var uniform: String = property.trim_prefix("shader_parameter/")
|
|
290
|
+
if material.shader == null:
|
|
291
|
+
return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
|
|
292
|
+
var shader: String = material.shader.resource_path
|
|
293
|
+
var named: String = shader if not shader.is_empty() else "Its shader"
|
|
294
|
+
var uniforms: Array = []
|
|
295
|
+
for entry: Dictionary in material.shader.get_shader_uniform_list():
|
|
296
|
+
uniforms.append(str(entry["name"]))
|
|
297
|
+
var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
|
|
298
|
+
if uniforms.is_empty():
|
|
299
|
+
return said + "; it declares no uniforms."
|
|
300
|
+
return GdhRead.offered(said, uniform, uniforms) + "."
|
|
301
|
+
|
|
302
|
+
|
|
220
303
|
## Puts under alsoChanged in [param answer] each property the last [method write_all] changed
|
|
221
|
-
## without being given it, as {was, now},
|
|
222
|
-
##
|
|
223
|
-
##
|
|
224
|
-
|
|
304
|
+
## without being given it, as {was, now}, and under outsideDeclared each value it wrote outside what
|
|
305
|
+
## its property declares, when there are any. A node is named by its path from [param relative_to],
|
|
306
|
+
## as a scene answer names one. Called before the scene is written, since the write frees the nodes
|
|
307
|
+
## these values can hold.
|
|
308
|
+
func note_write(answer: Dictionary, relative_to: Node = null) -> void:
|
|
309
|
+
if not _outside_declared.is_empty():
|
|
310
|
+
answer["outsideDeclared"] = _outside_declared.duplicate()
|
|
225
311
|
if _also_changed.is_empty():
|
|
226
312
|
return
|
|
227
313
|
var noted: Dictionary = {}
|
|
@@ -324,6 +410,18 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
|
|
|
324
410
|
var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
|
|
325
411
|
if expected_type == TYPE_OBJECT and hint == PROPERTY_HINT_NODE_TYPE:
|
|
326
412
|
return _node_reference(target, property, raw)
|
|
413
|
+
# An enum is written by the name of a value as well as its number, since the name is what the
|
|
414
|
+
# script and the inspector show and the number is only where the value happens to sit.
|
|
415
|
+
if expected_type == TYPE_INT and hint == PROPERTY_HINT_ENUM and typeof(raw) == TYPE_STRING:
|
|
416
|
+
var choices: Dictionary = GdhDeclared.enum_of(target, declared)
|
|
417
|
+
var chosen: Variant = GdhDeclared.enum_named(choices, str(raw))
|
|
418
|
+
if chosen == null:
|
|
419
|
+
var hinted: Dictionary = GdhDeclared.enum_values(str(declared.get("hint_string", "")))
|
|
420
|
+
chosen = GdhDeclared.enum_named(hinted, str(raw))
|
|
421
|
+
if chosen != null:
|
|
422
|
+
return [true, chosen]
|
|
423
|
+
if not str(raw).strip_edges().is_valid_int():
|
|
424
|
+
return [false, GdhDeclared.enum_refusal(class_called(target), property, str(raw), choices)]
|
|
327
425
|
|
|
328
426
|
# A resource-valued property takes the path of one, which is how a caller names a TileSet,
|
|
329
427
|
# a material or a theme: there is no other way to hand a tool a Resource.
|
|
@@ -339,12 +437,12 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
|
|
|
339
437
|
return [false, _fraction_refusal(target, property, raw)]
|
|
340
438
|
var rounded: String = GdhSerialisation.inexact(raw, expected_type)
|
|
341
439
|
if not rounded.is_empty():
|
|
342
|
-
return [false, "%s.%s holds int: %s." % [target
|
|
440
|
+
return [false, "%s.%s holds int: %s." % [class_called(target), property, rounded]]
|
|
343
441
|
_refused = ""
|
|
344
442
|
value = parse(raw, expected_type, elements)
|
|
345
443
|
if not _refused.is_empty():
|
|
346
444
|
return [false, _refused]
|
|
347
|
-
var refusal: String = cannot_hold(target
|
|
445
|
+
var refusal: String = cannot_hold(class_called(target), property, value, expected_type, elements)
|
|
348
446
|
if not refusal.is_empty():
|
|
349
447
|
return [false, refusal]
|
|
350
448
|
if value is Object:
|
|
@@ -489,10 +587,43 @@ func _fraction_refusal(target: Object, property: String, raw: Variant) -> String
|
|
|
489
587
|
"%s.%s holds whole numbers, and %s would be cut to fit. Setting it would save a different "
|
|
490
588
|
+ "value than the one asked for."
|
|
491
589
|
)
|
|
492
|
-
% [target
|
|
590
|
+
% [class_called(target), property, shown(raw)]
|
|
493
591
|
)
|
|
494
592
|
|
|
495
593
|
|
|
594
|
+
## [param raw] as a colour, read as a property write reads one, as [true, Color] or [false, why]:
|
|
595
|
+
## fields from 0 to 1 with a optional, or an html code. [param named] is what the refusal calls it.
|
|
596
|
+
func colour_from(raw: Variant, named: String) -> Array:
|
|
597
|
+
_refused = ""
|
|
598
|
+
var value: Variant = parse(raw, TYPE_COLOR)
|
|
599
|
+
if not _refused.is_empty():
|
|
600
|
+
return [false, _refused]
|
|
601
|
+
if value is Color:
|
|
602
|
+
return [true, value]
|
|
603
|
+
return [
|
|
604
|
+
false,
|
|
605
|
+
(
|
|
606
|
+
(
|
|
607
|
+
'%s is {"r": .., "g": .., "b": .., "a": ..} from 0 to 1, a optional, or an html code such as '
|
|
608
|
+
+ '"#ff8000" or "#ff800080"; %s is neither.'
|
|
609
|
+
)
|
|
610
|
+
% [named, JSON.stringify(raw)]
|
|
611
|
+
)
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
## What [param target] is called in a refusal: the class_name its script declares, or else its
|
|
616
|
+
## engine class. A resource made as a project class is a Resource to the engine, and "Resource.kind"
|
|
617
|
+
## named a property no Resource has.
|
|
618
|
+
static func class_called(target: Object) -> String:
|
|
619
|
+
var attached: Variant = target.get_script()
|
|
620
|
+
if attached is Script:
|
|
621
|
+
var script: Script = attached
|
|
622
|
+
if not str(script.get_global_name()).is_empty():
|
|
623
|
+
return str(script.get_global_name())
|
|
624
|
+
return target.get_class()
|
|
625
|
+
|
|
626
|
+
|
|
496
627
|
## The resource [param path] names, as [found, resource or why not].
|
|
497
628
|
##
|
|
498
629
|
## The project boundary is enforced here as well as on the server, because only the engine knows
|
|
@@ -516,6 +647,19 @@ func _resource_at(path: String, property: String) -> Array:
|
|
|
516
647
|
)
|
|
517
648
|
]
|
|
518
649
|
if not ResourceLoader.exists(path):
|
|
650
|
+
# An image or a sound nobody has imported is a file the engine has no loader for, and "No
|
|
651
|
+
# resource at" sent a caller looking for a file that was there.
|
|
652
|
+
if path.begins_with("res://") and FileAccess.file_exists(path):
|
|
653
|
+
return [
|
|
654
|
+
false,
|
|
655
|
+
(
|
|
656
|
+
(
|
|
657
|
+
"%s is there, but it has never been imported, so the engine has no resource to load"
|
|
658
|
+
+ " from it for %s. project_import reimport with resourcePath %s imports it."
|
|
659
|
+
)
|
|
660
|
+
% [path, property, path]
|
|
661
|
+
)
|
|
662
|
+
]
|
|
519
663
|
return [false, "No resource at %s for %s" % [path, property]]
|
|
520
664
|
return [true, load(path)]
|
|
521
665
|
|
|
@@ -164,6 +164,43 @@ static func not_of_kind(
|
|
|
164
164
|
return told + " The scene's %ss are at %s." % [kind, shown]
|
|
165
165
|
|
|
166
166
|
|
|
167
|
+
## Up to three of [param names] closest to [param word], closest first, ignoring case: the names a
|
|
168
|
+
## refusal offers for one that is not there, as a typo usually is one of them.
|
|
169
|
+
static func nearest(word: String, names: PackedStringArray) -> PackedStringArray:
|
|
170
|
+
var wanted: String = word.to_lower()
|
|
171
|
+
var close: Array[String] = []
|
|
172
|
+
for name: String in names:
|
|
173
|
+
if name.to_lower().similarity(wanted) >= 0.5:
|
|
174
|
+
close.append(name)
|
|
175
|
+
close.sort_custom(
|
|
176
|
+
func(a: String, b: String) -> bool:
|
|
177
|
+
return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
|
|
178
|
+
)
|
|
179
|
+
return PackedStringArray(close.slice(0, 3))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## [param said], the refusal for [param word], which is not among [param names], with those nearest
|
|
183
|
+
## it when any are, or else every one of them, so the caller has a name to send instead.
|
|
184
|
+
static func offered(said: String, word: String, names: Array) -> String:
|
|
185
|
+
var every: PackedStringArray = PackedStringArray(names)
|
|
186
|
+
var close: PackedStringArray = nearest(word, every)
|
|
187
|
+
if close.is_empty():
|
|
188
|
+
return "%s; it has %s" % [said, ", ".join(every)]
|
|
189
|
+
return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
## The signals [param object] has that a caller can connect, its own class's first: the engine
|
|
193
|
+
## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
|
|
194
|
+
## starting with an underscore are the engine's own.
|
|
195
|
+
static func signals_of(object: Object) -> Array:
|
|
196
|
+
var names: Array = []
|
|
197
|
+
for entry: Dictionary in object.get_signal_list():
|
|
198
|
+
var name: String = str(entry["name"])
|
|
199
|
+
if not name.begins_with("_"):
|
|
200
|
+
names.push_front(name)
|
|
201
|
+
return names
|
|
202
|
+
|
|
203
|
+
|
|
167
204
|
## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
|
|
168
205
|
static func with_article(word: String) -> String:
|
|
169
206
|
return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
extends RefCounted
|
|
2
2
|
|
|
3
|
+
const GdhFileWalk = preload("file_walk.gd")
|
|
3
4
|
const GdhLog = preload("logger.gd")
|
|
4
5
|
const GdhPatterns = preload("patterns.gd")
|
|
5
6
|
|
|
@@ -29,7 +30,9 @@ func get_uid(params: Dictionary) -> Dictionary:
|
|
|
29
30
|
|
|
30
31
|
var absolute_path: String = ProjectSettings.globalize_path(file_path)
|
|
31
32
|
if not FileAccess.file_exists(file_path):
|
|
32
|
-
return _log.failure(
|
|
33
|
+
return _log.failure(
|
|
34
|
+
GdhFileWalk.new().with_nearest_files("File does not exist: " + file_path, file_path)
|
|
35
|
+
)
|
|
33
36
|
|
|
34
37
|
var answer: Dictionary = {"file": file_path, "absolute_path": absolute_path}
|
|
35
38
|
var found: Dictionary = uid_of(file_path)
|
|
@@ -54,6 +54,22 @@ func _init(p_log: GdhLog) -> void:
|
|
|
54
54
|
_log = p_log
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
## One line for [param failure], a script [method check_scripts] answered as failing, with its
|
|
58
|
+
## first error and the note the server put on it.
|
|
59
|
+
static func failure_line(failure: Dictionary) -> String:
|
|
60
|
+
var errors: Array = failure.get("errors", [])
|
|
61
|
+
var said: String = "%s does not compile" % str(failure.get("path", ""))
|
|
62
|
+
if not errors.is_empty():
|
|
63
|
+
var first: Dictionary = errors[0]
|
|
64
|
+
var line: int = GdhRead.as_int(first.get("line", 0))
|
|
65
|
+
var message: String = str(first.get("message", ""))
|
|
66
|
+
said += ": line %d, %s" % [line, message] if line > 0 else ": " + message
|
|
67
|
+
if errors.size() > 1:
|
|
68
|
+
said += " (and %d more)" % (errors.size() - 1)
|
|
69
|
+
var note: String = str(failure.get("note", ""))
|
|
70
|
+
return said if note.is_empty() else "%s %s" % [said, note]
|
|
71
|
+
|
|
72
|
+
|
|
57
73
|
## Whether the scripts in [code]paths[/code] compile in a fresh engine, which reads the class cache
|
|
58
74
|
## as it starts and so resolves every class the cache lists, up to and including the first that
|
|
59
75
|
## raised an error. [code]checked[/code] names the scripts read, in order, and [code]failed[/code]
|
|
@@ -410,6 +410,10 @@ func fitted(value: Variant, type: int) -> Variant:
|
|
|
410
410
|
var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
|
|
411
411
|
if not simple.has(type) or not simple.has(typeof(rebuilt)):
|
|
412
412
|
return rebuilt
|
|
413
|
+
# A number or a truth value stays as sent where text is wanted, for the refusal to name. Converted,
|
|
414
|
+
# 1 arrived as the double it is in JSON and was saved as "1.0", which is not the text anyone sends.
|
|
415
|
+
if type == TYPE_STRING and not rebuilt is String:
|
|
416
|
+
return rebuilt
|
|
413
417
|
|
|
414
418
|
# Digits are read as digits, since read as JSON they become a double and one past 2^53 a
|
|
415
419
|
# neighbour of the number written.
|
|
@@ -82,6 +82,13 @@ static func spare(fields: Dictionary, tag: String) -> Array:
|
|
|
82
82
|
##
|
|
83
83
|
## The refusal names only the two types, and the key is what the caller has to take out.
|
|
84
84
|
static func remedy(value: Variant, type: int) -> String:
|
|
85
|
+
if type == TYPE_STRING and typeof(value) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]:
|
|
86
|
+
var number: Variant = value
|
|
87
|
+
if value is float:
|
|
88
|
+
var whole: float = value
|
|
89
|
+
if is_finite(whole) and whole == floorf(whole):
|
|
90
|
+
number = int(whole)
|
|
91
|
+
return ' Text is sent in quotes, as "%s".' % str(number)
|
|
85
92
|
var shape: String = written_as(type)
|
|
86
93
|
if shape.is_empty():
|
|
87
94
|
return ""
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
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",
|