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
@@ -193,7 +193,13 @@ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray
193
193
  func(a: String, b: String) -> bool:
194
194
  return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
195
195
  )
196
- return PackedStringArray(close.slice(0, 3))
196
+ # Every name that only adds to the word stays, since cutting at three named three corners of
197
+ # corner_radius and left out the fourth as if it were not there.
198
+ var named: PackedStringArray = PackedStringArray(close.slice(0, 3))
199
+ for name: String in close.slice(3):
200
+ if name.to_lower().begins_with(wanted):
201
+ var _added: bool = named.append(name)
202
+ return named
197
203
 
198
204
 
199
205
  ## [param said], the refusal for [param word], which is not among [param names], with those nearest
@@ -216,6 +222,10 @@ static func near_property(said: String, target: Object, property: String) -> Str
216
222
  var scripted: Array[String] = []
217
223
  for declared: Dictionary in target.get_property_list():
218
224
  var usage: int = as_int(declared.get("usage", 0))
225
+ if usage & PROPERTY_USAGE_ARRAY:
226
+ var listed: String = _list_called(target, declared, property)
227
+ if not listed.is_empty():
228
+ return "%s; %s" % [said, listed]
219
229
  if not usage & headings:
220
230
  names.append(str(declared.get("name", "")))
221
231
  if usage & PROPERTY_USAGE_SCRIPT_VARIABLE and not usage & headings:
@@ -229,6 +239,46 @@ static func near_property(said: String, target: Object, property: String) -> Str
229
239
  return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
230
240
 
231
241
 
242
+ ## How [param target] keeps the list [param property] names, when that is the label of one of the
243
+ ## engine's numbered lists, such as an OptionButton's Items; empty when it is not. The engine has no
244
+ ## property by that name, only a count and each entry's fields under a numbered prefix, so "items"
245
+ ## was refused with nothing near it to offer.
246
+ static func _list_called(target: Object, count: Dictionary, property: String) -> String:
247
+ var parts: PackedStringArray = str(count.get("class_name", "")).split(",")
248
+ if parts.size() != 2 or _bare(parts[0]) != _bare(property):
249
+ return ""
250
+ var counted: String = str(count.get("name", ""))
251
+ var first: String = parts[1] + "0/"
252
+ var fields: PackedStringArray = _fields_at(target, first)
253
+ if fields.is_empty() and ClassDB.can_instantiate(target.get_class()):
254
+ var fresh: Object = ClassDB.instantiate(target.get_class())
255
+ fresh.set(counted, 1)
256
+ fields = _fields_at(fresh, first)
257
+ if not fresh is RefCounted:
258
+ fresh.free()
259
+ var each: String = "each one's fields" if fields.is_empty() else "each one's %s" % ", ".join(fields)
260
+ return (
261
+ (
262
+ "it keeps its %s as %s, how many there are, and %s under %s for the first, %s for the second and so on: "
263
+ + "give %s before the fields of the entries it makes"
264
+ )
265
+ % [parts[0].to_lower(), counted, each, first, parts[1] + "1/", counted]
266
+ )
267
+
268
+
269
+ static func _fields_at(target: Object, prefix: String) -> PackedStringArray:
270
+ var fields: PackedStringArray = PackedStringArray()
271
+ for declared: Dictionary in target.get_property_list():
272
+ var name: String = str(declared.get("name", ""))
273
+ if name.begins_with(prefix):
274
+ var _added: bool = fields.append(name.trim_prefix(prefix))
275
+ return fields
276
+
277
+
278
+ static func _bare(word: String) -> String:
279
+ return word.to_lower().replace("_", "").replace(" ", "")
280
+
281
+
232
282
  ## The signals [param object] has that a caller can connect, its own class's first: the engine
233
283
  ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
234
284
  ## starting with an underscore are the engine's own.
