gdharness 1.3.9 → 1.3.11

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 (73) hide show
  1. package/build/cli.js +4740 -3212
  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 +102 -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 +51 -1
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +77 -140
  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 +11 -22
  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 +28 -5
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +39 -20
  17. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -362
  18. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  21. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +147 -492
  24. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  25. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -3
  26. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  27. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +79 -5
  28. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  29. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +183 -37
  30. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  31. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  32. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  33. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  34. package/build/godot/addons/gdharness_runtime/reading.gd +51 -1
  35. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  36. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +185 -30
  37. package/build/godot/addons/gdharness_runtime/runtime_input.gd +86 -79
  38. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  39. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  40. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +309 -22
  41. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +163 -20
  42. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  43. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +5 -2
  44. package/build/godot/addons/gdharness_runtime/runtime_values.gd +146 -5
  45. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  46. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  47. package/build/godot/addons/gdharness_runtime/serialisation.gd +11 -22
  48. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  49. package/build/godot/operations/animation_reads.gd +115 -4
  50. package/build/godot/operations/audio_buses.gd +93 -7
  51. package/build/godot/operations/classdb_queries.gd +119 -7
  52. package/build/godot/operations/declared_values.gd +272 -0
  53. package/build/godot/operations/dependencies.gd +154 -41
  54. package/build/godot/operations/editor_tools.gd +22 -14
  55. package/build/godot/operations/export_presets.gd +602 -4
  56. package/build/godot/operations/file_reads.gd +102 -18
  57. package/build/godot/operations/gdscript_authoring.gd +11 -1
  58. package/build/godot/operations/godot_operations.gd +16 -2
  59. package/build/godot/operations/import_pipeline.gd +20 -221
  60. package/build/godot/operations/input_actions.gd +108 -47
  61. package/build/godot/operations/project_config.gd +83 -69
  62. package/build/godot/operations/project_diagnostics.gd +4 -0
  63. package/build/godot/operations/project_validation.gd +266 -0
  64. package/build/godot/operations/property_paths.gd +89 -16
  65. package/build/godot/operations/property_values.gd +180 -121
  66. package/build/godot/operations/reading.gd +51 -1
  67. package/build/godot/operations/scene_connections.gd +222 -0
  68. package/build/godot/operations/serialisation.gd +11 -22
  69. package/build/godot/operations/setting_reads.gd +121 -0
  70. package/build/godot/operations/value_shapes.gd +128 -1
  71. package/build/godot/tests/listed.gd +73 -13
  72. package/build/keeper.js +1371 -706
  73. package/package.json +1 -1
@@ -5,6 +5,13 @@ const GdhLog = preload("logger.gd")
5
5
  const GdhProjectFile = preload("project_file.gd")
6
6
  ## The device an event matches every device with, InputMap's ALL_DEVICES, which GDScript is not given.
7
7
  const ALL_DEVICES: int = -1
8
+ const MOUSE_NAMES: String = (
9
+ "1 left, 2 right, 3 middle, 4 and 5 the wheel up and down, 6 and 7 the wheel left and right,"
10
+ + " 8 and 9 the side buttons"
11
+ )
12
+ const AXIS_NAMES: String = (
13
+ "0 and 1 the left stick across and down, 2 and 3 the right stick," + " 4 and 5 the triggers"
14
+ )
8
15
 
9
16
  # The key names a caller may write, and what the engine calls them.
