gdharness 1.1.21 → 1.1.22

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 (47) hide show
  1. package/build/cli.js +1036 -525
  2. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  3. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  8. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  9. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  10. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  13. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  14. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  15. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
  19. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  22. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
  23. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
  24. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  25. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  26. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  28. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  29. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
  30. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  31. package/build/godot/operations/audio_buses.gd +47 -11
  32. package/build/godot/operations/class_cache.gd +18 -2
  33. package/build/godot/operations/dependencies.gd +128 -17
  34. package/build/godot/operations/file_walk.gd +20 -0
  35. package/build/godot/operations/gdscript_analysis.gd +59 -20
  36. package/build/godot/operations/gdscript_authoring.gd +127 -60
  37. package/build/godot/operations/godot_operations.gd +24 -5
  38. package/build/godot/operations/import_pipeline.gd +179 -19
  39. package/build/godot/operations/logger.gd +2 -2
  40. package/build/godot/operations/plugins.gd +88 -70
  41. package/build/godot/operations/project_config.gd +41 -6
  42. package/build/godot/operations/project_diagnostics.gd +25 -10
  43. package/build/godot/operations/reading.gd +17 -0
  44. package/build/godot/operations/resource_files.gd +60 -17
  45. package/build/godot/operations/serialisation.gd +116 -7
  46. package/build/index.js +949 -435
  47. package/package.json +1 -1
@@ -52,16 +52,30 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
52
52
  # answer: a `var x: int = 5 # note` carried `5 # note` as its default, and correcting the
53
53
  # end of a parameter list put the comment into the return type, where `rfind(")")` had been
54
54
  # hiding it by stopping inside the comment instead.
55
- var stripped: String = _without_comment(lines[i])
56
-
57
- if stripped.is_empty():
55
+ var raw: String = lines[i]
56
+ # Inside a string the raw line is counted, because a `#` in it is text and taking it for a
57
+ # comment would cut off the quotes that close the string.
58
+ if in_multiline_string:
59
+ if _triple_quotes(raw) % 2 == 1:
60
+ in_multiline_string = false
58
61
  continue
62
+ var stripped: String = _without_comment(raw)
59
63
 
60
- if '"""' in stripped or "'''" in stripped:
61
- in_multiline_string = not in_multiline_string
64
+ if stripped.is_empty():
62
65
  continue
63
66
 
64
- if in_multiline_string:
67
+ # An odd count opens a string that runs on; an even one is a string that closed on the line
68
+ # it opened on. Toggling on any count lost `const HELP := """Usage"""`, and everything after
69
+ # it up to the next triple quote, with the state inverted from there to the end of the file.
70
+ # The line that opens one is still read: it can be the declaration the string belongs to.
71
+ if _triple_quotes(stripped) % 2 == 1:
72
+ in_multiline_string = true
73
+
74
+ # Only a line at the left margin declares one of the script's own members. Indented, it is a
75
+ # function's local or an inner class's member, and each was answered as the script's own:
76
+ # every `var` in every body, and an inner class's `extends` taken for the script's base.
77
+ if raw.begins_with(" ") or raw.begins_with("\t"):
78
+ _collect_dependencies(stripped, dependencies)
65
79
  continue
66
80
 
67
81
  # Annotations may share the line with anything they annotate, so every branch below decides
@@ -116,14 +130,11 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
116
130
  elif header.begins_with("class "):
117
131
  inner_classes.append(header.substr(6).split(":")[0].split(" ")[0].strip_edges())
118
132
 
119
- if "preload(" in stripped or "load(" in stripped:
120
- for dep: String in _extract_dependencies(stripped):
121
- if dep not in dependencies:
122
- dependencies.append(dep)
133
+ _collect_dependencies(stripped, dependencies)
123
134
 
124
135
  # After the loop, because it needs the base the loop reads, and a file that declared one after
125
136
  # its first function would otherwise be judged against the default.
126
- _mark_virtuals(functions, extends_name)
137
+ _mark_virtuals(functions, extends_name, full_script_path)
127
138
 