@@ -0,0 +1,222 @@
1
+ extends RefCounted
2
+
3
+ ## The signal connections the project's scenes hold that cannot work: a method the node it calls
4
+ ## does not have, which fails on every emission, or a signal the node it leaves does not have, which
5
+ ## the engine drops as the scene loads. Read from the text of each scene and script, so nothing is
6
+ ## loaded and no script runs: a scene loaded to be checked loads every texture and mesh it uses, and
7
+ ## a script loaded that does not compile prints its errors here as well as where the scripts check
8
+ ## names it.
9
+ ##
10
+ ## A node whose type or script the text does not settle (a built-in script, a C# one, a child of an
11
+ ## instance the scene does not list) is not judged, since nothing here could say it is wrong.
12
+
13
+ const GdhDependencies = preload("dependencies.gd")
14
+ const GdhFileWalk = preload("file_walk.gd")
15
+ const GdhLog = preload("logger.gd")
16
+
17
+ const EXT_RESOURCE: String = '^\\[ext_resource [^\\]]*?path="([^"]+)"[^\\]]*? id="([^"]+)"'
18
+ const NODE: String = "^\\[node ([^\\]]*)\\]"
19
+ const CONNECTION: String = "^\\[connection ([^\\]]*)\\]"
20
+ const ATTRIBUTE: String = '(\\w+)="([^"]*)"'
21
+ const INSTANCE: String = 'instance=ExtResource\\(\\s*"([^"]+)"\\s*\\)'
22
+ const SCRIPT_LINE: String = '^script = (Ext|Sub)Resource\\(\\s*"([^"]+)"\\s*\\)'
23
+ const FUNC_LINE: String = "^(?:static\\s+)?func\\s+(\\w+)"
24
+ const SIGNAL_LINE: String = "^signal\\s+(\\w+)"
25
+ const EXTENDS_LINE: String = "^(?:@\\w+\\s+)*(?:class_name\\s+\\w+\\s+)?extends\\s+([^\\s:#]+)"
26
+
27
+ var _files: GdhFileWalk = GdhFileWalk.new()
28
+ ## What each script declares, by path: {class, methods, signals}, or {} when it cannot be read.
29
+ var _scripts: Dictionary = {}
30
+ ## What each scene's root is, by path: {class, script}, as a node instancing it is.
31
+ var _roots: Dictionary = {}
32
+ ## The class each class_name names, read from the scripts rather than the class cache, which only a
33
+ ## scan rewrites.
34
+ var _globals: Dictionary = {}
35
+
36
+
37
+ func _init(p_log: GdhLog) -> void:
38
+ _globals = GdhDependencies.new(p_log).classes_declared()
39
+
40
+
41
+ ## Every connection that cannot work, as {scene, from, signal, to, method, missing}, where missing is
42
+ ## "method" or "signal".
43
+ func broken() -> Array[Dictionary]:
44
+ var found: Array[Dictionary] = []
45
+ for scene: String in _files.find_files_with_extensions("res://", ["tscn"]):
46
+ var content: String = FileAccess.get_file_as_string(scene)
47
+ var nodes: Dictionary = _nodes_of(content, scene)
48
+ for attributes: Dictionary in _matches(CONNECTION, content):
49
+ var from: String = str(attributes.get("from", ""))
50
+ var to: String = str(attributes.get("to", ""))
51
+ var signal_name: String = str(attributes.get("signal", ""))
52
+ var method: String = str(attributes.get("method", ""))
53
+ # The root by its name, since its path, ".", runs into the member's dot.
54
+ var root: Dictionary = nodes.get(".", {})
55
+ var root_name: String = str(root.get("name", "."))
56
+ var entry: Dictionary = {
57
+ "scene": scene,
58
+ "from": root_name if from == "." else from,
59
+ "signal": signal_name,
60
+ "to": root_name if to == "." else to,
61
+ "method": method,
62
+ }
63
+ if _lacks(nodes, from, signal_name, "signals"):
64
+ entry["missing"] = "signal"
65
+ found.append(entry)
66
+ elif _lacks(nodes, to, method, "methods"):
67
+ entry["missing"] = "method"
68
+ found.append(entry)
69
+ return found
70
+
71
+
72
+ ## One line for [param entry], one of [method broken].
73
+ static func broken_line(entry: Dictionary) -> String:
74
+ var connected: String = (
75
+ "%s connects %s.%s to %s.%s"
76
+ % [entry["scene"], entry["from"], entry["signal"], entry["to"], entry["method"]]
77
+ )
78
+ if entry["missing"] == "signal":
79
+ return connected + ", a signal that node does not have, so the engine drops it as the scene loads."
80
+ return connected + ", a method that node does not have, so every emission fails to call it."
81
+
82
+
83
+ ## Each node [param content] declares, by its path from the root, as {class, script}, either of
84
+ ## which may be "" when the text does not say.
85
+ func _nodes_of(content: String, scene: String) -> Dictionary:
86
+ var resources: Dictionary = {}
87
+ var expression: RegEx = RegEx.create_from_string(EXT_RESOURCE)
88
+ for line: String in content.split("\n"):
89
+ var matched: RegExMatch = expression.search(line)
90
+ if matched != null:
91
+ resources[matched.get_string(2)] = matched.get_string(1)
92
+ var nodes: Dictionary = {}
93
+ var node_line: RegEx = RegEx.create_from_string(NODE)
94
+ var script_line: RegEx = RegEx.create_from_string(SCRIPT_LINE)
95
+ var instance: RegEx = RegEx.create_from_string(INSTANCE)
96
+ var current: String = ""
97
+ for line: String in content.split("\n"):
98
+ var heading: RegExMatch = node_line.search(line)
99
+ if heading != null:
100
+ var attributes: Dictionary = _attributes(heading.get_string(1))
101
+ var name: String = str(attributes.get("name", ""))
102
+ var parent: Variant = attributes.get("parent")
103
+ current = "." if parent == null else (name if parent == "." else "%s/%s" % [parent, name])
104
+ var node: Dictionary = {"class": str(attributes.get("type", "")), "script": "", "name": name}
105
+ var instanced: RegExMatch = instance.search(heading.get_string(1))
106
+ if instanced != null:
107
+ var root: Dictionary = _root_of(str(resources.get(instanced.get_string(1), "")), scene)
108
+ node = root.duplicate()
109
+ node["name"] = name
110
+ nodes[current] = node
111
+ continue
112
+ if line.begins_with("["):
113
+ current = ""
114
+ continue
115
+ var scripted: RegExMatch = script_line.search(line)
116
+ if scripted != null and nodes.has(current):
117
+ var node: Dictionary = nodes[current]
118
+ # A built-in script lives in the scene, where this does not read it, so its node is one
119
+ # nothing here can judge, which a path that is no script file says.
120
+ var external: bool = scripted.get_string(1) == "Ext"
121
+ node["script"] = str(resources.get(scripted.get_string(2), "?")) if external else "built-in"
122
+ return nodes
123
+
124
+
125
+ ## What the root of the scene at [param path] is, as [method _nodes_of] gives a node, or a node that
126
+ ## says nothing when it cannot be read or instances [param from], which would never end.
127
+ func _root_of(path: String, from: String) -> Dictionary:
128
+ if _roots.has(path):
129
+ return _roots[path]
130
+ _roots[path] = {"class": "", "script": ""}
131
+ if path.is_empty() or path == from or not path.ends_with(".tscn") or not FileAccess.file_exists(path):
132
+ return _roots[path]
133
+ var nodes: Dictionary = _nodes_of(FileAccess.get_file_as_string(path), path)
134
+ _roots[path] = nodes.get(".", {"class": "", "script": ""})
135
+ return _roots[path]
136
+
137
+
138
+ ## Whether the node at [param path] among [param nodes] surely has no [param member] among its
139
+ ## [param kind], "methods" or "signals": false whenever what it is cannot be read.
140
+ func _lacks(nodes: Dictionary, path: String, member: String, kind: String) -> bool:
141
+ if not nodes.has(path):
142
+ return false
143
+ var node: Dictionary = nodes[path]
144
+ var engine_class: String = str(node["class"])
145
+ var script: String = str(node["script"])
146
+ if not script.is_empty():
147
+ var declared: Dictionary = _script(script, {})
148
+ if declared.is_empty():
149
+ return false
150
+ var declared_members: Dictionary = declared[kind]
151
+ if declared_members.has(member):
152
+ return false
153
+ engine_class = str(declared["class"])
154
+ if engine_class.is_empty() or not ClassDB.class_exists(engine_class):
155
+ return false
156
+ if kind == "signals":
157
+ return not ClassDB.class_has_signal(engine_class, member)
158
+ return not ClassDB.class_has_method(engine_class, member)
159
+
160
+
161
+ ## What the script at [param path] declares with every script it extends, as {class, methods,
162
+ ## signals}, where class is the engine class at the bottom; {} for one this cannot read through.
163
+ ## [param seen] stops a loop of scripts extending each other.
164
+ func _script(path: String, seen: Dictionary) -> Dictionary:
165
+ if _scripts.has(path):
166
+ return _scripts[path]
167
+ if seen.has(path) or not path.ends_with(".gd") or not FileAccess.file_exists(path):
168
+ return {}
169
+ seen[path] = true
170
+ var methods: Dictionary = {}
171
+ var signals: Dictionary = {}
172
+ var base: String = "RefCounted"
173
+ var func_line: RegEx = RegEx.create_from_string(FUNC_LINE)
174
+ var signal_line: RegEx = RegEx.create_from_string(SIGNAL_LINE)
175
+ var extends_line: RegEx = RegEx.create_from_string(EXTENDS_LINE)
176
+ for line: String in FileAccess.get_file_as_string(path).split("\n"):
177
+ var named: RegExMatch = func_line.search(line)
178
+ if named != null:
179
+ methods[named.get_string(1)] = true
180
+ var sent: RegExMatch = signal_line.search(line)
181
+ if sent != null:
182
+ signals[sent.get_string(1)] = true
183
+ var extended: RegExMatch = extends_line.search(line)
184
+ if extended != null:
185
+ base = extended.get_string(1)
186
+ var engine_class: String = base
187
+ var base_path: String = ""
188
+ if base.begins_with('"') or base.begins_with("'"):
189
+ base_path = base.substr(1, base.length() - 2)
190
+ if base_path.is_relative_path():
191
+ base_path = path.get_base_dir().path_join(base_path)
192
+ elif _globals.has(base):
193
+ base_path = _globals[base]
194
+ if not base_path.is_empty():
195
+ var inherited: Dictionary = _script(base_path, seen)
196
+ if inherited.is_empty():
197
+ return {}
198
+ var inherited_methods: Dictionary = inherited["methods"]
199
+ var inherited_signals: Dictionary = inherited["signals"]
200
+ methods.merge(inherited_methods)
201
+ signals.merge(inherited_signals)
202
+ engine_class = str(inherited["class"])
203
+ _scripts[path] = {"class": engine_class, "methods": methods, "signals": signals}
204
+ return _scripts[path]
205
+
206
+
207
+ ## The attributes of every line of [param content] [param pattern] matches, one Dictionary each.
208
+ func _matches(pattern: String, content: String) -> Array[Dictionary]:
209
+ var expression: RegEx = RegEx.create_from_string(pattern)
210
+ var all: Array[Dictionary] = []
211
+ for line: String in content.split("\n"):
212
+ var matched: RegExMatch = expression.search(line)
213
+ if matched != null:
214
+ all.append(_attributes(matched.get_string(1)))
215
+ return all
216
+
217
+
218
+ static func _attributes(text: String) -> Dictionary:
219
+ var attributes: Dictionary = {}
220
+ for matched: RegExMatch in RegEx.create_from_string(ATTRIBUTE).search_all(text):
221
+ attributes[matched.get_string(1)] = matched.get_string(2)
222
+ return attributes
@@ -223,8 +223,7 @@ func deserialize_value(value: Variant) -> Variant:
223
223
  # under the target project's warning levels, so one such project lost every headless call.