10
17
  const KEY_NAMES: Dictionary = {
@@ -147,49 +154,11 @@ func add_input_action(params: Dictionary) -> Dictionary:
147
154
  if not event is Dictionary:
148
155
  return _log.failure("Every event must be an object, not " + JSON.stringify(event))
149
156
  var fields: Dictionary = event
150
- var event_type: String = str(fields.get("type", ""))
151
-
152
- match event_type:
153
- "key":
154
- var keycode: String = str(fields.get("keycode", ""))
155
- if keycode.is_empty():
156
- return _log.failure("A key event needs keycode")
157
- var key_value: int = _keycode_value(keycode)
158
- if key_value == KEY_NONE:
159
- return _log.failure(_unknown_key(keycode))
160
-
161
- var event_config: Dictionary = {"class_name": "InputEventKey"}
162
- var physical: bool = GdhRead.as_bool(fields.get("physical", false))
163
- event_config["physical_keycode" if physical else "keycode"] = key_value
164
- if fields.get("ctrl", false):
165
- event_config["ctrl_pressed"] = true
166
- if fields.get("alt", false):
167
- event_config["alt_pressed"] = true
168
- if fields.get("shift", false):
169
- event_config["shift_pressed"] = true
170
- events_config.append(event_config)
171
-
172
- "mouse_button":
173
- events_config.append(
174
- {"class_name": "InputEventMouseButton", "button_index": fields.get("button", 1)}
175
- )
176
-
177
- "joypad_button":
178
- events_config.append(
179
- {"class_name": "InputEventJoypadButton", "button_index": fields.get("button", 0)}
180
- )
181
-
182
- "joypad_axis":
183
- var motion: Dictionary = {
184
- "class_name": "InputEventJoypadMotion",
185
- "axis": fields.get("axis", 0),
186
- "axis_value": fields.get("axisValue", 1),
187
- }
188
- events_config.append(motion)
189
-
190
- _:
191
- var known: String = "key, mouse_button, joypad_button, joypad_axis"
192
- return _log.failure("Unknown event type: " + event_type + ". One of " + known + ".")
157
+ var built: Variant = _event_config(fields)
158
+ if built is String:
159
+ return _log.failure(str(built))
160
+ var event_config: Dictionary = built
161
+ events_config.append(event_config)
193
162
 
194
163
  if events_config.is_empty():
195
164
  return _log.failure("events must hold at least one event")
@@ -303,14 +272,28 @@ func remove_input_action(params: Dictionary) -> Dictionary:
303
272
  return _log.failure("This project has no input action named " + action_name)
304
273
  # The engine registers its own ui_ actions with a default, so clearing one from project.godot
305
274
  # puts the engine's binding back rather than removing the action.
306
- if ProjectSettings.property_get_revert(setting) != null:
275
+ var engine_binding: Variant = ProjectSettings.property_get_revert(setting)
276
+ if engine_binding != null:
277
+ # At the engine's binding the file holds no line for it, and the refusal for a rebound one
278
+ # spoke of a binding the project had never changed.
279
+ if ProjectSettings.get_setting(setting) == engine_binding:
280
+ return _log.failure(
281
+ (
282
+ (
283
+ "%s is one of the engine's own actions, and project.godot does not rebind it, so "
284
+ + "there is nothing to remove: the engine's binding holds. Rebind it with "
285
+ + "add_input_action."
286
+ )
287
+ % action_name
288
+ )
289
+ )
307
290
  return _log.failure(
308
291
  (
309
292
  (
310
- "%s is one of the engine's own actions: removing it from project.godot restores the "
311
- + "engine's binding rather than removing it. Rebind it with add_input_action instead."
293
+ "%s is one of the engine's own actions, so it cannot be removed. project_settings "
294
+ + "unset %s puts the engine's binding back, and add_input_action rebinds it."
312
295
  )
313
- % action_name
296
+ % [action_name, setting]
314
297
  )
315
298
  )
316
299
  ProjectSettings.set_setting(setting, null)
@@ -376,6 +359,84 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
376
359
  # The keycode for a key name as the tool spells it, or KEY_NONE for a name the table lacks.
377
360
  ## The refusal for [param key_name], which names no key: the names nearest it, then every name taken,
378
361
  ## since a name the caller has to guess at is the same guess again.
362
+ ## The event [param fields] describe, as [method build_input_action] takes it, or why it is no event.
363
+ func _event_config(fields: Dictionary) -> Variant:
364
+ var event_type: String = str(fields.get("type", ""))
365
+ match event_type:
366
+ "key":
367
+ var keycode: String = str(fields.get("keycode", ""))
368
+ if keycode.is_empty():
369
+ return "A key event needs keycode"
370
+ var key_value: int = _keycode_value(keycode)
371
+ if key_value == KEY_NONE:
372
+ return _unknown_key(keycode)
373
+ var event_config: Dictionary = {"class_name": "InputEventKey"}
374
+ var physical: bool = GdhRead.as_bool(fields.get("physical", false))
375
+ event_config["physical_keycode" if physical else "keycode"] = key_value
376
+ for modifier: String in ["ctrl", "alt", "shift"]:
377
+ if GdhRead.as_bool(fields.get(modifier, false)):
378
+ event_config[modifier + "_pressed"] = true
379
+ return event_config
380
+ "mouse_button":
381
+ var mouse: Variant = _whole_in(
382
+ fields, "button", MOUSE_BUTTON_LEFT, MOUSE_BUTTON_XBUTTON2, MOUSE_NAMES
383
+ )
384
+ if mouse is String:
385
+ return "A mouse_button event " + str(mouse)
386
+ return {"class_name": "InputEventMouseButton", "button_index": mouse}
387
+ "joypad_button":
388
+ var pad: Variant = _whole_in(fields, "button", 0, JOY_BUTTON_MAX - 1, "0 is A, 1 B, 2 X, 3 Y")
389
+ if pad is String:
390
+ return "A joypad_button event " + str(pad)
391
+ return {"class_name": "InputEventJoypadButton", "button_index": pad}
392
+ "joypad_axis":
393
+ var axis: Variant = _whole_in(fields, "axis", 0, JOY_AXIS_MAX - 1, AXIS_NAMES)
394
+ if axis is String:
395
+ return "A joypad_axis event " + str(axis)
396
+ # The direction is half the binding: left on a stick and right on it are two actions, and the
397
+ # editor binds only a whole push one way, so a guessed direction or part of one is a binding
398
+ # nobody asked for.
399
+ var direction: Variant = fields.get("axisValue")
400
+ var numeric: bool = direction is int or direction is float
401
+ if not numeric or absf(GdhRead.as_float(direction)) != 1.0:
402
+ return (
403
+ "A joypad_axis event needs axisValue, -1 or 1, the way the axis is pushed: %s was given."
404
+ % _shown(direction)
405
+ )
406
+ return {
407
+ "class_name": "InputEventJoypadMotion",
408
+ "axis": axis,
409
+ "axis_value": GdhRead.as_float(direction)
410
+ }
411
+ var known: String = "key, mouse_button, joypad_button, joypad_axis"
412
+ return "Unknown event type: " + event_type + ". One of " + known + "."
413
+
414
+
415
+ ## The whole number [param fields] holds under [param key], from [param least] to [param most], or
416
+ ## the rest of a refusal saying what it takes and what [param named] each number is. A field left out
417
+ ## was given a default, a left click for a mouse event and axis 0 for a stick, and written as bound.
418
+ static func _whole_in(fields: Dictionary, key: String, least: int, most: int, named: String) -> Variant:
419
+ var held: Variant = fields.get(key)
420
+ if held is int or held is float:
421
+ var number: float = GdhRead.as_float(held)
422
+ if number == floorf(number) and number >= least and number <= most:
423
+ return int(number)
424
+ return (
425
+ "needs %s, a whole number from %d to %d (%s): %s was given." % [key, least, most, named, _shown(held)]
426
+ )
427
+
428
+
429
+ ## [param held] as the caller wrote it: JSON has one kind of number, so 10 arrives as 10.0.
430
+ static func _shown(held: Variant) -> String:
431
+ if held == null:
432
+ return "none"
433
+ if held is float:
434
+ var number: float = held
435
+ if is_finite(number) and number == floorf(number):
436
+ return str(int(number))
437
+ return JSON.stringify(held)
438
+
439
+
379
440
  static func _unknown_key(key_name: String) -> String:
380
441
  var every: PackedStringArray = []
381
442
  var named: PackedStringArray = []
@@ -5,6 +5,7 @@ const GdhRead = preload("reading.gd")
5
5
  const GdhLog = preload("logger.gd")
6
6
  const GdhProjectFile = preload("project_file.gd")
7
7
  const GdhSerialisation = preload("serialisation.gd")
8
+ const GdhSettingReads = preload("setting_reads.gd")
8
9
  const GdhShapes = preload("value_shapes.gd")
9
10
 
10
11
  var _log: GdhLog
@@ -19,7 +20,8 @@ func _init(p_log: GdhLog) -> void:
19
20
  func get_project_setting(params: Dictionary) -> Dictionary:
20
21
  var prefix: String = str(params.get("prefix", ""))
21
22
  if not prefix.is_empty():
22
- return _settings_under(prefix)
23
+ _log.info("Listing project settings under: " + prefix)
24
+ return GdhSettingReads.under(prefix, _values)
23
25
 
24
26
  var setting_path: String = str(params.get("setting", ""))
25
27
  if setting_path.is_empty():
@@ -27,73 +29,7 @@ func get_project_setting(params: Dictionary) -> Dictionary:
27
29
 
28
30
  _log.info("Getting project setting: " + setting_path)
29
31
 
30
- var result: Dictionary = {
31
- "setting_path": setting_path, "exists": ProjectSettings.has_setting(setting_path)
32
- }
33
-
34
- if result["exists"]:
35
- var value: Variant = ProjectSettings.get_setting(setting_path)
36
- result["value"] = _values.serialize_value(value)
37
- # A setting the engine registers exists whether or not the project sets it, so exists did
38
- # not say whether project.godot has a line for it. At its default it has none: the engine
39
- # drops such a key when it saves.
40
- var default_value: Variant = ProjectSettings.property_get_revert(setting_path)
41
- if default_value != null:
42
- result["at_default"] = value == default_value
43
- else:
44
- result["value"] = null
45
- result["message"] = "Setting does not exist"
46
-
47
- # The value under this name is not the one every build reads: `name.windows` replaces it on
48
- # Windows, `name.editor` in the editor. Read alone, a caller saw the plain value and not the one
49
- # their game showed.
50
- var overrides: Dictionary = {}
51
- for info: Dictionary in ProjectSettings.get_property_list():
52
- var name: String = str(info.get("name", ""))
53
- if name.begins_with(setting_path + "."):
54
- overrides[name.substr(setting_path.length() + 1)] = _values.serialize_value(
55
- ProjectSettings.get_setting(name)
56
- )
57
- if not overrides.is_empty():
58
- result["overrides"] = overrides
59
-
60
- return result
61
-
62
-
63
- # Every setting whose name starts with [prefix], with the type the engine registers for each.
64
- #
65
- # A caller after a whole family, "everything under debug/gdscript/warnings", had no way to ask: a get
66
- # takes one name, and the engine is the only thing that knows what the family contains. The way round
67
- # it was to walk ProjectSettings.get_property_list() from a script written for the purpose, which is
68
- # an engine start to answer a question the engine was already open for.
69
- #
70
- # The type is here because it is the half a name hides. A setting sitting among a family of levels
71
- # can be a bool, and a caller that assumes otherwise writes a level over it and sees nothing go
72
- # wrong: 2 is as true as true is. Reading it out of the property list is how that is caught.
73
- func _settings_under(prefix: String) -> Dictionary:
74
- _log.info("Listing project settings under: " + prefix)
75
-
76
- var found: Array[Dictionary] = []
77
- for property: Dictionary in ProjectSettings.get_property_list():
78
- var name: String = str(property.get("name", ""))
79
- if not name.begins_with(prefix):
80
- continue
81
- var kind: int = GdhRead.as_int(property.get("type", TYPE_NIL), TYPE_NIL)
82
- var value: Variant = ProjectSettings.get_setting(name)
83
- var described: Dictionary = {
84
- "setting": name,
85
- "type": type_string(kind),
86
- "value": _values.serialize_value(value),
87
- }
88
- # As a single get says it, so a family read can be told apart into what the project sets
89
- # and the engine's defaults: "input/" is ninety actions the engine ships and the project's few.
90
- var default_value: Variant = ProjectSettings.property_get_revert(name)
91
- if default_value != null:
92
- described["at_default"] = value == default_value
93
- found.append(described)
94
-
95
- found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["setting"] < b["setting"])
96
- return {"prefix": prefix, "count": found.size(), "settings": found}
32
+ return GdhSettingReads.one(setting_path, _values)
97
33
 