128
139
  var answer: Dictionary = {
129
140
  "path": script_path,
@@ -151,8 +162,8 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
151
162
  # with a private helper in it. The virtuals are the ones the native ancestor declares with
152
163
  # `METHOD_FLAG_VIRTUAL`, which is a question `ClassDB` answers exactly, and `class_has_method` does
153
164
  # not: it says false for `_ready` on `Node`, so the method list is what has to be walked.
154
- func _mark_virtuals(functions: Array[Dictionary], extends_name: String) -> void:
155
- var native: String = _native_ancestor(extends_name)
165
+ func _mark_virtuals(functions: Array[Dictionary], extends_name: String, from_path: String) -> void:
166
+ var native: String = _native_ancestor(extends_name, from_path)
156
167
  var virtuals: Dictionary = {}
157
168
  if not native.is_empty():
158
169
  for m: Dictionary in ClassDB.class_get_method_list(native, false):
@@ -168,11 +179,12 @@ func _mark_virtuals(functions: Array[Dictionary], extends_name: String) -> void:
168
179
  # A base named by a script is followed; a base that is neither a script nor a class the engine knows
169
180
  # leaves nothing to ask, and then no function is called virtual rather than every underscore being
170
181
  # guessed at.
171
- func _native_ancestor(base: String) -> String:
182
+ func _native_ancestor(base: String, from_path: String) -> String:
172
183
  var seen: Array[String] = []
173
184
  var current: String = base
185
+ var declared_in: String = from_path
174
186
  while true:
175
- var path: String = _script_named(current)
187
+ var path: String = _script_named(current, declared_in)
176
188
  if path.is_empty():
177
189
  break
178
190
  if path in seen:
@@ -182,6 +194,7 @@ func _native_ancestor(base: String) -> String:
182
194
  if above.is_empty():
183
195
  return ""
184
196
  current = str(above.get("extends", ""))
197
+ declared_in = path
185
198
  return current if ClassDB.class_exists(current) else ""
186
199
 
187
200
 
@@ -196,8 +209,9 @@ func _native_ancestor(base: String) -> String:
196
209
  func _add_inherited(answer: Dictionary, seen: Array[String]) -> void:
197
210
  var inherits_from: Array[String] = []
198
211
  var base: String = str(answer.get("extends", ""))
212
+ var declared_in: String = str(answer.get("full_path", ""))
199
213
  while true:
200
- var base_path: String = _script_named(base)
214
+ var base_path: String = _script_named(base, declared_in)
201
215
  if base_path.is_empty() or base_path in seen:
202
216
  break
203
217
  seen.append(base_path)
@@ -216,16 +230,28 @@ func _add_inherited(answer: Dictionary, seen: Array[String]) -> void:
216
230
  carried["inherited_from"] = base_path
217
231
  mine.append(carried)
218
232
  base = str(above.get("extends", ""))
233
+ declared_in = base_path
219
234
  answer["inherits_from"] = inherits_from
220
235
 
221
236
 
222
237
  # The file a base names, whether it named a path or a class, or empty for a native class.
223
- func _script_named(base: String) -> String:
238
+ #
239
+ # A quoted path is resolved the way the engine resolves it: `extends "base.gd"` is relative to the
240
+ # script [param declared_in], and asking whether `base.gd` exists asked about nothing, so such a
241
+ # script had no ancestor and none of its engine callbacks were virtual.
242
+ func _script_named(base: String, declared_in: String) -> String:
224
243
  if base.is_empty():
225
244
  return ""
226
- if base.begins_with('"') and base.ends_with('"'):
227
- var quoted: String = base.substr(1, base.length() - 2)
228
- return quoted if FileAccess.file_exists(quoted) else ""
245
+ var quoted: String = ""
246
+ if base.length() >= 2 and (base[0] == '"' or base[0] == "'") and base.ends_with(base[0]):
247
+ quoted = base.substr(1, base.length() - 2)
248
+ if not quoted.is_empty():
249
+ if quoted.begins_with("uid://"):
250
+ var id: int = ResourceUID.text_to_id(quoted)
251
+ quoted = ResourceUID.get_id_path(id) if ResourceUID.has_id(id) else ""
252
+ elif not quoted.begins_with("res://"):
253
+ quoted = declared_in.get_base_dir().path_join(quoted).simplify_path()
254
+ return quoted if not quoted.is_empty() and FileAccess.file_exists(quoted) else ""
229
255
  for entry: Dictionary in ProjectSettings.get_global_class_list():
230
256
  if str(entry.get("class", "")) == base:
231
257
  var path: String = str(entry.get("path", ""))
@@ -562,6 +588,19 @@ func _parse_param(param_text: String) -> Dictionary:
562
588
  return {"name": name, "type": type_hint, "default": default_value, "is_rest": is_rest}
563
589
 
564
590
 
591
+ func _collect_dependencies(line: String, into: Array[String]) -> void:
592
+ if "load(" not in line:
593
+ return
594
+ for dep: String in _extract_dependencies(line):
595
+ if dep not in into:
596
+ into.append(dep)
597
+
598
+
599
+ # How many triple quotes of either kind [param line] holds.
600
+ static func _triple_quotes(line: String) -> int:
601
+ return line.count('"""') + line.count("'''")
602
+
603
+
565
604
  func _extract_dependencies(line: String) -> Array[String]:
566
605
  var deps: Array[String] = []
567
606
  var regex: RegEx = Patterns.compiled("(?:preload|load)\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)")
@@ -23,6 +23,24 @@ func create_gdscript(params: Dictionary) -> Dictionary:
23
23
 
24
24
  _log.info("Creating GDScript: " + script_path)
25
25
 
26
+ # Content is the whole file, so a header written above it would be a second one: a content that
27
+ # starts with its own `extends` did not parse, and one without it was not the file that was sent.
28
+ if not content.is_empty():
29
+ var alongside: Array[String] = []
30
+ for name: String in ["class_name", "extends", "template"]:
31
+ if not str(params.get(name, "")).is_empty():
32
+ alongside.append(name)
33
+ if not alongside.is_empty():
34
+ return _log.failure(
35
+ (
36
+ "content is the whole file, so "
37
+ + " and ".join(alongside)
38
+ + " cannot be given with it: write "
39
+ + ("them" if alongside.size() > 1 else "it")
40
+ + " into content instead"
41
+ )
42
+ )
43
+
26
44
  var full_script_path: String = script_path
27
45
  if not full_script_path.begins_with("res://"):
28
46
  full_script_path = "res://" + full_script_path
@@ -41,20 +59,16 @@ func create_gdscript(params: Dictionary) -> Dictionary:
41
59
  if error != OK:
42
60
  return _log.failure("Failed to create directory: " + script_dir + ", error: " + str(error))
43
61
 
44
- var script_content: String = ""
45
-
46
- if not cls_name_param.is_empty():
47
- script_content += "class_name " + cls_name_param + "\n"
48
-
49
- script_content += "extends " + extends_class + "\n\n"
50
-
51
- if not template.is_empty():
52
- script_content += _script_template(template)
53
- elif not content.is_empty():
54
- script_content += content
55
- else:
56
- script_content += "func _ready() -> void:\n"
57
- script_content += "\tpass\n"
62
+ var script_content: String = content
63
+ if content.is_empty():
64
+ if not cls_name_param.is_empty():
65
+ script_content += "class_name " + cls_name_param + "\n"
66
+ script_content += "extends " + extends_class + "\n\n"
67
+ if not template.is_empty():
68
+ script_content += _script_template(template)
69
+ else:
70
+ script_content += "func _ready() -> void:\n"
71
+ script_content += "\tpass\n"
58
72
 
59
73
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.WRITE)
60
74
  if not file:
@@ -65,21 +79,20 @@ func create_gdscript(params: Dictionary) -> Dictionary:
65
79
  if not stored:
66
80
  return _log.failure("Failed to write to script file: " + full_script_path)
67
81
 
68
- # Whether the engine accepts what was written, parsed under this project's own warning
69
- # settings: a script that does not load is a script the caller wants to hear about now,
70
- # and the reason is on stderr, which comes back with the answer.
71
82
  var written: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
72
-
73
- return {
83
+ var answer: Dictionary = {
74
84
  "success": true,
75
85
  "script_path": script_path,
76
86
  "full_path": full_script_path,
77
87
  "absolute_path": ProjectSettings.globalize_path(full_script_path),
78
- "registered": not cls_name_param.is_empty(),
79
- "extends": extends_class,
80
88
  "template_used": template if not template.is_empty() else "none",
81
- "parses": written != null and written.can_instantiate(),
89
+ "parses": _parses(written),
82
90
  }
91
+ # Read off the file as the engine parsed it rather than off the arguments, which content leaves out.
92
+ if written != null:
93
+ answer["extends"] = _base_named(written)
94
+ answer["class_name"] = str(written.get_global_name())
95
+ return answer
83
96
 
84
97
 
85
98
  # Modify an existing GDScript file by adding functions, variables, or signals
@@ -103,34 +116,40 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
103
116
  var original_content: String = file.get_as_text()
104
117
  file.close()
105
118
 
119
+ # All of them checked before any is made, so a call is made whole or not at all: one skipped for a
120
+ # missing name used to leave the rest written and the answer saying success over an empty list.
121
+ var problems: Array[String] = _modification_problems(modifications)
122
+ if not problems.is_empty():
123
+ return _log.failure("Nothing was changed: " + "; ".join(problems))
124
+
106
125
  var lines: Array[String] = []
107
126
  lines.assign(original_content.split("\n"))
108
- var modifications_applied: Array[Dictionary] = []
127
+ # Each placed line as a zero-based index that moves down whenever a later insertion lands above
128
+ # it, so the numbers answered are where the lines are in the file written, not where each went
129
+ # at the moment it was placed.
130
+ var placed: Array[Dictionary] = []
109
131
 
110
132
  for mod: Variant in modifications:
111
- if not mod is Dictionary:
112
- _log.error("Modification must be an object")
113
- continue
114
133
  var fields: Dictionary = mod
115
134
  var mod_type: String = str(fields.get("type", ""))
116
- var mod_name: String = str(fields.get("name", ""))
117
-
118
- if mod_name.is_empty():
119
- _log.error("Modification missing 'name' field")
120
- continue
121
-
135
+ var at: int = -1
122
136
  match mod_type:
123
137
  "add_variable":
124
- var line: int = _add_variable(lines, fields)
125
- modifications_applied.append({"type": "add_variable", "name": mod_name, "line": line})
138
+ at = _add_variable(lines, fields, placed)
126
139
  "add_signal":
127
- var line: int = _add_signal(lines, fields)
128
- modifications_applied.append({"type": "add_signal", "name": mod_name, "line": line})
140
+ at = _add_signal(lines, fields, placed)
129
141
  "add_function":
130
- var line: int = _add_function(lines, fields)
131
- modifications_applied.append({"type": "add_function", "name": mod_name, "line": line})
132
- _:
133
- _log.error("Unknown modification type: " + mod_type)
142
+ at = _add_function(lines, fields, placed)
143
+ if at == -1:
144
+ return _log.failure(
145
+ "Nothing was changed: " + mod_type + " " + str(fields.get("name")) + " could not be placed"
146
+ )
147
+ placed.append({"type": mod_type, "name": str(fields.get("name")), "index": at})
148
+
149
+ var modifications_applied: Array[Dictionary] = []
150
+ for entry: Dictionary in placed:
151
+ var index: int = entry["index"]
152
+ modifications_applied.append({"type": entry["type"], "name": entry["name"], "line": index + 1})
134
153
 
135
154
  file = FileAccess.open(full_script_path, FileAccess.WRITE)
136
155
  if not file:
@@ -141,17 +160,75 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
141
160
  if not rewrote:
142
161
  return _log.failure("Failed to write to script file: " + full_script_path)
143
162
 
163
+ var made: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
144
164
  return {
145
165
  "success": true,
146
166
  "script_path": script_path,
147
167
  "modifications_applied": modifications_applied,
148
- "total_modifications": modifications_applied.size()
168
+ "total_modifications": modifications_applied.size(),
169
+ "parses": _parses(made),
149
170
  }
150
171
 
151
172
 
152
- # Inserts the declaration and answers with its one-based line number, or 0 for one that could not
153
- # be placed, which is a number no line has.
154
- func _add_variable(lines: Array[String], mod: Dictionary) -> int:
173
+ # Every reason [param modifications] cannot be made as asked, each naming the entry by position.
174
+ static func _modification_problems(modifications: Array) -> Array[String]:
175
+ var problems: Array[String] = []
176
+ if modifications.is_empty():
177
+ problems.append("modifications is empty")
178
+ for index: int in range(modifications.size()):
179
+ var mod: Variant = modifications[index]
180
+ var which: String = "modification " + str(index + 1)
181
+ if not mod is Dictionary:
182
+ problems.append(which + " is not an object")
183
+ continue
184
+ var fields: Dictionary = mod
185
+ var mod_type: String = str(fields.get("type", ""))
186
+ var mod_name: String = str(fields.get("name", ""))
187
+ if mod_type not in ["add_variable", "add_signal", "add_function"]:
188
+ problems.append(
189
+ which + " has type '" + mod_type + "', not add_variable, add_signal or add_function"
190
+ )
191
+ if mod_name.is_empty():
192
+ problems.append(which + " has no name")
193
+ elif not mod_name.is_valid_unicode_identifier():
194
+ problems.append(which + " is named '" + mod_name + "', which GDScript does not accept as a name")
195
+ return problems
196
+
197
+
198
+ # Inserts [param new_lines] at [param at] and moves every placed line at or below it down by as many.
199
+ static func _insert(
200
+ lines: Array[String], at: int, new_lines: Array[String], placed: Array[Dictionary]
201
+ ) -> bool:
202
+ for i: int in range(new_lines.size() - 1, -1, -1):
203
+ if lines.insert(at, new_lines[i]) != OK:
204
+ push_error("gdharness: could not place a line at " + str(at))
205
+ return false
206
+ for entry: Dictionary in placed:
207
+ var index: int = entry["index"]
208
+ if index >= at:
209
+ entry["index"] = index + new_lines.size()
210
+ return true
211
+
212
+
213
+ # Whether the engine accepts [param script], parsed under this project's own warning settings: a
214
+ # script that does not load is one the caller wants to hear about now, and the reason is on stderr,
215
+ # which comes back with the answer. A script that fails to parse still loads, as one that cannot be
216
+ # instantiated; an abstract one that parses can be, on 4.7.
217
+ static func _parses(script: Script) -> bool:
218
+ return script != null and script.can_instantiate()
219
+
220
+
221
+ # What [param script] extends: the base script by class name or path, or the native class.
222
+ static func _base_named(script: Script) -> String:
223
+ var base: Script = script.get_base_script()
224
+ if base == null:
225
+ return str(script.get_instance_base_type())
226
+ var named: String = str(base.get_global_name())
227
+ return named if not named.is_empty() else base.resource_path
228
+
229
+
230
+ # Inserts the declaration and answers with its zero-based index, or -1 for one that could not be placed.
231
+ func _add_variable(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
155
232
  var var_name: String = str(mod.get("name", ""))
156
233
  var var_type: String = str(mod.get("varType", ""))
157
234
  var default_value: String = str(mod.get("defaultValue", ""))
@@ -186,10 +263,7 @@ func _add_variable(lines: Array[String], mod: Dictionary) -> int:
186
263
  var_line += ": Variant"
187
264
 
188
265
  var insert_line: int = _variable_insertion_point(lines)
189
- if lines.insert(insert_line, var_line) != OK:
190
- push_error("gdharness: could not place a declaration at line " + str(insert_line))
191
- return 0
192
- return insert_line + 1
266
+ return insert_line if _insert(lines, insert_line, [var_line], placed) else -1
193
267
 
194
268
 
195
269
  # The type name a constant expression evaluates to, or Variant when it is not one: a call
@@ -208,7 +282,7 @@ func _type_of_literal(expression: String) -> String:
208
282
  return type_string(typeof(value))
209
283
 
210
284
 
211
- func _add_signal(lines: Array[String], mod: Dictionary) -> int:
285
+ func _add_signal(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
212
286
  var signal_name: String = str(mod.get("name", ""))
213
287
  var signal_params: String = str(mod.get("params", ""))
214
288
 
@@ -217,13 +291,11 @@ func _add_signal(lines: Array[String], mod: Dictionary) -> int:
217
291
  signal_line += "(" + signal_params + ")"
218
292
 
219
293
  var insert_line: int = _signal_insertion_point(lines)
220
- if lines.insert(insert_line, signal_line) != OK:
221
- push_error("gdharness: could not place a signal at line " + str(insert_line))
222
- return 0
223
- return insert_line + 1
294
+ return insert_line if _insert(lines, insert_line, [signal_line], placed) else -1
224
295
 
225
296
 
226
- func _add_function(lines: Array[String], mod: Dictionary) -> int:
297
+ # Answers with the zero-based index of the `func` line, below the blank line that separates it.
298
+ func _add_function(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
227
299
  var func_name: String = str(mod.get("name", ""))
228
300
  var func_params: String = str(mod.get("params", ""))
229
301
  # A function that names no return type returns nothing, and says so, for the same reason
@@ -248,12 +320,7 @@ func _add_function(lines: Array[String], mod: Dictionary) -> int:
248
320
  if insert_line < lines.size():
249
321
  func_lines.append("")
250
322
 
251
- for i: int in range(func_lines.size() - 1, -1, -1):
252
- if lines.insert(insert_line, func_lines[i]) != OK:
253
- push_error("gdharness: could not place a function at line " + str(insert_line))
254
- return 0
255
-
256
- return insert_line + 1
323
+ return insert_line + 1 if _insert(lines, insert_line, func_lines, placed) else -1
257
324
 
258
325
 
259
326
  func _variable_insertion_point(lines: Array[String]) -> int:
@@ -2,8 +2,13 @@
2
2
  extends SceneTree
3
3
 
4
4
  # The command line the server runs is `godot --headless --path <project> --script <this>
5
- # <operation> @file:<params.json>`, and one JSON object on stdout is the answer. Everything an
6
- # operation does lives in a sibling module; this file is the command table and the wire format.
5
+ # <operation> @file:<params.json> <answer.json>`, and the one JSON object written to the answer
6
+ # file is the answer. Everything an operation does lives in a sibling module; this file is the
7
+ # command table and the wire format.
8
+ #
9
+ # A file rather than a line on stdout, because the project's autoloads run after `_init` and print
10
+ # into the same stream: an autoload printing a dictionary in `_process` was the last JSON line out
11
+ # and was taken as the answer. Nothing the project prints can reach the file.
7
12
  #
8
13
  # Each module is preloaded by a path relative to this script rather than a res:// one, because
9
14
  # the operations directory ships inside the server package and is handed to the engine as an
@@ -39,8 +44,9 @@ func _init() -> void:
39
44
  return
40
45
 
41
46
  var params_index: int = script_index + 3
42
- if args.size() <= params_index:
43
- _log.error("Usage: godot --headless --script godot_operations.gd <operation> <json_params>")
47
+ var answer_index: int = script_index + 4
48
+ if args.size() <= answer_index:
49
+ _log.error("Usage: godot_operations.gd <operation> @file:<params.json> <answer.json>")
44
50
  _log.error("Not enough command-line arguments provided.")
45
51
  quit(1)
46
52
  return
@@ -65,7 +71,20 @@ func _init() -> void:
65
71
  quit(1)
66
72
  return
67
73
 
68
- print(JSON.stringify(payload))
74
+ var answer_path: String = args[answer_index]
75
+ var answer: FileAccess = FileAccess.open(answer_path, FileAccess.WRITE)
76
+ if answer == null:
77
+ _log.error(
78
+ "Could not write the answer to " + answer_path + ": " + error_string(FileAccess.get_open_error())
79
+ )
80
+ quit(1)
81
+ return
82
+ var written: bool = answer.store_string(JSON.stringify(payload))
83
+ answer.close()
84
+ if not written:
85
+ _log.error("Could not write the answer to " + answer_path)
86
+ quit(1)
87
+ return
69
88
  quit()
70
89
 
71
90