224
224
  var tag: String = str(fields["_type"])
225
225
  var builder: String = BUILDERS.get(tag, "")
226
- var spare: Array = GdhShapes.spare(fields, tag)
227
- if builder.is_empty() or not spare.any(func(left: Array) -> bool: return left.is_empty()):
226
+ if builder.is_empty() or not GdhShapes.buildable(fields, tag):
228
227
  return fields
229
228
  if tag == "float" and str(fields["value"]) not in ["inf", "-inf", "nan"]:
230
229
  return fields
@@ -277,24 +276,7 @@ static func components_of(value: Variant) -> PackedStringArray:
277
276
  TYPE_VECTOR4, TYPE_VECTOR4I, TYPE_QUATERNION:
278
277
  return PackedStringArray(["x", "y", "z", "w"])
279
278
  TYPE_COLOR:
280
- return PackedStringArray(
281
- [
282
- "r",
283
- "g",
284
- "b",
285
- "a",
286
- "r8",
287
- "g8",
288
- "b8",
289
- "a8",
290
- "h",
291
- "s",
292
- "v",
293
- "ok_hsl_h",
294
- "ok_hsl_s",
295
- "ok_hsl_l"
296
- ]
297
- )
279
+ return "r g b a r8 g8 b8 a8 h s v ok_hsl_h ok_hsl_s ok_hsl_l".split(" ")
298
280
  TYPE_RECT2, TYPE_RECT2I, TYPE_AABB:
299
281
  return PackedStringArray(["position", "size", "end"])
300
282
  TYPE_PLANE:
@@ -405,7 +387,8 @@ func fitted(value: Variant, type: int) -> Variant:
405
387
  return built
406
388
 
407
389
  if rebuilt is Array and LIST_TYPES.has(type):
408
- return type_convert(rebuilt, type)
390
+ var items: Array = value if value is Array else rebuilt
391
+ return GdhShapes.packed_from(items, type, fitted)
409
392
 
410
393
  # The inspector and every colour picker give a colour as its html code. Anything else stays the
411
394
  # text it is, for the refusal to name.
@@ -705,7 +688,13 @@ func _describe_built_in(resource: Resource) -> Dictionary:
705
688
  var script: Script = null
706
689
  if attached is Script:
707
690
  script = attached
708
- described["script"] = serialize_value(script)
691
+ # By the class its script declares, which a write takes as the _type and which says what it
692
+ # is: as Resource with the script beside it, one answer named a Shop's built-in item that way
693
+ # and the file item beside it as an Item.
694
+ if script.get_global_name().is_empty():
695
+ described["script"] = serialize_value(script)
696
+ else:
697
+ described["_type"] = str(script.get_global_name())
709
698
  var fresh: Object = _fresh(script)
710
699
  for property: Dictionary in resource.get_property_list():