98
34
 
99
35
  # Set a project setting value
@@ -146,6 +82,14 @@ func set_project_setting(params: Dictionary) -> Dictionary:
146
82
  if had_value and typeof(old_value) == typeof(final_value) and old_value == final_value:
147
83
  answer["saved"] = false
148
84
  answer["unchanged"] = true
85
+ if was_default:
86
+ answer["note"] = (
87
+ (
88
+ "%s is already this setting's default, which project.godot holds no line for:"
89
+ + " the engine reads it wherever %s is asked for, so nothing was written."
90
+ )
91
+ % [JSON.stringify(_values.serialize_value(final_value)), setting_path]
92
+ )
149
93
  return answer
150
94
 
151
95
  var near: PackedStringArray = []
@@ -159,6 +103,24 @@ func set_project_setting(params: Dictionary) -> Dictionary:
159
103
  )
160
104
  % [setting_path, "setting is" if near.size() == 1 else "settings are", ", ".join(near)]
161
105
  )
106
+ # Written as given, since a file can be made after the setting names it, as an import makes a
107
+ # translation; said, since until then the engine complains of it on every boot.
108
+ var missing: Array[String] = _missing_files(final_value)
109
+ if not missing.is_empty():
110
+ answer["missing_files"] = missing
111
+ var said: String = (
112
+ "%s %s not exist, and the engine reports %s on every boot until %s"
113
+ % [
114
+ ", ".join(missing),
115
+ "does" if missing.size() == 1 else "do",
116
+ "it" if missing.size() == 1 else "them",
117
+ "it does" if missing.size() == 1 else "they do",
118
+ ]
119
+ )
120
+ var made: Array[String] = _translations_made_by(missing)
121
+ if not made.is_empty():
122
+ said += ("; project_import reimport makes the translation files from " + ", ".join(made))
123
+ answer["note"] = (str(answer["note"]) + " " if answer.has("note") else "") + said + "."
162
124
  ProjectSettings.set_setting(setting_path, final_value)
