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
@@ -3,6 +3,15 @@ extends RefCounted
3
3
  const GdhRead = preload("reading.gd")
4
4
  const GdhLog = preload("logger.gd")
5
5
  const GdhProjectFile = preload("project_file.gd")
6
+ ## The device an event matches every device with, InputMap's ALL_DEVICES, which GDScript is not given.
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
+ )
6
15
 
7
16
  # The key names a caller may write, and what the engine calls them.
8
17
  const KEY_NAMES: Dictionary = {
@@ -108,7 +117,13 @@ func _init(p_log: GdhLog) -> void:
108
117
  func add_input_action(params: Dictionary) -> Dictionary:
109
118
  var action_name: String = str(params.get("action_name", ""))
110
119
  var events: Array = params.get("events", [])
111
- var deadzone: float = GdhRead.as_float(params.get("deadzone", 0.5), 0.5)
120
+ var setting: String = "input/" + action_name
121
+ var held: Variant = ProjectSettings.get_setting(setting) if ProjectSettings.has_setting(setting) else null
122
+ var old: Dictionary = held if held is Dictionary else {}
123
+ var old_deadzone: float = GdhRead.as_float(old.get("deadzone", 0.5), 0.5)
124
+ # A rebind that names no deadzone keeps the one the action has, which a caller adding a key did
125
+ # not mean to change.
126
+ var deadzone: float = GdhRead.as_float(params.get("deadzone", old_deadzone), old_deadzone)
112
127
 
113
128
  _log.info("Adding input action: " + action_name)
114
129
  # The editor's Input Map refuses these, since project.godot keys an action by its name and
@@ -139,47 +154,11 @@ func add_input_action(params: Dictionary) -> Dictionary:
139
154
  if not event is Dictionary:
140
155
  return _log.failure("Every event must be an object, not " + JSON.stringify(event))
141
156
  var fields: Dictionary = event
142
- var event_type: String = str(fields.get("type", ""))
143
-
144
- match event_type:
145
- "key":
146
- var keycode: String = str(fields.get("keycode", ""))
147
- if keycode.is_empty():
148
- return _log.failure("A key event needs keycode")
149
- var key_value: int = _keycode_value(keycode)
150
- if key_value == KEY_NONE:
151
- return _log.failure(_unknown_key(keycode))
152
-
153
- var event_config: Dictionary = {"class_name": "InputEventKey", "keycode": key_value}
154
- if fields.get("ctrl", false):
155
- event_config["ctrl_pressed"] = true
156
- if fields.get("alt", false):
157
- event_config["alt_pressed"] = true
158
- if fields.get("shift", false):
159
- event_config["shift_pressed"] = true
160
- events_config.append(event_config)
161
-
162
- "mouse_button":
163
- events_config.append(
164
- {"class_name": "InputEventMouseButton", "button_index": fields.get("button", 1)}
165
- )
166
-
167
- "joypad_button":
168
- events_config.append(
169
- {"class_name": "InputEventJoypadButton", "button_index": fields.get("button", 0)}
170
- )
171
-
172
- "joypad_axis":
173
- var motion: Dictionary = {
174
- "class_name": "InputEventJoypadMotion",
175
- "axis": fields.get("axis", 0),
176
- "axis_value": fields.get("axisValue", 1),
177
- }
178
- events_config.append(motion)
179
-
180
- _:
181
- var known: String = "key, mouse_button, joypad_button, joypad_axis"
182
- 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)
183
162
 
184
163
  if events_config.is_empty():
185
164
  return _log.failure("events must hold at least one event")
@@ -190,7 +169,6 @@ func add_input_action(params: Dictionary) -> Dictionary:
190
169
 
191
170
  # An action that exists is rebound, which is how one of the engine's ui_ actions is changed, so
192
171
  # what it held is answered rather than lost without a word.
193
- var setting: String = "input/" + action_name
194
172
  var answer: Dictionary = {
195
173
  "action_name": action_name,
196
174
  "events_count": events_config.size(),
@@ -199,8 +177,6 @@ func add_input_action(params: Dictionary) -> Dictionary:
199
177
  "was_new": not ProjectSettings.has_setting(setting),
200
178
  }
201
179
  if not answer["was_new"]:
202
- var held: Variant = ProjectSettings.get_setting(setting)
203
- var old: Dictionary = held if held is Dictionary else {}
204
180
  var old_events: Array[Dictionary] = []
205
181
  var listed: Variant = old.get("events", [])
206
182
  if listed is Array:
@@ -210,7 +186,7 @@ func add_input_action(params: Dictionary) -> Dictionary:
210
186
  var bound: InputEvent = event
211
187
  old_events.append(_shaped(bound))
212
188
  answer["old_events"] = old_events
213
- answer["old_deadzone"] = GdhRead.as_float(old.get("deadzone", 0.5), 0.5)
189
+ answer["old_deadzone"] = old_deadzone
214
190
 
215
191
  # Through ProjectSettings so the file is saved the way the editor saves it, header and
216
192
  # every other line kept; a ConfigFile of project.godot drops the comments on the way out.