711
700
  var usage: int = property.get("usage", 0)
@@ -0,0 +1,121 @@
1
+ extends RefCounted
2
+
3
+ ## Project settings read the same way headless and from the open editor, because the two are one
4
+ ## question asked of two places: a field one of them left out read as the editor and the file
5
+ ## disagreeing about something they agree about.
6
+
7
+ const GdhRead = preload("reading.gd")
8
+ const GdhSerialisation = preload("serialisation.gd")
9
+
10
+
11
+ ## One setting: whether the engine knows it, its value, whether that is the engine's default, and
12
+ ## the feature overrides that replace it in some builds.
13
+ static func one(setting_path: String, values: GdhSerialisation) -> Dictionary:
14
+ var result: Dictionary = {
15
+ "setting_path": setting_path, "exists": ProjectSettings.has_setting(setting_path)
16
+ }
17
+
18
+ if result["exists"]:
19
+ var value: Variant = ProjectSettings.get_setting(setting_path)
20
+ result["value"] = values.serialize_value(value)
21
+ # A setting the engine registers exists whether or not the project sets it, so exists did
22
+ # not say whether project.godot has a line for it. At its default it has none: the engine
23
+ # drops such a key when it saves.
24
+ var default_value: Variant = ProjectSettings.property_get_revert(setting_path)
25
+ if default_value != null:
26
+ result["at_default"] = value == default_value
27
+ else:
28
+ result["value"] = null
29
+ result["message"] = "Setting does not exist"
30
+
31
+ # An override exists only while project.godot sets it, so one the file does not set read as a
32
+ # setting nobody had heard of, when a build with that feature reads its base setting instead.
33
+ var base: String = override_base(setting_path)
34
+ if not base.is_empty():
35
+ result["override_of"] = base
36
+ if not result["exists"] and ProjectSettings.has_setting(base):
37
+ result["holds"] = values.serialize_value(ProjectSettings.get_setting(base))
38
+ result["message"] = overridden_by_none(setting_path, values)
39
+
40
+ # The value under this name is not the one every build reads: `name.windows` replaces it on
41
+ # Windows, `name.editor` in the editor. Read alone, a caller saw the plain value and not the one
42
+ # their game showed.
43
+ var overrides: Dictionary = overrides_of(setting_path, values)
44
+ if not overrides.is_empty():
45
+ result["overrides"] = overrides
46
+
47
+ return result
48
+
49
+
50
+ ## Every setting whose name starts with [param prefix], with the type the engine registers for
51
+ ## each.
52
+ ##
53
+ ## The type is here because it is the half a name hides. A setting sitting among a family of levels
54
+ ## can be a bool, and a caller that assumes otherwise writes a level over it and sees nothing go
55
+ ## wrong: 2 is as true as true is.
56
+ static func under(prefix: String, values: GdhSerialisation) -> Dictionary:
57
+ var found: Array[Dictionary] = []
58
+ for property: Dictionary in ProjectSettings.get_property_list():
59
+ var name: String = str(property.get("name", ""))
60
+ if not name.begins_with(prefix):
61
+ continue
62
+ var kind: int = GdhRead.as_int(property.get("type", TYPE_NIL), TYPE_NIL)
63
+ var value: Variant = ProjectSettings.get_setting(name)
64
+ var described: Dictionary = {
65
+ "setting": name,
66
+ "type": type_string(kind),
67
+ "value": values.serialize_value(value),
68
+ }
69
+ # As a single get says it, so a family read can be told apart into what the project sets
70
+ # and the engine's defaults: "input/" is ninety actions the engine ships and the project's few.
71
+ var default_value: Variant = ProjectSettings.property_get_revert(name)
72
+ if default_value != null:
73
+ described["at_default"] = value == default_value
74
+ var base: String = override_base(name)
75
+ if not base.is_empty():
76
+ described["override_of"] = base
77
+ found.append(described)
78
+
79
+ found.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["setting"] < b["setting"])
80
+ return {"prefix": prefix, "count": found.size(), "settings": found}
81
+
82
+
83
+ ## The setting [param setting_path] overrides for builds with one feature, as
84
+ ## `application/run/max_fps.mobile` does `application/run/max_fps`, or "" for a plain setting.
85
+ static func override_base(setting_path: String) -> String:
86
+ var dot: int = setting_path.find(".", setting_path.rfind("/") + 1)
87
+ return "" if dot == -1 else setting_path.substr(0, dot)
88
+
89
+
90
+ ## The overrides project.godot sets for [param setting_path], by feature.
91
+ static func overrides_of(setting_path: String, values: GdhSerialisation) -> Dictionary:
92
+ var overrides: Dictionary = {}
93
+ for info: Dictionary in ProjectSettings.get_property_list():
94
+ var name: String = str(info.get("name", ""))
95
+ if name.begins_with(setting_path + "."):
96
+ overrides[name.substr(setting_path.length() + 1)] = values.serialize_value(
97
+ ProjectSettings.get_setting(name)
98
+ )
99
+ return overrides
100
+
101
+
102
+ ## What a build with the feature of [param setting_path], an override project.godot does not set,
103
+ ## reads in its place.
104
+ static func overridden_by_none(setting_path: String, values: GdhSerialisation) -> String:
105
+ var base: String = override_base(setting_path)
106
+ var feature: String = setting_path.substr(base.length() + 1)
107
+ var said: String = (
108
+ (
109
+ "project.godot sets no %s override of %s, so a build with the %s feature reads %s, "
110
+ + "the value under holds"
111
+ )
112
+ % [feature, base, feature, base]
113
+ )
114
+ var others: Array[String] = []
115
+ for name: String in overrides_of(base, values).keys():
116
+ if name != feature:
117
+ others.append(name)
118
+ if others.is_empty():
119
+ return said + "."
120
+ others.sort()
121
+ return said + (", unless it also has a feature with an override: %s." % ", ".join(others))
@@ -5,6 +5,22 @@ extends RefCounted
5
5
  ##