163
125
  var err: Error = GdhProjectFile.save()
164
126
  if err != OK:
@@ -166,6 +128,45 @@ func set_project_setting(params: Dictionary) -> Dictionary:
166
128
  return answer
167
129
 
168
130
 
131
+ ## The res:// files [param value] names that are not on disk: a path, an autoload's starred path, or
132
+ ## a list or dictionary of them.
133
+ static func _missing_files(value: Variant) -> Array[String]:
134
+ var items: Array = []
135
+ if value is String or value is StringName:
136
+ items = [value]
137
+ elif value is PackedStringArray:
138
+ var strings: PackedStringArray = value
139
+ items = Array(strings)
140
+ elif value is Array:
141
+ items = value
142
+ elif value is Dictionary:
143
+ var held: Dictionary = value
144
+ items = held.values()
145
+ var missing: Array[String] = []
146
+ for item: Variant in items:
147
+ if not (item is String or item is StringName):
148
+ continue
149
+ var path: String = str(item).trim_prefix("*")
150
+ if not path.begins_with("res://"):
151
+ continue
152
+ if not FileAccess.file_exists(path) and not DirAccess.dir_exists_absolute(path):
153
+ missing.append(path)
154
+ return missing
155
+
156
+
157
+ ## The CSV files an import makes the translations among [param missing] from: strings.en.translation
158
+ ## from strings.csv beside it.
159
+ static func _translations_made_by(missing: Array[String]) -> Array[String]:
160
+ var sources: Array[String] = []
161
+ for path: String in missing:
162
+ if path.get_extension() != "translation":
163
+ continue
164
+ var csv: String = path.get_basename().get_basename() + ".csv"
165
+ if FileAccess.file_exists(csv) and not csv in sources:
166
+ sources.append(csv)
167
+ return sources
168
+
169
+
169
170
  # Takes a setting's line out of project.godot, so the engine's default holds again, or nothing at all
