gdharness 0.9.5 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -15382,7 +15382,10 @@ var init_tool_definitions = __esm(() => {
15382
15382
  parameters: {
15383
15383
  projectPath: PROJECT_PATH,
15384
15384
  preset: { type: "string", description: "Preset name from export_presets.cfg." },
15385
- outputPath: { type: "string", description: "Where the export is written, inside the project." },
15385
+ outputPath: {
15386
+ type: "string",
15387
+ description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
15388
+ },
15386
15389
  debug: { type: "boolean", description: "run: a debug export. Default false." }
15387
15390
  },
15388
15391
  requires: ["projectPath"],
@@ -15791,7 +15794,7 @@ var init_tool_definitions = __esm(() => {
15791
15794
  says: {
15792
15795
  type: "string",
15793
15796
  ops: ["find"],
15794
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match."
15797
+ description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern."
15795
15798
  },
15796
15799
  limit: {
15797
15800
  type: "number",
@@ -36002,7 +36005,7 @@ var init_setup = __esm(() => {
36002
36005
 
36003
36006
  // src/server.ts
36004
36007
  import { execFile as execFile5, spawn } from "node:child_process";
36005
- import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
36008
+ import { existsSync as existsSync12, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
36006
36009
  import { tmpdir as tmpdir5 } from "node:os";
36007
36010
  import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
36008
36011
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -36935,6 +36938,12 @@ class GodotServer {
36935
36938
  if (!engine.ok) {
36936
36939
  return engine.response;
36937
36940
  }
36941
+ const folder = dirname9(output.absolutePath);
36942
+ try {
36943
+ mkdirSync7(folder, { recursive: true });
36944
+ } catch (error) {
36945
+ return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
36946
+ }
36938
36947
  const debug = readBoolean(args, "debug") ?? false;
36939
36948
  const exportArgs = [
36940
36949
  "--headless",
@@ -38930,6 +38939,7 @@ init_class_cache();
38930
38939
  init_headless();
38931
38940
  init_resources();
38932
38941
  init_server_version();
38942
+ import { spawnSync } from "node:child_process";
38933
38943
  import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
38934
38944
  import { dirname as dirname3, join as join8 } from "node:path";
38935
38945
  import { fileURLToPath } from "node:url";
@@ -38992,6 +39002,29 @@ async function enablePlugins(engine, projectPath, names) {
38992
39002
  async function setRuntime(engine, projectPath, on) {
38993
39003
  return on ? await runOperation2(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, enabled: true }, projectPath) : await runOperation2(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
38994
39004
  }
39005
+ function autoloadPaths(settings) {
39006
+ const named = new Map;
39007
+ for (const [name, value] of Object.entries(settings["autoload"] ?? {})) {
39008
+ const said = typeof value === "string" ? value.replace(/^\*/, "") : "";
39009
+ if (said.startsWith("res://")) {
39010
+ named.set(name, said.slice("res://".length));
39011
+ }
39012
+ }
39013
+ return named;
39014
+ }
39015
+ function trackedByGit(projectPath, wanted) {
39016
+ if (wanted.length === 0) {
39017
+ return new Set;
39018
+ }
39019
+ const asked = spawnSync("git", ["ls-files", "-z", "--", ...wanted], {
39020
+ cwd: projectPath,
39021
+ encoding: "utf8"
39022
+ });
39023
+ if (asked.status !== 0) {
39024
+ return null;
39025
+ }
39026
+ return new Set(asked.stdout.split("\x00").filter(Boolean));
39027
+ }
38995
39028
  function enabledPlugins(settings) {
38996
39029
  const raw = settings["editor_plugins"]?.["enabled"];
38997
39030
  if (typeof raw !== "string") {
@@ -39023,6 +39056,13 @@ function inspectProject(projectPath) {
39023
39056
  }
39024
39057
  const autoload = settings["autoload"];
39025
39058
  const runtimeAutoload = autoload !== undefined && typeof readString(autoload, RUNTIME_AUTOLOAD.name) === "string";
39059
+ const named = autoloadPaths(settings);
39060
+ const tracked = trackedByGit(projectPath, [...named.values()]);
39061
+ for (const [name, path] of named) {
39062
+ if (tracked !== null && !tracked.has(path) && existsSync7(join8(projectPath, path))) {
39063
+ problems.push(`the ${name} autoload names res://${path}, which git does not carry, so a clone boots with a missing script; commit that file, or point the autoload at one the project tracks`);
39064
+ }
39065
+ }
39026
39066
  const cached = cachedClasses(projectPath);
39027
39067
  const staleClasses = cached === null ? [] : staleAgainst(cached, projectPath);
39028
39068
  if (cached === null) {
@@ -39157,6 +39197,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
39157
39197
  These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
39158
39198
  window and refuses headless rather than handing back the last frame anything drew.
39159
39199
 
39200
+ A headless game answers; a \`godot -s\` script run does not. It has no game in it, so the autoload
39201
+ stays quiet there rather than announcing a test tier as the project.
39202
+
39160
39203
  The autoload reaches an export. \`gdharness runtime off\` in the project before shipping.
39161
39204
 
39162
39205
  ## At a breakpoint
@@ -23,6 +23,12 @@ const BIND_ADDRESS_SETTING: String = "gdharness/runtime/bind_address"
23
23
  ## expects. A fixed port is for a client that cannot read the announcement.
24
24
  const PORT_SETTING: String = "gdharness/runtime/port"
25
25
 
26
+ ## How the engine is told to run a script instead of the game: `godot -s thing.gd`.
27
+ const SCRIPT_FLAGS: PackedStringArray = ["-s", "--script"]
28
+
29
+ ## Serve a script run anyway, for somebody driving a `-s` script rather than a game.
30
+ const SCRIPT_RUNS_SETTING: String = "gdharness/runtime/serve_script_runs"
31
+
26
32
  var values: Values = Values.new()
27
33
 
28
34
  # The modules are members and not locals of _init, because a Callable holds its object by id
@@ -161,6 +167,15 @@ func _start_server() -> void:
161
167
  _enabled = false
162
168
  return
163
169
 
170
+ # And a script run has nothing to serve. Autoloads come up for `godot -s` as well, so a test
171
+ # tier or a batch tool binds a loopback port and writes an announcement under the project's
172
+ # own path: a gate that starts sixteen engines at once announces sixteen games that are not
173
+ # games, and a client asking the runtime anything while they run can be answered by whichever
174
+ # of them replies first. Measured on two projects before it was written here.
175
+ if _script_run() and not bool(ProjectSettings.get_setting(SCRIPT_RUNS_SETTING, false)):
176
+ _enabled = false
177
+ return
178
+
164
179
  _server = TCPServer.new()
165
180
  # listen() defaults bind_address to "*", which exposes the game to the whole network.
166
181
  var bind_address: String = str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
@@ -180,6 +195,15 @@ func _start_server() -> void:
180
195
  print("[gdharness] runtime listening on %s:%d, announced at %s" % [bind_address, _port, _announcement])
181
196
 
182
197
 
198
+ ## Whether the engine was told to run a script rather than the game.
199
+ func _script_run() -> bool:
200
+ var given: PackedStringArray = OS.get_cmdline_args()
201
+ for flag: String in SCRIPT_FLAGS:
202
+ if given.has(flag):
203
+ return true
204
+ return false
205
+
206
+
183
207
  ## Where the announcement goes. The server derives the same path with the same precedence, so
184
208
  ## the two only meet if this stays in step with `runtimeDirectory` in src/runtime-client.ts.
185
209
  func _announcement_directory() -> String:
@@ -160,6 +160,17 @@ static func _is_literal(pattern: String) -> bool:
160
160
  return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
161
161
 
162
162
 
163
+ ## Whether [param said] is what a find asked for in [param wanted].
164
+ ##
165
+ ## A plain word is a contains, which is what somebody looking for the row about a person means. A
166
+ ## pattern is a glob, because [code]namePattern[/code] beside it is one and nobody writes `*Still*`
167
+ ## in one field meaning a glob and in the other meaning those characters. Written as a contains
168
+ ## only, a glob matched nothing at all and an empty answer reads as a control that is not on the
169
+ ## screen: twice in one session here, over a button that was.
170
+ static func _says(said: String, wanted: String) -> bool:
171
+ return said.containsn(wanted) if _is_literal(wanted) else said.matchn(wanted)
172
+
173
+
163
174
  ## Every filter but the name, so a find that came back empty can say how many nodes the name was
164
175
  ## the only thing standing between it and.
165
176
  func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
@@ -168,7 +179,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
168
179
  # What this node says, rather than everything said underneath it. A row is then found by the
169
180
  # label in it, and the path answered is that label's, which is where the words a caller is
170
181
  # looking at actually are: matching every container above it would answer with the screen.
171
- if not wanted["says"].is_empty() and not said_by(node).containsn(wanted["says"]):
182
+ if not wanted["says"].is_empty() and not _says(said_by(node), wanted["says"]):
172
183
  return false
173
184
  var script: Variant = node.get_script()
174
185
  if not wanted["script"].is_empty():
package/build/index.js CHANGED
@@ -10126,7 +10126,7 @@ function defectReport(where, error, godotVersion) {
10126
10126
 
10127
10127
  // src/server.ts
10128
10128
  import { execFile as execFile3, spawn } from "node:child_process";
10129
- import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10129
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10130
10130
  import { tmpdir as tmpdir4 } from "node:os";
10131
10131
  import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
10132
10132
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -27985,7 +27985,10 @@ var TOOL_SPECS = [
27985
27985
  parameters: {
27986
27986
  projectPath: PROJECT_PATH,
27987
27987
  preset: { type: "string", description: "Preset name from export_presets.cfg." },
27988
- outputPath: { type: "string", description: "Where the export is written, inside the project." },
27988
+ outputPath: {
27989
+ type: "string",
27990
+ description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
27991
+ },
27989
27992
  debug: { type: "boolean", description: "run: a debug export. Default false." }
27990
27993
  },
27991
27994
  requires: ["projectPath"],
@@ -28394,7 +28397,7 @@ var TOOL_SPECS = [
28394
28397
  says: {
28395
28398
  type: "string",
28396
28399
  ops: ["find"],
28397
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match."
28400
+ description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern."
28398
28401
  },
28399
28402
  limit: {
28400
28403
  type: "number",
@@ -29715,6 +29718,12 @@ class GodotServer {
29715
29718
  if (!engine.ok) {
29716
29719
  return engine.response;
29717
29720
  }
29721
+ const folder = dirname4(output.absolutePath);
29722
+ try {
29723
+ mkdirSync4(folder, { recursive: true });
29724
+ } catch (error) {
29725
+ return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
29726
+ }
29718
29727
  const debug = readBoolean(args, "debug") ?? false;
29719
29728
  const exportArgs = [
29720
29729
  "--headless",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",