6
6
  ## This file is the one beside the operations, and `bun run sync:gd` copies it into each addon.
7
7
 
8
+ const GdhRead = preload("reading.gd")
9
+
10
+ ## What each packed array holds, one value of which every element is fitted to.
11
+ const PACKED_ELEMENTS: Dictionary = {
12
+ TYPE_PACKED_BYTE_ARRAY: TYPE_INT,
13
+ TYPE_PACKED_INT32_ARRAY: TYPE_INT,
14
+ TYPE_PACKED_INT64_ARRAY: TYPE_INT,
15
+ TYPE_PACKED_FLOAT32_ARRAY: TYPE_FLOAT,
16
+ TYPE_PACKED_FLOAT64_ARRAY: TYPE_FLOAT,
17
+ TYPE_PACKED_STRING_ARRAY: TYPE_STRING,
18
+ TYPE_PACKED_VECTOR2_ARRAY: TYPE_VECTOR2,
19
+ TYPE_PACKED_VECTOR3_ARRAY: TYPE_VECTOR3,
20
+ TYPE_PACKED_VECTOR4_ARRAY: TYPE_VECTOR4,
21
+ TYPE_PACKED_COLOR_ARRAY: TYPE_COLOR,
22
+ }
23
+
8
24
  # What each tag has to carry before it is built. A tag naming a type whose keys are not there is a