170
171
  # for one the engine does not declare: the way back from a set, which writes a misspelt name as given.
171
172
  func unset_project_setting(params: Dictionary) -> Dictionary:
@@ -184,7 +185,7 @@ func unset_project_setting(params: Dictionary) -> Dictionary:
184
185
  answer["removed"] = false
185
186
  answer["saved"] = false
186
187
  answer["note"] = "project.godot does not set %s, so there was nothing to take out." % setting_path
187
- return answer
188
+ return _with_what_holds(answer, setting_path)
188
189
  answer["old_value"] = _values.serialize_value(value)
189
190
  # The default rather than null for a setting the engine declares: null takes the declaration away
190
191
  # as well, and a value at its default is left out of the file all the same.
@@ -194,6 +195,19 @@ func unset_project_setting(params: Dictionary) -> Dictionary:
194
195
  return _log.failure("Failed to save project.godot: " + error_string(err))
195
196
  answer["removed"] = true
196
197
  answer["saved"] = true
198
+ return _with_what_holds(answer, setting_path)
199
+
200
+
201
+ # An override has no default of its own: with it gone, a build with its feature reads the base
202
+ # setting, and default_value, null, said nothing of that.
203
+ func _with_what_holds(answer: Dictionary, setting_path: String) -> Dictionary:
204
+ var base: String = GdhSettingReads.override_base(setting_path)
205
+ if base.is_empty() or not ProjectSettings.has_setting(base):
206
+ return answer
207
+ answer["override_of"] = base
208
+ answer["holds"] = _values.serialize_value(ProjectSettings.get_setting(base))
209
+ var said: String = GdhSettingReads.overridden_by_none(setting_path, _values)
210
+ answer["note"] = said if not answer.has("note") else "%s %s" % [answer["note"], said]
197
211
  return answer