@@ -226,9 +202,12 @@ func add_input_action(params: Dictionary) -> Dictionary:
226
202
  ## under text, which also covers a kind of event that form has no fields for.
227
203
  static func _shaped(event: InputEvent) -> Dictionary:
228
204
  var shaped: Dictionary = {"class_name": event.get_class(), "text": event.as_text()}
205
+ if event.device != ALL_DEVICES:
206
+ shaped["device"] = event.device
229
207
  if event is InputEventKey:
230
208
  var key: InputEventKey = event
231
- shaped["keycode"] = key.keycode
209
+ if key.keycode != KEY_NONE or key.physical_keycode == KEY_NONE:
210
+ shaped["keycode"] = key.keycode
232
211
  if key.physical_keycode != KEY_NONE:
233
212
  shaped["physical_keycode"] = key.physical_keycode
234
213
  for modifier: String in ["ctrl_pressed", "alt_pressed", "shift_pressed"]:
@@ -293,14 +272,28 @@ func remove_input_action(params: Dictionary) -> Dictionary:
293
272
  return _log.failure("This project has no input action named " + action_name)
294
273
  # The engine registers its own ui_ actions with a default, so clearing one from project.godot
295
274
  # puts the engine's binding back rather than removing the action.
296
- 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
+ )
297
290
  return _log.failure(
298
291
  (
299
292
  (
300
- "%s is one of the engine's own actions: removing it from project.godot restores the "
301
- + "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."
302
295
  )
303
- % action_name
296
+ % [action_name, setting]
304
297
  )
305
298
  )
306
299
  ProjectSettings.set_setting(setting, null)
@@ -328,6 +321,7 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
328
321
  "InputEventKey":
329
322
  var key: InputEventKey = InputEventKey.new()
330
323
  key.keycode = GdhRead.as_int(event.get("keycode", 0)) as Key
324
+ key.physical_keycode = GdhRead.as_int(event.get("physical_keycode", 0)) as Key
331
325
  key.ctrl_pressed = GdhRead.as_bool(event.get("ctrl_pressed", false))
332
326
  key.alt_pressed = GdhRead.as_bool(event.get("alt_pressed", false))
333
327
  key.shift_pressed = GdhRead.as_bool(event.get("shift_pressed", false))
@@ -354,6 +348,10 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
354
348
 
355
349
  _:
356
350
  return _log.failure("Unknown input event class: " + evt_class)
351
+ # Every device, as the editor's Input Map binds an event: a new event names one device, and
352
+ # an action bound to it ignored a second joypad.
353
+ var bound: InputEvent = built.back()
354
+ bound.device = ALL_DEVICES
357
355
 
358
356
  return {"deadzone": deadzone, "events": built}
359
357
 
@@ -361,6 +359,84 @@ func build_input_action(deadzone: float, events: Array) -> Dictionary:
361
359
  # The keycode for a key name as the tool spells it, or KEY_NONE for a name the table lacks.
362
360
  ## The refusal for [param key_name], which names no key: the names nearest it, then every name taken,
363
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
+
364
440
  static func _unknown_key(key_name: String) -> String:
365
441
  var every: PackedStringArray = []
366
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,89 @@ 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
+
170
+ # Takes a setting's line out of project.godot, so the engine's default holds again, or nothing at all
171
+ # for one the engine does not declare: the way back from a set, which writes a misspelt name as given.
172
+ func unset_project_setting(params: Dictionary) -> Dictionary:
173
+ var setting_path: String = str(params.get("setting", ""))
174
+ if setting_path.is_empty():
175
+ return _log.failure("setting is required")
176
+ var default_value: Variant = ProjectSettings.property_get_revert(setting_path)
177
+ var held: bool = ProjectSettings.has_setting(setting_path)
178
+ var value: Variant = ProjectSettings.get_setting(setting_path) if held else null
179
+ var answer: Dictionary = {
180
+ "setting_path": setting_path,
181
+ "default_value": _values.serialize_value(default_value) if default_value != null else null,
182
+ }
183
+ # A setting at the engine's default has no line to take out, as the engine leaves it out of the file.
184
+ if not held or (default_value != null and value == default_value):
185
+ answer["removed"] = false
186
+ answer["saved"] = false
187
+ answer["note"] = "project.godot does not set %s, so there was nothing to take out." % setting_path
188
+ return _with_what_holds(answer, setting_path)
189
+ answer["old_value"] = _values.serialize_value(value)
190
+ # The default rather than null for a setting the engine declares: null takes the declaration away
191
+ # as well, and a value at its default is left out of the file all the same.
192
+ ProjectSettings.set_setting(setting_path, default_value)
193
+ var err: Error = GdhProjectFile.save()
194
+ if err != OK:
195
+ return _log.failure("Failed to save project.godot: " + error_string(err))
196
+ answer["removed"] = true
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]
211
+ return answer
212
+
213
+
169
214
  # The settings in the same section as [param setting_path] named nearest it, for a new one that is
170
215
  # likely a misspelt one: a setting nothing declares is written all the same, and is never read.
171
216
  static func _settings_near(setting_path: String) -> PackedStringArray:
@@ -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",