9
25
  # tag somebody wrote by mistake or wrote for a different shape, and building one out of defaults
10
26
  # answers with a Transform2D of zeros where the caller meant something else entirely. Unbuilt, it
@@ -33,6 +49,21 @@ const REQUIRED: Dictionary = {
33
49
  "RID": [["id"]],
34
50
  "Dictionary": [["entries"]],
35
51
  }
52
+ ## The fields each type reads as numbers. A builder reads a word there as 0, so {"x": 0, "y": "far"}
53
+ ## was written as (0, 0) and answered as written.
54
+ const NUMBER_FIELDS: Dictionary = {
55
+ "Vector2": ["x", "y"],
56
+ "Vector2i": ["x", "y"],
57
+ "Vector3": ["x", "y", "z"],
58
+ "Vector3i": ["x", "y", "z"],
59
+ "Vector4": ["x", "y", "z", "w"],
60
+ "Vector4i": ["x", "y", "z", "w"],
61
+ "Rect2": ["x", "y", "width", "height"],
62
+ "Rect2i": ["x", "y", "width", "height"],
63
+ "Plane": ["d"],
64
+ "Quaternion": ["x", "y", "z", "w"],
65
+ "Color": ["r", "g", "b", "a"],
66
+ }
36
67
  ## The fields a builder reads when they are there and does without when not.