198
212
 
199
213
 
@@ -5,6 +5,7 @@ const GdhFileWalk = preload("file_walk.gd")
5
5
  const GdhImportPipeline = preload("import_pipeline.gd")
6
6
  const GdhLog = preload("logger.gd")
7
7
  const GdhRead = preload("reading.gd")
8
+ const GdhSceneConnections = preload("scene_connections.gd")
8
9
  const GdhScriptCheck = preload("script_check.gd")
9
10
 
10
11
  var _log: GdhLog
@@ -129,6 +130,9 @@ func get_project_health(params: Dictionary) -> Dictionary:
129
130
  if scene_files.size() == 0:
130
131
  details.append("No scene files found in project")
131
132
  deductions += 5
133
+ for entry: Dictionary in GdhSceneConnections.new(_log).broken():
134
+ details.append(GdhSceneConnections.broken_line(entry))
135
+ deductions += 5
132
136
 
133
137
  checks["scenes"] = {
134
138
  "name": "Scenes",
@@ -0,0 +1,266 @@
1
+ extends RefCounted
2
+
3
+ ## Whether a project is ready to run and export: its settings, main scene, imports, presets,
4
+ ## scripts, references and signal connections, each named under checks_performed.
5
+
6
+ const GdhDependencies = preload("dependencies.gd")
7
+ const GdhExportPresets = preload("export_presets.gd")
8
+ const GdhFileWalk = preload("file_walk.gd")
9
+ const GdhImportPipeline = preload("import_pipeline.gd")
10
+ const GdhLog = preload("logger.gd")
11
+ const GdhRead = preload("reading.gd")
12
+ const GdhSceneConnections = preload("scene_connections.gd")
13
+ const GdhScriptCheck = preload("script_check.gd")
14
+ const CONNECTION_FIX: String = (
15
+ "Write the method or signal, or point the connection at the one that replaced it with" + " scene_signal"
16
+ )
17
+
18
+ var _log: GdhLog
19
+ var _files: GdhFileWalk = GdhFileWalk.new()
20
+
21
+
22
+ func _init(p_log: GdhLog) -> void:
23
+ _log = p_log
24
+
25
+
26
+ func validate_project(params: Dictionary) -> Dictionary:
27
+ var preset_name: String = str(params.get("preset", ""))
28
+ var include_suggestions: bool = GdhRead.as_bool(params.get("include_suggestions", true), true)
29
+
30
+ _log.info("Validating project" + (" for preset: " + preset_name if not preset_name.is_empty() else ""))
31
+
32
+ var issues: Array[Dictionary] = []
33
+ var warnings: Array[Dictionary] = []
34
+ var checks_performed: Array[String] = []
35
+
36
+ checks_performed.append("project_file")
37
+ if not FileAccess.file_exists("res://project.godot"):
38
+ issues.append(
39
+ _finding(
40
+ "error",
41
+ "project_file",
42
+ "project.godot not found",
43
+ "Ensure you are running this from a valid Godot project directory",
44
+ include_suggestions
45
+ )
46
+ )
47
+
48
+ checks_performed.append("main_scene")
49
+ var main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
50
+ if main_scene.is_empty():
51
+ issues.append(
52
+ _finding(
53
+ "error",
54
+ "main_scene",
55
+ "No main scene set",
56
+ "Set a main scene in Project Settings > Application > Run > Main Scene",
57
+ include_suggestions
58
+ )
59
+ )
60
+ elif not GdhFileWalk.missing_reason(main_scene).is_empty():
61
+ issues.append(
62
+ _finding(
63
+ "error",
64
+ "main_scene",
65
+ "Main scene " + GdhFileWalk.missing_reason(main_scene),
66
+ "Update the main scene setting or create the missing scene file",
67
+ include_suggestions
68
+ )
69
+ )
70
+
71
+ # A file never imported is on disk, so the reference checks pass it, and still nothing loads it:
72
+ # a scene using one fails to load in a run or an export.
73
+ checks_performed.append("imports")
74
+ var never: Array[String] = []
75
+ for path: String in _files.find_files_with_extensions("res://", GdhImportPipeline.IMPORTABLE_EXTENSIONS):
76
+ if not FileAccess.file_exists(path + ".import"):
77
+ never.append(path)
78
+ if not never.is_empty():
79
+ never.sort()
80
+ warnings.append(
81
+ _finding(
82
+ "warning",
83
+ "imports",
84
+ GdhImportPipeline.never_imported_line(never),
85
+ "Run project_import reimport, or open the project in the editor, whose scan imports them",
86
+ include_suggestions
87
+ )
88
+ )
89
+
90
+ checks_performed.append("export_presets")
91
+ if not FileAccess.file_exists("res://export_presets.cfg"):
92
+ warnings.append(
93
+ _finding(
94
+ "warning",
95
+ "export_presets",
96
+ "No export presets configured",
97
+ "Add a preset with project_export add_preset, or in the editor: Project > Export",
98
+ include_suggestions
99
+ )
100
+ )
101
+
102
+ # The preset asked about is looked for rather than only logged: validating "for" a preset that
103
+ # does not exist answered valid, as though it had been checked.
104
+ if not preset_name.is_empty():
105
+ checks_performed.append("export_preset")
106
+ var named: Array[String] = GdhExportPresets.names()
107
+ if preset_name not in named:
108
+ issues.append(
109
+ _finding(
110
+ "error",
111
+ "export_preset",
112
+ (
113
+ "No export preset is named "
114
+ + preset_name
115
+ + (
116
+ ": the project has none"
117
+ if named.is_empty()
118
+ else ": the presets are " + ", ".join(named)
119
+ )
120
+ ),
121
+ "Pass one of the preset names, or add the preset with project_export add_preset",
122
+ include_suggestions
123
+ )
124
+ )
125
+ else:
126
+ var gone: Array[String] = GdhExportPresets.missing_named(preset_name)
127
+ if not gone.is_empty():
128
+ warnings.append(
129
+ _finding(
130
+ "warning",
131
+ "export_preset_files",
132
+ "The preset %s names files that are not there: %s" % [preset_name, "; ".join(gone)],
133
+ "Add the files, or point the options at others with project_export set_preset",
134
+ include_suggestions
135
+ )
136
+ )
137
+
138
+ checks_performed.append("icon")
139
+ var icon_path: String = str(ProjectSettings.get_setting("application/config/icon", ""))
140
+ if icon_path.is_empty():
141
+ warnings.append(
142
+ _finding(
143
+ "warning",
144
+ "icon",
145
+ "No application icon set",
146
+ "Set an icon in Project Settings > Application > Config > Icon",
147
+ include_suggestions
148
+ )
149
+ )
150
+ elif not FileAccess.file_exists(icon_path):
151
+ warnings.append(
152
+ _finding(
153
+ "warning",
154
+ "icon",
155
+ "Icon file does not exist: " + icon_path,
156
+ "Update the icon path or add the missing icon file",
157
+ include_suggestions
158
+ )
159
+ )
160
+
161
+ checks_performed.append("project_name")
162
+ var project_name: String = str(ProjectSettings.get_setting("application/config/name", ""))
163
+ if project_name.is_empty():
164
+ warnings.append(
165
+ _finding(
166
+ "warning",
167
+ "project_name",
168
+ "No project name set",
169
+ "Set a project name in Project Settings > Application > Config > Name",
170
+ include_suggestions
171
+ )
172
+ )
173
+
174
+ checks_performed.append("scripts")
175
+ var script_files: Array[String] = _files.find_files_with_extensions("res://", ["gd"])
176
+ var scripts_checked: int = 0
177
+ var script_issues: Array[Dictionary] = []
178
+
179
+ # A hundred scripts is enough to say whether the project is tidy without a large one
180
+ # turning validation into a full read of its source tree.
181
+ for script_path: String in script_files.slice(0, 100):
182
+ scripts_checked += 1
183
+ var file: FileAccess = FileAccess.open(script_path, FileAccess.READ)
184
+ if file:
185
+ var content: String = file.get_as_text()
186
+ file.close()
187
+
188
+ if "# TODO" in content or "# FIXME" in content:
189
+ script_issues.append({"path": script_path, "issue": "Contains TODO/FIXME comments"})
190
+ if "pass # TODO" in content:
191
+ script_issues.append({"path": script_path, "issue": "Contains unimplemented functions"})
192
+
193
+ # Compiled by the server and handed in, as for the health check: a script the engine refuses
194
+ # stops the export's game at the first scene using it, which the comment check above never saw.
195
+ var compiled: Dictionary = params.get("compiled", {})
196
+ if compiled.has("checked"):
197
+ checks_performed.append("compiles")
198
+ var failures: Array = compiled.get("failures", [])
199
+ for failure: Dictionary in failures:
200
+ issues.append(
201
+ _finding(
202
+ "error",
203
+ "compiles",
204
+ GdhScriptCheck.failure_line(failure),
205
+ "script_diagnostics gives every error in it with its line",
206
+ include_suggestions
207
+ )
208
+ )
209
+
210
+ checks_performed.append("references")
211
+ for entry: Dictionary in GdhDependencies.new(_log).missing_references():
212
+ issues.append(
213
+ _finding(
214
+ "error",
215
+ "references",
216
+ GdhDependencies.missing_line(entry),
217
+ "Restore the file, or point the reference at the file that replaced it",
218
+ include_suggestions
219
+ )
220
+ )
221
+
222
+ # A connection whose method or signal is not there loads without a word and fails only once the
223
+ # game runs, on every emission or not at all; the references check sees files, not members.
224
+ checks_performed.append("connections")
225
+ for entry: Dictionary in GdhSceneConnections.new(_log).broken():
226
+ warnings.append(
227
+ _finding(
228
+ "warning",
229
+ "connections",
230
+ GdhSceneConnections.broken_line(entry),
231
+ CONNECTION_FIX,
232
+ include_suggestions
233
+ )
234
+ )
235
+
236
+ if script_issues.size() > 0:
237
+ var warning: Dictionary = _finding(
238
+ "warning",
239
+ "scripts",
240
+ str(script_issues.size()) + " script issues found",
241
+ "Review and resolve TODO/FIXME items before release",
242
+ include_suggestions
243
+ )
244
+ warning["details"] = script_issues.slice(0, 5)
245
+ warnings.append(warning)
246
+
247
+ return {
248
+ "valid": issues.is_empty(),
249
+ "issues": issues,
250
+ "warnings": warnings,
251
+ "checks_performed": checks_performed,
252
+ "scripts_checked": scripts_checked,
253
+ # Said beside the count, so a project of four hundred scripts is not read as checked whole.
254
+ "scripts_total": script_files.size(),
255
+ "issue_count": issues.size(),
256
+ "warning_count": warnings.size()
257
+ }
258
+
259
+
260
+ func _finding(
261
+ type: String, check: String, message: String, suggestion: String, include_suggestion: bool
262
+ ) -> Dictionary:
263
+ var finding: Dictionary = {"type": type, "check": check, "message": message}
264
+ if include_suggestion:
265
+ finding["suggestion"] = suggestion
266
+ return finding