37
68
  const OPTIONAL: Dictionary = {
38
69
  "Color": ["a"],
@@ -77,6 +108,86 @@ static func spare(fields: Dictionary, tag: String) -> Array:
77
108
  return lists
78
109
 
79
110
 
111
+ ## Whether a [param tag] built from [param fields] reads every one of them, and each it reads as a
112
+ ## number holds one.
113
+ static func buildable(fields: Dictionary, tag: String) -> bool:
114
+ return (
115
+ spare(fields, tag).any(func(left: Array) -> bool: return left.is_empty())
116
+ and not_a_number(fields, tag).is_empty()
117
+ )
118
+
119
+
120
+ ## The first field of [param fields] that a [param tag] reads as a number and that holds no number,
121
+ ## or "" when there is none. Digits in quotes are a number, as a write takes them for a float.
122
+ static func not_a_number(fields: Dictionary, tag: String) -> String:
123
+ for key: String in NUMBER_FIELDS.get(tag, []):
124
+ if fields.has(key) and not _is_number(fields[key]):
125
+ return key
126
+ return ""
127
+
128
+
129
+ ## Whether every item of [param items] is a number, for a list read by its places as a vector.
130
+ static func all_numbers(items: Array) -> bool:
131
+ return items.all(_is_number)
132
+
133
+
134
+ # A float JSON has no number for, infinity or NaN, crosses tagged as {"_type": "float", "value":
135
+ # "inf"}, and is a number.
136
+ static func _is_number(held: Variant) -> bool:
137
+ if held is int or held is float:
138
+ return true
139
+ if held is String:
140
+ return str(held).is_valid_float()
141
+ if held is Dictionary:
142
+ var tagged: Dictionary = held
143
+ return str(tagged.get("_type", "")) in ["float", "int"] and tagged.has("value")
144
+ return false
145
+
146
+
147
+ ## Whether [param value] holds a number with a fractional part where an integer is wanted, in a
148
+ ## list or a dictionary as much as bare. The conversion would cut 2.7 to 2 without a word, and a
149
+ ## dictionary key arrives as the text "2.7", which reads as the same number.
150
+ static func fraction(value: Variant, type: int, elements: Array = []) -> bool:
151
+ if type == TYPE_INT:
152
+ var number: Variant = value
153
+ if typeof(value) == TYPE_STRING:
154
+ number = GdhRead.json_or_null(str(value))
155
+ if typeof(number) != TYPE_FLOAT:
156
+ return false
157
+ var whole: float = number
158
+ return is_finite(whole) and whole != floorf(whole)
159
+ var element: int = GdhRead.as_int(PACKED_ELEMENTS.get(type, TYPE_NIL), TYPE_NIL)
160
+ if type == TYPE_ARRAY and not elements.is_empty():
161
+ element = GdhRead.as_int(elements[0], TYPE_NIL)
162
+ if value is Array and element != TYPE_NIL:
163
+ var items: Array = value
164
+ return items.any(func(item: Variant) -> bool: return fraction(item, element))
165
+ if type == TYPE_DICTIONARY and elements.size() == 2 and value is Dictionary:
166
+ var entries: Dictionary = value
167
+ var key_type: int = GdhRead.as_int(elements[0], TYPE_NIL)
168
+ var value_type: int = GdhRead.as_int(elements[1], TYPE_NIL)
169
+ for key: Variant in entries:
170
+ if fraction(key, key_type) or fraction(entries[key], value_type):
171
+ return true
172
+ return false
173
+
174
+
175
+ ## The packed array [param type] built from [param items], each fitted by [param fit] to what the
176
+ ## array holds, or [param items] as they are when one cannot become that, for the caller to refuse.
177
+ ## Element by element, so a point given as its fields is read as one: converted whole, a list of
178
+ ## {"x": 3, "y": 4} became zero vectors, and the call ran on them.
179
+ static func packed_from(items: Array, type: int, fit: Callable) -> Variant:
180
+ var element: int = PACKED_ELEMENTS[type]
181
+ var numbers: Array[int] = [TYPE_INT, TYPE_FLOAT]
182
+ var built: Array = []
183
+ for item: Variant in items:
184
+ var one: Variant = fit.call(item, element)
185
+ if typeof(one) != element and not (numbers.has(typeof(one)) and numbers.has(element)):
186
+ return items
187
+ built.append(one)
188
+ return type_convert(built, type)
189
+
190
+
80
191
  ## The sentences a refusal of [param value] where a [param type] goes ends with: which of its keys
81
192
  ## kept it from being read as one, and how one is written. Empty for a type not built from fields.
82
193
  ##
@@ -92,7 +203,23 @@ static func remedy(value: Variant, type: int) -> String:
92
203
  var shape: String = written_as(type)
93
204
  if shape.is_empty():
94
205
  return ""
95
- return _unread(value, type) + " A %s is sent as %s." % [type_string(type), shape]
206
+ return (
207
+ _unread(value, type) + _no_number(value, type) + " A %s is sent as %s." % [type_string(type), shape]
208
+ )
209
+
210
+
211
+ static func _no_number(value: Variant, type: int) -> String:
212
+ var tag: String = type_string(type)
213
+ if value is Dictionary:
214
+ var fields: Dictionary = value
215
+ var key: String = not_a_number(fields, tag)
216
+ if not key.is_empty():
217
+ return " Its %s is %s, which is no number." % [key, JSON.stringify(fields[key])]
218
+ if value is Array and NUMBER_FIELDS.has(tag):
219
+ var items: Array = value
220
+ if not all_numbers(items):
221
+ return " Read by its places, each item is a number."
222
+ return ""
96
223
 
97
224
 
98
225
  static func _unread(value: Variant, type: int) -> String: