gdharness 1.1.45 → 1.1.46

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
@@ -17588,13 +17588,13 @@ var init_tool_definitions = __esm(() => {
17588
17588
  },
17589
17589
  {
17590
17590
  name: "project_test",
17591
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
17591
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
17592
17592
  parameters: {
17593
17593
  projectPath: PROJECT_PATH,
17594
17594
  path: {
17595
17595
  type: ["string", "array"],
17596
17596
  items: { type: "string" },
17597
- description: "A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. A run where any entry is not there is refused before the engine starts, naming each one. Default test."
17597
+ description: "A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. An entry that is not there is left out and named under missing, the rest run, and such a run is never called passed; when no entry is there the run is refused before the engine starts, naming each one. Default test."
17598
17598
  },
17599
17599
  ignore: {
17600
17600
  type: "array",
@@ -17908,7 +17908,7 @@ var init_tool_definitions = __esm(() => {
17908
17908
  },
17909
17909
  {
17910
17910
  name: "editor_run",
17911
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. start and check both wait first for a scan the open editor is running and for the class cache it writes just after, since a game booted in between resolves no global class, as does every other engine this server starts on the project; waitedForEditorScanMs says how long, and scanNote says when a scan ran past thirty seconds and the game started during it. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
17911
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. start and check both wait first for a scan the open editor is running and for the class cache it writes just after, since a game booted in between resolves no global class, as does every other engine this server starts on the project; waitedForEditorScanMs says how long, and scanNote says when a scan ran past thirty seconds and the game started during it. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for, and the note then says what a runtime_* call naming no projectPath meets until it announces. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
17912
17912
  parameters: {
17913
17913
  projectPath: {
17914
17914
  ...PROJECT_PATH,
@@ -18124,7 +18124,7 @@ var init_tool_definitions = __esm(() => {
18124
18124
  property: {
18125
18125
  type: "string",
18126
18126
  ops: ["property", "find"],
18127
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
18127
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. An engine method that reads takes literal arguments in its brackets, words in double quotes, numbers, true, false or null, so a control\'s theme is read where it is drawn: \'get_theme_stylebox("panel"):content_margin_top\' or \'get_theme_constant("separation")\'. A game method is given none in a path, since nothing marks one as only reading; runtime_invoke call takes those. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
18128
18128
  },
18129
18129
  depth: {
18130
18130
  type: "integer",
@@ -18221,7 +18221,7 @@ var init_tool_definitions = __esm(() => {
18221
18221
  property: {
18222
18222
  type: "string",
18223
18223
  ops: ["set"],
18224
- description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. A map keyed by numbers is stepped into by the number, "_game:units:3". The answer reads back off the same holder, and a write that leaves the property as it was is refused and says what it still holds: the engine not taking it and a setter keeping the value look the same, so the refusal names neither. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
18224
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a reading method returned, as runtime_inspect property describes. A map keyed by numbers is stepped into by the number, "_game:units:3". The answer reads back off the same holder, and a write that leaves the property as it was is refused and says what it still holds: the engine not taking it and a setter keeping the value look the same, so the refusal names neither. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
18225
18225
  },
18226
18226
  value: {
18227
18227
  ops: ["set"],
@@ -18231,12 +18231,18 @@ var init_tool_definitions = __esm(() => {
18231
18231
  method: {
18232
18232
  type: "string",
18233
18233
  ops: ["call"],
18234
- description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, a list or a map is stepped into by index or key, "_game:run:roster:0:retire", and a step written as a call walks through what a method taking no arguments returned, "get_viewport():gui_get_focus_owner".'
18234
+ description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, a list or a map is stepped into by index or key, "_game:run:roster:0:retire", and a step written as a call walks through what a reading method returned, "get_viewport():gui_get_focus_owner", as runtime_inspect property describes.'
18235
18235
  },
18236
18236
  args: {
18237
18237
  type: "array",
18238
18238
  ops: ["call"],
18239
- description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
18239
+ description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths. The method itself takes its arguments here only: brackets on it holding arguments are refused.`
18240
+ },
18241
+ properties: {
18242
+ type: ["boolean", "array"],
18243
+ items: { type: "string" },
18244
+ ops: ["call"],
18245
+ description: 'call: read the object the call answered with in the same answer, under result_properties: true for every variable its script declares, or a list of names, each a colon path walked from that object the way property paths are ("dead_allowed", "terms:0:name"). A name that is not there is listed under result_properties_missing with why, and a result that is not an object answers properties_note instead. Without it an object comes back as its class and id only.'
18240
18246
  },
18241
18247
  timeoutMs: {
18242
18248
  type: "integer",
@@ -18318,7 +18324,7 @@ var init_tool_definitions = __esm(() => {
18318
18324
  },
18319
18325
  {
18320
18326
  name: "runtime_input",
18321
- description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: the pointer ops answer with pointer_note there, because get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
18327
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: every pointer answer there carries pointer_on_hidden_desktop, and the first of the run pointer_note saying why it matters: get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
18322
18328
  parameters: {
18323
18329
  projectPath: RUNNING_PROJECT_PATH,
18324
18330
  pid: RUNNING_PID,
@@ -18330,7 +18336,7 @@ var init_tool_definitions = __esm(() => {
18330
18336
  says: {
18331
18337
  type: "string",
18332
18338
  ops: ["click"],
18333
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
18339
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when a control saying exactly the words is hidden, off the screen or covered, since it would stand in for that one; index presses it anyway. An empty field says its placeholder. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
18334
18340
  },
18335
18341
  action: {
18336
18342
  type: "string",
@@ -41895,10 +41901,13 @@ function runtimeVerdict(endpoint, after) {
41895
41901
  return {
41896
41902
  listening: false,
41897
41903
  mayYetAnnounce: after.running,
41898
- note: after.running ? `nothing announced itself within ${after.budgetMs}ms${sized} and ${state}, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms${sized} and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
41904
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms${sized} and ${state}, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}. ${untilItAnnounces(after.servesAProject)}` : `nothing announced itself within ${after.budgetMs}ms${sized} and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
41899
41905
  heldAt: null
41900
41906
  };
41901
41907
  }
41908
+ function untilItAnnounces(servesAProject) {
41909
+ return servesAProject ? "Until it does, a runtime_* call for it waits briefly and is then refused, and another project's game answers only a call whose projectPath names that project." : "Until it does, a runtime_* call naming no projectPath reaches whichever game is the only one announced, another project's included, so pass projectPath to be sure the answer is this game's.";
41910
+ }
41902
41911
  function isOfType(value, kind) {
41903
41912
  switch (kind) {
41904
41913
  case "string":
@@ -42073,18 +42082,29 @@ function settingsNamedBy(op, args) {
42073
42082
  return [];
42074
42083
  }
42075
42084
  }
42085
+ function foldedScriptErrors(errors) {
42086
+ const scriptErrors = errors.filter((error) => !error.message.includes(DEPENDED_FAILED));
42087
+ const own = new Set(scriptErrors.map((error) => error.path));
42088
+ return {
42089
+ scriptErrors,
42090
+ failedThroughDependency: [...new Set(errors.map((error) => error.path))].filter((path) => !own.has(path))
42091
+ };
42092
+ }
42093
+ function nonEmpty(lists) {
42094
+ return Object.fromEntries(Object.entries(lists).filter(([, list]) => list.length > 0));
42095
+ }
42076
42096
  function scriptErrorsNote(errors) {
42097
+ const { scriptErrors: own, failedThroughDependency: dependents } = foldedScriptErrors(errors);
42077
42098
  const causes = new Map;
42078
- for (const error of errors) {
42079
- if (!error.message.includes(DEPENDED_FAILED) && !causes.has(error.path)) {
42099
+ for (const error of own) {
42100
+ if (!causes.has(error.path)) {
42080
42101
  causes.set(error.path, error);
42081
42102
  }
42082
42103
  }
42083
- const dependents = [...new Set(errors.map((error) => error.path))].filter((path) => !causes.has(path));
42084
42104
  const scripts = causes.size + dependents.length;
42085
42105
  const named = [...causes.values()].map((error) => `${error.line === null ? error.path : `${error.path}:${error.line}`} ${error.message}${/[.!?]\)?$/.test(error.message) ? "" : "."}`).join(" ");
42086
42106
  const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
42087
- const more = errors.filter((error) => !error.message.includes(DEPENDED_FAILED)).length - causes.size;
42107
+ const more = own.length - causes.size;
42088
42108
  const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
42089
42109
  return [
42090
42110
  `No tests ran: gdUnit4 could not load ${scripts === 1 ? "one script" : `${scripts} scripts`} while looking for suites, and it runs no suite at all when one fails to load.`,
@@ -42917,7 +42937,8 @@ class GodotServer {
42917
42937
  ...whichGame(args),
42918
42938
  path: readNonEmptyString(args, "nodePath") ?? "",
42919
42939
  method: readString(args, "method") ?? "",
42920
- args: readArray(args, "args") ?? []
42940
+ args: readArray(args, "args") ?? [],
42941
+ ...args["properties"] === undefined ? {} : { properties: args["properties"] }
42921
42942
  }, patience);
42922
42943
  }
42923
42944
  case "runtime_capture":
@@ -43711,9 +43732,9 @@ class GodotServer {
43711
43732
  if (!paths.ok) {
43712
43733
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
43713
43734
  }
43714
- const asked = paths.value;
43715
- const missing = asked.filter((path) => !existsSync19(join24(project.value.path, path.slice("res://".length))));
43716
- if (missing.length > 0) {
43735
+ const missing = paths.value.filter((path) => !existsSync19(join24(project.value.path, path.slice("res://".length))));
43736
+ const asked = paths.value.filter((path) => !missing.includes(path));
43737
+ if (asked.length === 0) {
43717
43738
  const verdict = `nothing at ${missing.join(" and ")}`;
43718
43739
  return {
43719
43740
  content: [
@@ -43830,7 +43851,10 @@ class GodotServer {
43830
43851
  const hung = cutShort?.hung ?? false;
43831
43852
  const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
43832
43853
  const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
43833
- const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
43854
+ const ranAs = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
43855
+ const nothingAt = missing.join(" and ");
43856
+ const verdict = missing.length === 0 ? ranAs : `${ranAs}; nothing at ${nothingAt}`;
43857
+ const missingNote = missing.length === 0 ? undefined : `Nothing at ${nothingAt}, so ${missing.length === 1 ? "it was" : "they were"} left out and only ${asked.join(", ")} ran; a run of fewer paths than were asked for is not called a pass.${elsewhereIn(project.value.path, missing[0] ?? "")}`;
43834
43858
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
43835
43859
  const marker = readNonEmptyString(args, "printed");
43836
43860
  const echoed = marker === undefined ? undefined : run.log.select({ severity: "info", sinceLastCall: false, contains: marker, limit: 200 });
@@ -43839,7 +43863,8 @@ class GodotServer {
43839
43863
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
43840
43864
  };
43841
43865
  if (report === null) {
43842
- const note = nothingRan !== null ? `No tests ran: ${verdict}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
43866
+ const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
43867
+ const note = missingNote === undefined ? said : `${said} ${missingNote}`;
43843
43868
  return {
43844
43869
  content: [
43845
43870
  { type: "text", text: note },
@@ -43853,7 +43878,8 @@ class GodotServer {
43853
43878
  exitSignal: run.exitSignal ?? undefined,
43854
43879
  hung,
43855
43880
  ...timedOut ? { timedOut, silentForMs } : {},
43856
- ...scriptErrors.length > 0 ? { scriptErrors } : {},
43881
+ ...missing.length > 0 ? { missing } : {},
43882
+ ...nonEmpty(foldedScriptErrors(scriptErrors)),
43857
43883
  arguments: cmdArgs,
43858
43884
  entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
43859
43885
  ...printedLines,
@@ -43890,8 +43916,9 @@ class GodotServer {
43890
43916
  });
43891
43917
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
43892
43918
  return this.jsonTextResponse({
43893
- passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0,
43919
+ passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && missing.length === 0,
43894
43920
  verdict,
43921
+ ...missing.length > 0 ? { missing } : {},
43895
43922
  ...timedOut ? { timedOut, hung, silentForMs } : {},
43896
43923
  exitCode,
43897
43924
  exitSignal: run.exitSignal ?? undefined,
@@ -43908,7 +43935,10 @@ class GodotServer {
43908
43935
  ...printedLines,
43909
43936
  savesNote,
43910
43937
  ...scanned,
43911
- note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true),
43938
+ note: [
43939
+ missingNote,
43940
+ ranAs.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true)
43941
+ ].filter((part) => part !== undefined).join(" ") || undefined,
43912
43942
  suites: unclean.map((suite) => ({
43913
43943
  name: suite.name,
43914
43944
  path: suite.path,
@@ -44682,7 +44712,14 @@ class GodotServer {
44682
44712
  async runtimeUp(projectPath, before, budget, withArgs) {
44683
44713
  const budgetMs = budget.ms;
44684
44714
  if (!existsSync19(join24(projectPath, RUNTIME_AUTOLOAD.path))) {
44685
- return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
44715
+ return runtimeVerdict(null, {
44716
+ addon: false,
44717
+ budgetMs,
44718
+ heldAt: null,
44719
+ running: false,
44720
+ withArgs,
44721
+ servesAProject: this.ownProject !== null
44722
+ });
44686
44723
  }
44687
44724
  let asking = null;
44688
44725
  let editorAskedAt = 0;
@@ -44734,6 +44771,7 @@ class GodotServer {
44734
44771
  heldAt: this.dapClient?.whereItStopped() ?? null,
44735
44772
  running: endpoint !== null || going !== null && await this.runStillGoing(going),
44736
44773
  withArgs,
44774
+ servesAProject: this.ownProject !== null,
44737
44775
  ...going?.throughEditor === true ? { playingSeen: going.seenPlaying === true } : {}
44738
44776
  });
44739
44777
  }
@@ -47978,7 +48016,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
47978
48016
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
47979
48017
  | What a property reads right now, on a node whose path you already hold | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
47980
48018
  | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
47981
- | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments that only reads and walks into what it returned |
48019
+ | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method that only reads and walks into what it returned; an engine method takes literal arguments in the brackets, \`get_theme_constant("separation")\` |
47982
48020
  | Press a button | \`runtime_input click\` with \`says\` and the words on it, which finds it and presses it in the same frame and says what was under the pointer. A \`find\` and then a click by the path it answered is two calls over a generated path, and a panel that rebuilds between them frees the button first; \`nodePath\` narrows where \`says\` looks, and several matches are refused with an \`index\` for each |
47983
48021
  | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows (no \`nodePath\` needed: without one the whole game is searched), then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
47984
48022
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
@@ -233,13 +233,17 @@ func call_method(params: Dictionary) -> Dictionary:
233
233
  var read_from: int = Time.get_ticks_usec()
234
234
  var answered: Variant = Paths.read_under(reached["holder"], spelled)
235
235
  var read_for: int = Time.get_ticks_usec() - read_from
236
- return {
236
+ var listed: Dictionary = {
237
237
  "type": "method_result",
238
238
  "path": node_path,
239
239
  "method": method,
240
240
  "result": _values.serialize(answered),
241
241
  "elapsed_usec": read_for,
242
242
  }
243
+ var asked_of_it: Variant = params.get("properties")
244
+ if _reads_the_result(asked_of_it):
245
+ listed.merge(_read_off_result(answered, asked_of_it))
246
+ return listed
243
247
  var why: String = (
244
248
  "%s:%s takes no arguments" % [reached["called"], spelled]
245
249
  if Paths.can_read(reached["holder"], spelled)
@@ -258,10 +262,11 @@ func call_method(params: Dictionary) -> Dictionary:
258
262
  # The method a call ends on is the one being called whether or not it carries the brackets a
259
263
  # step along the way would: "get_viewport():gui_get_focus_owner()" is the same call as without
260
264
  # the last pair.
265
+ var not_called: String = _not_a_method_to_call(holder, named, str(reached["called"]))
266
+ if not not_called.is_empty():
267
+ return {"type": "error", "message": not_called}
261
268
  if not Paths.method_of(named).is_empty():
262
269
  named = Paths.method_of(named)
263
- if not holder.has_method(named):
264
- return {"type": "error", "message": "%s has no method %s" % [reached["called"], named]}
265
270
 
266
271
  # Checked before the call rather than left to it. `callv` raises inside the game when an
267
272
  # argument cannot be converted, and an error raised in a game somebody is playing holds it at a
@@ -360,9 +365,98 @@ func call_method(params: Dictionary) -> Dictionary:
360
365
  answer["errors"] = caught.raised.slice(0, ERRORS_KEPT)
361
366
  if caught.raised.size() > ERRORS_KEPT:
362
367
  answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
368
+ var asked: Variant = params.get("properties")
369
+ if _reads_the_result(asked):
370
+ answer.merge(_read_off_result(result, asked))
363
371
  return answer
364
372
 
365
373
 
374
+ ## Why [param named], the last step of a call's method, names no method of [param holder] to call, or
375
+ ## "" when it does. Brackets on it holding arguments are refused rather than read: the arguments are
376
+ ## taken from args alone, so a call is never made with half of them in one place and half in the
377
+ ## other, or with the bracketed ones quietly dropped.
378
+ static func _not_a_method_to_call(holder: Object, named: String, called: String) -> String:
379
+ var method: String = named
380
+ if not Paths.method_of(named).is_empty():
381
+ var bracketed: Dictionary = Paths.arguments_of(named)
382
+ var in_brackets: Array = bracketed.get("args", [])
383
+ if bracketed.has("message") or not in_brackets.is_empty():
384
+ return (
385
+ "%s:%s: the method a call ends on takes its arguments under args, not in its brackets"
386
+ % [called, named]
387
+ )
388
+ method = Paths.method_of(named)
389
+ return "" if holder.has_method(method) else "%s has no method %s" % [called, method]
390
+
391
+
392
+ ## Whether a call's `properties` asks for its result to be read: true, or a list of names.
393
+ static func _reads_the_result(asked: Variant) -> bool:
394
+ return asked is Array or (asked is bool and asked)
395
+
396
+
397
+ ## What [param asked] reads off the object a call answered with: every variable its script declares
398
+ ## when [param asked] is true, or the properties a list names, each a path walked from the object.
399
+ ##
400
+ ## In the same answer because an object handed back is otherwise only its class and an id, and
401
+ ## reading one field of it meant finding the same object again by a path into the game's private
402
+ ## state, which works only where the caller already knows a key. A name that is not there is listed
403
+ ## rather than refusing the answer, since the call has run by then and its result is what was asked.
404
+ func _read_off_result(result: Variant, asked: Variant) -> Dictionary:
405
+ if result is Object and not is_instance_valid(result):
406
+ return {
407
+ "properties_note": "the call answered an object that has since been freed, so nothing was read"
408
+ }
409
+ if not result is Object:
410
+ return {
411
+ "properties_note":
412
+ "the call answered %s, which has no properties to read" % type_string(typeof(result))
413
+ }
414
+ var object: Object = result
415
+ var wanted: Array[String] = []
416
+ if asked is Array:
417
+ for name: Variant in asked:
418
+ wanted.append(str(name))
419
+ else:
420
+ wanted = _script_variables(object)
421
+ var read: Dictionary = {}
422
+ var missing: Array[String] = []
423
+ for path: String in wanted:
424
+ var walked: Dictionary = _walk_from(object, path)
425
+ if walked.has("message"):
426
+ missing.append(str(walked["message"]))
427
+ else:
428
+ read[path] = _values.serialize(walked["value"])
429
+ var answered: Dictionary = {"result_properties": read}
430
+ if not missing.is_empty():
431
+ answered["result_properties_missing"] = missing
432
+ return answered
433
+
434
+
435
+ ## The variables the script attached to [param object] declares, in the order it declares them.
436
+ static func _script_variables(object: Object) -> Array[String]:
437
+ var names: Array[String] = []
438
+ for entry: Dictionary in object.get_property_list():
439
+ var usage: int = entry.get("usage", 0)
440
+ if usage & PROPERTY_USAGE_SCRIPT_VARIABLE != 0:
441
+ names.append(str(entry.get("name", "")))
442
+ return names
443
+
444
+
445
+ ## What [param path] reads walked from [param object], as {"value"}, or {"message"} naming the step
446
+ ## that is not there, by the same rules a path read from a node follows.
447
+ static func _walk_from(object: Object, path: String) -> Dictionary:
448
+ var holder: Variant = object
449
+ var called: String = "the result"
450
+ for named: String in Paths.steps_of(path):
451
+ if not (holder is Object or holder is Array or holder is Dictionary or Paths.packed(holder)):
452
+ return {"message": "%s holds no object to read %s off" % [called, named]}
453
+ if not Paths.can_read(holder, named):
454
+ return {"message": Paths.nothing_there(holder, named, called)}
455
+ holder = Paths.read_under(holder, named)
456
+ called = "%s:%s" % [called, named]
457
+ return {"value": holder}
458
+
459
+
366
460
  ## Why [param given] arguments are the wrong number for [param method], or "" when they are not.
367
461
  ##
368
462
  ## Asked before the call, because `callv` given the wrong number refuses inside the game and returns
@@ -25,6 +25,9 @@ const HEADLESS_VIEWPORT: Vector2 = Vector2(64, 64)
25
25
  ## everything that process starts. Kept in step with `DESKTOP_VARIABLE` in src/desktop.ts.
26
26
  const DESKTOP_VARIABLE: String = "GDHARNESS_DESKTOP"
27
27
 
28
+ ## Whether this run has had the pointer note in full; the game process is the run.
29
+ static var _pointer_explained: bool = false
30
+
28
31
  var _host: Node
29
32
  var _values: Values
30
33
 
@@ -60,13 +63,21 @@ func _init(host: Node, values: Values) -> void:
60
63
  ## that would take the screen. What reads the injected event is right, tooltips included, since the
61
64
  ## engine places a tooltip from the motion that reached the control.
62
65
  static func _pointer_noted(answer: Dictionary) -> Dictionary:
63
- if OS.get_environment(DESKTOP_VARIABLE) != "":
66
+ if OS.get_environment(DESKTOP_VARIABLE) == "":
67
+ return answer
68
+ # The fact on every answer and the explanation on the first of the run: a session sends dozens of
69
+ # pointer calls to one game, and the same four hundred characters on each was most of what came
70
+ # back.
71
+ answer["pointer_on_hidden_desktop"] = true
72
+ if not _pointer_explained:
73
+ _pointer_explained = true
64
74
  answer["pointer_note"] = (
65
75
  "This game is on gdharness's own desktop, where Windows refuses to say where the"
66
76
  + " pointer is, so get_mouse_position() on the root window, and get_global_mouse_position()"
67
77
  + " and get_local_mouse_position() on what is drawn there, read a meaningless position"
68
78
  + " rather than where this put it. What reads the event itself gets this position: the"
69
- + " control under the pointer, its hover and tooltip, and the game's input handlers."
79
+ + " control under the pointer, its hover and tooltip, and the game's input handlers. Said"
80
+ + " once a run; pointer_on_hidden_desktop marks every answer it applies to."
70
81
  )
71
82
  return answer
72
83
 
@@ -83,19 +83,21 @@ const READS_ONLY: String = ", and a path, which a wait reads every frame, only c
83
83
  ## at a time rather than handed to that method, which answers null for a path that goes wrong
84
84
  ## halfway along and for one that ends on null.
85
85
  ##
86
- ## A step written as a call, "get_viewport()", calls a method of that name that takes no arguments
87
- ## and walks into what it returned. Some of what hangs off a node is only reachable by asking:
88
- ## which control holds the focus is a question for the viewport a control is in, and the viewport
89
- ## is a method's answer rather than a property. Spelled with the brackets so a reader sees a call
90
- ## where one happens, and so a method can never shadow a property of the same name. A list or a map
91
- ## answers the calls that read it, "board:size()" or "inbox:keys()", listed in [constant LIST_CALLS]
92
- ## and [constant MAP_CALLS].
86
+ ## A step written as a call, "get_viewport()", calls a method of that name and walks into what it
87
+ ## returned. Some of what hangs off a node is only reachable by asking: which control holds the
88
+ ## focus is a question for the viewport a control is in, and the viewport is a method's answer
89
+ ## rather than a property. Spelled with the brackets so a reader sees a call where one happens, and
90
+ ## so a method can never shadow a property of the same name. A list or a map answers the calls that
91
+ ## read it, "board:size()" or "inbox:keys()", listed in [constant LIST_CALLS] and
92
+ ## [constant MAP_CALLS]. An engine method that reads takes literal arguments in the brackets,
93
+ ## "get_theme_stylebox(\"panel\"):content_margin_top", since what a control's theme gives it is only
94
+ ## reachable by naming what is asked for; see [method arguments_of].
93
95
  ##
94
96
  ## Answers with a `message` instead when a step along the way is not there or holds something that
95
97
  ## is not an object, naming the step rather than the whole path: "/root/Main:_game has no property
96
98
  ## clocks" is a typo found, and "no property _game:clocks:speed" is a puzzle.
97
99
  static func walk_to(node: Node, node_path: String, reaching: String) -> Dictionary:
98
- var parts: PackedStringArray = reaching.split(":")
100
+ var parts: PackedStringArray = steps_of(reaching)
99
101
  var holder: Variant = node
100
102
  var called: String = node_path
101
103
  for step: int in parts.size() - 1:
@@ -187,26 +189,33 @@ static func nothing_under(holder: Variant, named: String, called: String) -> Str
187
189
  ## first one. An index reads the way a property does, and a negative one counts from the end the
188
190
  ## way GDScript's own does, so reading the last of something does not mean asking how many first.
189
191
  static func can_read(holder: Variant, named: String) -> bool:
192
+ var method: String = method_of(named)
193
+ var given: Dictionary = arguments_of(named) if not method.is_empty() else {"args": []}
194
+ if given.has("message"):
195
+ return false
196
+ var args: Array = given["args"]
190
197
  if holder is Array:
191
198
  var items: Array = holder
192
- var list_call: String = method_of(named)
193
- if not list_call.is_empty():
194
- return list_call in LIST_CALLS and not (items.is_empty() and list_call in NEEDS_AN_ELEMENT)
199
+ if not method.is_empty():
200
+ return (
201
+ args.is_empty()
202
+ and method in LIST_CALLS
203
+ and not (items.is_empty() and method in NEEDS_AN_ELEMENT)
204
+ )
195
205
  return _index_in(named, items.size()) != -1
196
206
  if packed(holder):
197
- if not method_of(named).is_empty():
198
- return method_of(named) in PACKED_CALLS
207
+ if not method.is_empty():
208
+ return args.is_empty() and method in PACKED_CALLS
199
209
  return _index_in(named, len(holder)) != -1
200
210
  if holder is Dictionary:
201
211
  var map: Dictionary = holder
202
- if not method_of(named).is_empty():
203
- return method_of(named) in MAP_CALLS
212
+ if not method.is_empty():
213
+ return args.is_empty() and method in MAP_CALLS
204
214
  return not _key_in(map, named).is_empty()
205
215
  if holder is Object:
206
216
  var object: Object = holder
207
- var method: String = method_of(named)
208
217
  if not method.is_empty():
209
- return call_refused(object, method, "").is_empty()
218
+ return call_refused(object, method, "", args.size()).is_empty()
210
219
  return _has_property(object, named)
211
220
  return false
212
221
 
@@ -236,24 +245,103 @@ static func _key_in(map: Dictionary, named: String) -> Array:
236
245
  return []
237
246
 
238
247
 
239
- ## The method a step written as a call names, "get_viewport()" being get_viewport, or "" for a
240
- ## step that is a name.
248
+ ## [param reaching] split into its steps at each colon, except one inside a call's brackets or a
249
+ ## quoted argument: `get_node("/root/Main"):name` is two steps, and split at every colon a quoted
250
+ ## argument holding one became a step that names nothing.
251
+ static func steps_of(reaching: String) -> PackedStringArray:
252
+ var steps: PackedStringArray = []
253
+ var step: String = ""
254
+ var depth: int = 0
255
+ var quoted: bool = false
256
+ var escaped: bool = false
257
+ for character: String in reaching:
258
+ if quoted:
259
+ if escaped:
260
+ escaped = false
261
+ elif character == "\\":
262
+ escaped = true
263
+ elif character == '"':
264
+ quoted = false
265
+ elif character == '"':
266
+ quoted = true
267
+ elif character == "(":
268
+ depth += 1
269
+ elif character == ")":
270
+ depth = maxi(depth - 1, 0)
271
+ elif character == ":" and depth == 0:
272
+ var _kept: bool = steps.push_back(step)
273
+ step = ""
274
+ continue
275
+ step += character
276
+ var _last: bool = steps.push_back(step)
277
+ return steps
278
+
279
+
280
+ ## The method a step written as a call names, "get_viewport()" being get_viewport and
281
+ ## `get_theme_constant("separation")` get_theme_constant, or "" for a step that is a name.
241
282
  static func method_of(named: String) -> String:
242
- return named.trim_suffix("()") if named.ends_with("()") else ""
283
+ if not named.ends_with(")"):
284
+ return ""
285
+ var opened: int = named.find("(")
286
+ if opened < 1:
287
+ return ""
288
+ var method: String = named.substr(0, opened)
289
+ return method if method.is_valid_ascii_identifier() else ""
243
290
 
244
291
 
245
- ## How many arguments [param method] on [param object] has to be given, or -1 when there is no such
246
- ## method. Read off the method list rather than [method Object.has_method], because a path can only
247
- ## call a method with nothing, and a method with a required argument called with nothing raises
248
- ## inside the game rather than answering.
249
- static func _arguments_required(object: Object, method: String) -> int:
292
+ ## The arguments a call step gives in its brackets, as {"args"}, or {"message"} saying why they are
293
+ ## not ones a path can give.
294
+ ##
295
+ ## Literals only: words in double quotes, numbers, true, false and null, read as JSON reads them, a
296
+ ## whole number as an int. A path names what is read, and an argument that was itself a path or an
297
+ ## object would make a read depend on other state in a way the step does not show.
298
+ static func arguments_of(named: String) -> Dictionary:
299
+ var inside: String = named.substr(named.find("(") + 1, named.length() - named.find("(") - 2).strip_edges()
300
+ if inside.is_empty():
301
+ return {"args": []}
302
+ var reader: JSON = JSON.new()
303
+ var parsed: Variant = reader.data if reader.parse("[%s]" % inside) == OK else null
304
+ if not parsed is Array:
305
+ return {"message": _not_literals(named)}
306
+ var args: Array = []
307
+ for given: Variant in parsed:
308
+ if given is float:
309
+ var number: float = given
310
+ if number == floorf(number) and absf(number) < 9.0e15:
311
+ args.append(int(number))
312
+ else:
313
+ args.append(number)
314
+ elif given == null or given is String or given is bool:
315
+ args.append(given)
316
+ else:
317
+ return {"message": _not_literals(named)}
318
+ return {"args": args}
319
+
320
+
321
+ static func _not_literals(named: String) -> String:
322
+ return (
323
+ "%s: a path gives a call literal arguments only, words in double quotes, numbers, true, false or null"
324
+ % named
325
+ )
326
+
327
+
328
+ ## How many arguments [param method] on [param object] takes, as {"least", "most", "any_more"}, or
329
+ ## an empty map when there is no such method. Read off the method list rather than
330
+ ## [method Object.has_method], because a method given too few or too many raises inside the game
331
+ ## rather than answering.
332
+ static func _arity(object: Object, method: String) -> Dictionary:
250
333
  for entry: Dictionary in object.get_method_list():
251
334
  if str(entry.get("name", "")) != method:
252
335
  continue
253
336
  var params: Array = entry.get("args", [])
254
337
  var defaults: Array = entry.get("default_args", [])
255
- return params.size() - defaults.size()
256
- return -1
338
+ var flags: int = entry.get("flags", 0)
339
+ return {
340
+ "least": params.size() - defaults.size(),
341
+ "most": params.size(),
342
+ "any_more": flags & METHOD_FLAG_VARARG != 0,
343
+ }
344
+ return {}
257
345
 
258
346
 
259
347
  ## Why a path may not call [param method] on [param object], said about [param called_as], or ""
@@ -265,16 +353,36 @@ static func _arguments_required(object: Object, method: String) -> int:
265
353
  ## doing something and answering nothing, and one left untyped is trusted. Without this a find
266
354
  ## with property "queue_free()" freed every node it matched, and a wait on "advance_day()" called
267
355
  ## it once a frame and then reported the state it had driven the game to.
268
- static func call_refused(object: Object, method: String, called_as: String) -> String:
269
- var required: int = _arguments_required(object, method)
270
- if required == -1:
356
+ ##
357
+ ## Given [param given] arguments, a call is the engine's and marked as reading, or it is refused. A
358
+ ## game's method taking arguments is trusted on nothing at all: one answering a value can as well
359
+ ## change what it was asked about, and runtime_invoke call is how one is called on purpose.
360
+ static func call_refused(object: Object, method: String, called_as: String, given: int = 0) -> String:
361
+ var arity: Dictionary = _arity(object, method)
362
+ if arity.is_empty():
271
363
  return "%s has no method %s" % [called_as, method]
272
- if required > 0:
364
+ var least: int = arity["least"]
365
+ var most: int = arity["most"]
366
+ var any_more: bool = arity["any_more"]
367
+ var scripted: Dictionary = _script_method(object, method)
368
+ if not scripted.is_empty() and (given > 0 or least > 0):
369
+ var why: String = (
370
+ "since nothing marks one as only reading%s; runtime_invoke call takes them" % READS_ONLY
371
+ )
273
372
  return (
274
- "%s.%s takes %d argument%s, and a path can only call a method that takes none"
275
- % [called_as, method, required, "" if required == 1 else "s"]
373
+ "%s.%s is the game's own and a path gives a game's method no arguments, %s"
374
+ % [called_as, method, why]
375
+ )
376
+ if given < least or (given > most and not any_more):
377
+ var takes: String = "%d" % least
378
+ if any_more:
379
+ takes = "at least %d" % least
380
+ elif most != least:
381
+ takes = "%d to %d" % [least, most]
382
+ return (
383
+ '%s.%s takes %s argument%s and the path gives it %d, in the brackets as in %s("name")'
384
+ % [called_as, method, takes, "" if takes == "1" else "s", given, method]
276
385
  )
277
- var scripted: Dictionary = _script_method(object, method)
278
386
  if not scripted.is_empty():
279
387
  var returned: Dictionary = scripted.get("return", {})
280
388
  var usage: int = returned.get("usage", 0)
@@ -350,7 +458,8 @@ static func read_under(holder: Variant, named: String) -> Variant:
350
458
  var object: Object = holder
351
459
  var method: String = method_of(named)
352
460
  if not method.is_empty():
353
- return object.call(method)
461
+ var args: Array = arguments_of(named)["args"]
462
+ return object.callv(method, args)
354
463
  return object.get(named)
355
464
 
356
465
 
@@ -374,20 +483,25 @@ static func _index_in(named: String, size: int) -> int:
374
483
  ## the whole path, because the step is the typo.
375
484
  static func nothing_there(holder: Variant, named: String, called_as: String) -> String:
376
485
  var container_call: String = method_of(named)
486
+ var given: Dictionary = arguments_of(named) if not container_call.is_empty() else {"args": []}
487
+ if given.has("message"):
488
+ return "%s:%s" % [called_as, given["message"]]
489
+ var given_args: Array = given["args"]
490
+ var given_count: int = given_args.size()
377
491
  if holder is Array:
378
492
  var items: Array = holder
379
493
  if container_call in NEEDS_AN_ELEMENT and items.is_empty():
380
494
  return "%s is an empty list, so it has no %s" % [called_as, named]
381
495
  if not container_call.is_empty():
382
- return container_calls_refused(called_as, "a list", container_call)
496
+ return container_calls_refused(called_as, "a list", container_call, given_count)
383
497
  return "%s is a list of %d, so there is no %s in it" % [called_as, items.size(), named]
384
498
  if packed(holder):
385
499
  if not container_call.is_empty():
386
- return container_calls_refused(called_as, "a packed list", container_call)
500
+ return container_calls_refused(called_as, "a packed list", container_call, given_count)
387
501
  return "%s is a packed list of %d, so there is no %s in it" % [called_as, len(holder), named]
388
502
  if holder is Dictionary:
389
503
  if not container_call.is_empty():
390
- return container_calls_refused(called_as, "a map", container_call)
504
+ return container_calls_refused(called_as, "a map", container_call, given_count)
391
505
  var map: Dictionary = holder
392
506
  var keys: Array = map.keys()
393
507
  # A key reading as the step and still not reached is one of a type a step cannot spell, and
@@ -410,7 +524,7 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
410
524
  var object: Object = holder
411
525
  var method: String = method_of(named)
412
526
  if not method.is_empty():
413
- return call_refused(object, method, called_as)
527
+ return call_refused(object, method, called_as, given_count)
414
528
  # A method spelled as a property is the likeliest thing behind a name the object has no property
415
529
  # for, and the caller is one pair of brackets away from what they meant.
416
530
  if object.has_method(named):
@@ -422,13 +536,18 @@ static func nothing_there(holder: Variant, named: String, called_as: String) ->
422
536
 
423
537
 
424
538
  ## Why [param method] is not a call a path makes on [param sort] ("a list", "a packed list" or "a
425
- ## map"), naming the calls it does make.
426
- static func container_calls_refused(called_as: String, sort: String, method: String) -> String:
539
+ ## map"), naming the calls it does make, or that it makes this one with no arguments when
540
+ ## [param given] were.
541
+ static func container_calls_refused(
542
+ called_as: String, sort: String, method: String, given: int = 0
543
+ ) -> String:
427
544
  var calls: Array[String] = MAP_CALLS
428
545
  if sort == "a list":
429
546
  calls = LIST_CALLS
430
547
  elif sort == "a packed list":
431
548
  calls = PACKED_CALLS
549
+ if given > 0 and method in calls:
550
+ return "%s is %s, and a path calls %s() on one with no arguments" % [called_as, sort, method]
432
551
  var spelled: Array[String] = []
433
552
  for each: String in calls:
434
553
  spelled.append(each + "()")
@@ -42,12 +42,19 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
42
42
  var said: Array[String] = []
43
43
  var exact: Array[bool] = []
44
44
  var hidden: int = 0
45
+ # Controls saying exactly the words that a click cannot reach, kept to say what a match on screen
46
+ # that only says them as part of more stands in for.
47
+ var exact_out_of_reach: Array[Node] = []
48
+ var out_of_reach_as: Array[String] = []
45
49
  var pending: Array[Node] = [reached["node"]]
46
50
  while not pending.is_empty():
47
51
  var node: Node = pending.pop_front()
48
52
  if node is Control and Says.says(node, wanted):
49
53
  if not Queries.shown(node):
50
54
  hidden += 1
55
+ if _says_exactly(Words.said_by(node), wanted):
56
+ exact_out_of_reach.append(node)
57
+ out_of_reach_as.append("hidden")
51
58
  else:
52
59
  var control: Control = node
53
60
  var target: Control = _pressed_through(control)
@@ -74,12 +81,18 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
74
81
  for index: int in matched.size():
75
82
  if _off_screen(matched[index]):
76
83
  outside += 1
84
+ if exact[index]:
85
+ exact_out_of_reach.append(matched[index])
86
+ out_of_reach_as.append("outside what the screen shows")
77
87
  continue
78
88
  var over: Node = _cover_of(matched[index], drawn, areas)
79
89
  if over == null:
80
90
  open.append(index)
81
91
  else:
82
92
  covered += 1
93
+ if exact[index]:
94
+ exact_out_of_reach.append(matched[index])
95
+ out_of_reach_as.append("under %s, which is drawn over it" % over.get_path())
83
96
  if cover == null:
84
97
  cover = over
85
98
  # Stable, so matches of one rank keep the order they are drawn in.
@@ -92,10 +105,12 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
92
105
  var targets: Array[Control] = []
93
106
  var ranks: Array[int] = []
94
107
  var lines: Array[String] = []
108
+ var wholes: Array[bool] = []
95
109
  for index: int in open:
96
110
  targets.append(matched[index])
97
111
  ranks.append(_rank(matched[index], exact[index]))
98
112
  lines.append(said[index])
113
+ wholes.append(exact[index])
99
114
 
100
115
  var under: String = "" if root_path == "/root" else " under %s" % root_path
101
116
  if targets.is_empty():
@@ -103,8 +118,15 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
103
118
  "type": "error",
104
119
  "message":
105
120
  (
106
- 'no control on screen%s says "%s", so nothing was clicked%s%s%s'
107
- % [under, wanted, _hidden_note(hidden), _outside_note(outside), _covered_note(covered, cover)]
121
+ 'no control on screen%s says "%s", so nothing was clicked%s%s%s%s'
122
+ % [
123
+ under,
124
+ wanted,
125
+ _hidden_note(hidden),
126
+ _outside_note(outside),
127
+ _covered_note(covered, cover),
128
+ _open_menu_note(root, wanted),
129
+ ]
108
130
  )
109
131
  }
110
132
  var best: int = ranks.count(ranks[0])
@@ -129,7 +151,33 @@ static func control_saying(root: Node, root_path: String, wanted: String, which:
129
151
  % [str(which), targets.size(), plural, under, wanted, _candidates(targets, lines)]
130
152
  )
131
153
  }
154
+ # A word inside a sentence on screen, while a control saying exactly that word is out of reach: the
155
+ # sentence is a stand-in, not the control meant. A tab reading "Out" sat in a collapsed drawer, and
156
+ # a click on it pressed a dispatch saying somebody "fell out in the hall". Pressed only when asked
157
+ # for by index, which is the caller saying the sentence is what they meant.
158
+ if which == null and not wholes[index] and not exact_out_of_reach.is_empty():
159
+ return {
160
+ "type": "error",
161
+ "message":
162
+ (
163
+ (
164
+ 'no control on screen%s says exactly "%s", so nothing was clicked: %s %s says it as part of'
165
+ + ' "%s", while %s; pass index %d to press that one anyway, or bring the other into view'
166
+ )
167
+ % [
168
+ under,
169
+ wanted,
170
+ "the one on screen," if targets.size() == 1 else "the best match on screen,",
171
+ targets[index].get_path(),
172
+ _shortened(lines[index]),
173
+ _out_of_reach_exactly(exact_out_of_reach, out_of_reach_as),
174
+ index,
175
+ ]
176
+ )
177
+ }
132
178
  var found: Dictionary = {"says": wanted, "index": index, "of": targets.size()}
179
+ if not wholes[index]:
180
+ found["partOf"] = _shortened(lines[index])
133
181
  if which == null and targets.size() > 1:
134
182
  var others: String = (
135
183
  "the other one on screen %s" % _passed_over(ranks[0], true)
@@ -515,10 +563,48 @@ static func _layer_of(control: Control) -> int:
515
563
  static func _candidates(targets: Array[Control], said: Array[String]) -> String:
516
564
  var named: Array[String] = []
517
565
  for index: int in mini(targets.size(), LISTED_CANDIDATES):
518
- var words: String = said[index].replace("\n", " ")
519
- if words.length() > 60:
520
- words = words.substr(0, 57) + "..."
521
- named.append('%d %s ("%s")' % [index, targets[index].get_path(), words])
566
+ named.append('%d %s ("%s")' % [index, targets[index].get_path(), _shortened(said[index])])
522
567
  if targets.size() > LISTED_CANDIDATES:
523
568
  named.append("and %d more" % (targets.size() - LISTED_CANDIDATES))
524
569
  return "; ".join(named)
570
+
571
+
572
+ ## What to add to a refusal that found nothing on screen, when an open menu has an item saying the
573
+ ## words: a menu draws its items rather than holding a control for each, so a click finds none, while
574
+ ## a wait for the words finds the item. A wait met on "Leave the hall" was followed by a click on it
575
+ ## refused as nothing on screen saying it, with no word of the call that chooses it.
576
+ static func _open_menu_note(root: Node, wanted: String) -> String:
577
+ var pending: Array[Node] = [root]
578
+ while not pending.is_empty():
579
+ var node: Node = pending.pop_back()
580
+ var menu: PopupMenu = node as PopupMenu
581
+ if menu != null and menu.visible:
582
+ for item: int in menu.item_count:
583
+ var text: String = menu.get_item_text(item)
584
+ if Says.matches(text, wanted):
585
+ var owner: Node = menu.get_parent()
586
+ var named: Node = owner if owner is MenuButton or owner is OptionButton else menu
587
+ return (
588
+ (
589
+ '; the open menu %s has an item saying it, "%s", which a click cannot reach:'
590
+ + ' choose it with runtime_input choose, path %s and text "%s"'
591
+ )
592
+ % [menu.get_path(), text, named.get_path(), text]
593
+ )
594
+ pending.append_array(node.get_children(true))
595
+ return ""
596
+
597
+
598
+ ## [param words] on one line and at most sixty characters, for naming a control by what it says.
599
+ static func _shortened(words: String) -> String:
600
+ var flat: String = words.replace("\n", " ").strip_edges()
601
+ return flat if flat.length() <= 60 else flat.substr(0, 57) + "..."
602
+
603
+
604
+ ## The controls in [param nodes], each saying the words exactly and out of a click's reach for the
605
+ ## reason [param why] holds at the same place, as the end of a sentence.
606
+ static func _out_of_reach_exactly(nodes: Array[Node], why: Array[String]) -> String:
607
+ var first: String = "%s, %s" % [nodes[0].get_path(), why[0]]
608
+ if nodes.size() == 1:
609
+ return "1 control says exactly that and cannot be clicked: %s" % first
610
+ return "%d controls say exactly that and cannot be clicked, such as %s" % [nodes.size(), first]
@@ -139,12 +139,16 @@ static func _text_of(node: Node) -> String:
139
139
  if not text is String:
140
140
  return ""
141
141
  var words: String = text
142
- # What somebody typed is drawn as typed, never translated.
142
+ # What somebody typed is drawn as typed, never translated. An empty field draws its placeholder
143
+ # instead, which is what a player reads it by: a search field was found by nothing it showed.
143
144
  if node is LineEdit:
144
145
  var field: LineEdit = node
146
+ if words.is_empty():
147
+ return field.atr(field.placeholder_text).strip_edges()
145
148
  return masked(field) if field.secret else words.strip_edges()
146
149
  if node is TextEdit:
147
- return words.strip_edges()
150
+ var area: TextEdit = node
151
+ return (area.atr(area.placeholder_text) if words.is_empty() else words).strip_edges()
148
152
  words = node.atr(words)
149
153
  if node is Label3D:
150
154
  var sign_text: Label3D = node
package/build/index.js CHANGED
@@ -33085,13 +33085,13 @@ var TOOL_SPECS = [
33085
33085
  },
33086
33086
  {
33087
33087
  name: "project_test",
33088
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
33088
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found, and the rebuild's answer is under classes, with the classes each addon declares counted under addedInAddons and changedInAddons rather than named: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A script that does not load stops gdUnit4 before any suite runs: its errors come back in full under scriptErrors, and the scripts that failed only because one they depend on did are listed by path under failedThroughDependency. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
33089
33089
  parameters: {
33090
33090
  projectPath: PROJECT_PATH,
33091
33091
  path: {
33092
33092
  type: ["string", "array"],
33093
33093
  items: { type: "string" },
33094
- description: "A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. A run where any entry is not there is refused before the engine starts, naming each one. Default test."
33094
+ description: "A test directory or suite file inside the project, or a list of them, all run on one engine: a named set of suites boots once rather than once per suite. An entry given twice, or a suite inside a directory also listed, runs once. An entry that is not there is left out and named under missing, the rest run, and such a run is never called passed; when no entry is there the run is refused before the engine starts, naming each one. Default test."
33095
33095
  },
33096
33096
  ignore: {
33097
33097
  type: "array",
@@ -33405,7 +33405,7 @@ var TOOL_SPECS = [
33405
33405
  },
33406
33406
  {
33407
33407
  name: "editor_run",
33408
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. start and check both wait first for a scan the open editor is running and for the class cache it writes just after, since a game booted in between resolves no global class, as does every other engine this server starts on the project; waitedForEditorScanMs says how long, and scanNote says when a scan ran past thirty seconds and the game started during it. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
33408
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. start and check both wait first for a scan the open editor is running and for the class cache it writes just after, since a game booted in between resolves no global class, as does every other engine this server starts on the project; waitedForEditorScanMs says how long, and scanNote says when a scan ran past thirty seconds and the game started during it. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for, and the note then says what a runtime_* call naming no projectPath meets until it announces. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
33409
33409
  parameters: {
33410
33410
  projectPath: {
33411
33411
  ...PROJECT_PATH,
@@ -33621,7 +33621,7 @@ var TOOL_SPECS = [
33621
33621
  property: {
33622
33622
  type: "string",
33623
33623
  ops: ["property", "find"],
33624
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
33624
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. An engine method that reads takes literal arguments in its brackets, words in double quotes, numbers, true, false or null, so a control\'s theme is read where it is drawn: \'get_theme_stylebox("panel"):content_margin_top\' or \'get_theme_constant("separation")\'. A game method is given none in a path, since nothing marks one as only reading; runtime_invoke call takes those. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
33625
33625
  },
33626
33626
  depth: {
33627
33627
  type: "integer",
@@ -33718,7 +33718,7 @@ var TOOL_SPECS = [
33718
33718
  property: {
33719
33719
  type: "string",
33720
33720
  ops: ["set"],
33721
- description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a method taking no arguments returned. A map keyed by numbers is stepped into by the number, "_game:units:3". The answer reads back off the same holder, and a write that leaves the property as it was is refused and says what it still holds: the engine not taking it and a setter keeping the value look the same, so the refusal names neither. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
33721
+ description: 'set: which one to write. Colons write through what a node holds, "_game:run:day", and a number or a key steps into a list or a map on the way, "_game:run:roster:0:name", a negative number counting from the end, and a step written as a call, "get_viewport():gui_embed_subwindows", walks through what a reading method returned, as runtime_inspect property describes. A map keyed by numbers is stepped into by the number, "_game:units:3". The answer reads back off the same holder, and a write that leaves the property as it was is refused and says what it still holds: the engine not taking it and a setter keeping the value look the same, so the refusal names neither. A write of the value the property already holds, at its own precision, as a float given in 64 bits to a property keeping 32, is not refused: it answers unchanged: true.'
33722
33722
  },
33723
33723
  value: {
33724
33724
  ops: ["set"],
@@ -33728,12 +33728,18 @@ var TOOL_SPECS = [
33728
33728
  method: {
33729
33729
  type: "string",
33730
33730
  ops: ["call"],
33731
- description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, a list or a map is stepped into by index or key, "_game:run:roster:0:retire", and a step written as a call walks through what a method taking no arguments returned, "get_viewport():gui_get_focus_owner".'
33731
+ description: 'call: which one to call. Colons call through what a node holds, "_game:run:advance", the same way a property is written through them, a list or a map is stepped into by index or key, "_game:run:roster:0:retire", and a step written as a call walks through what a reading method returned, "get_viewport():gui_get_focus_owner", as runtime_inspect property describes.'
33732
33732
  },
33733
33733
  args: {
33734
33734
  type: "array",
33735
33735
  ops: ["call"],
33736
- description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths.`
33736
+ description: `call: the arguments, fitted to the method's parameter types. A parameter typed as an object takes a path naming one the game holds and is handed that instance: a colon path read from nodePath, "_game:run:wares:3", or one starting at a node, "/root/Main/Hud" or "/root/Main:_game:run". A path that reaches no object, or an object of another class than the parameter declares, is refused, and so are too few or too many arguments. The engine names a parameter typed as a script class without class_name by the class the script extends, so an object of that class with another script passes that check and the engine refuses the call: the answer says the method did not run, with the engine's reason. A parameter typed as a list or map, such as Array[int] or Array[Gear], is built from a JSON list or object element by element, objects named by their paths. The method itself takes its arguments here only: brackets on it holding arguments are refused.`
33737
+ },
33738
+ properties: {
33739
+ type: ["boolean", "array"],
33740
+ items: { type: "string" },
33741
+ ops: ["call"],
33742
+ description: 'call: read the object the call answered with in the same answer, under result_properties: true for every variable its script declares, or a list of names, each a colon path walked from that object the way property paths are ("dead_allowed", "terms:0:name"). A name that is not there is listed under result_properties_missing with why, and a result that is not an object answers properties_note instead. Without it an object comes back as its class and id only.'
33737
33743
  },
33738
33744
  timeoutMs: {
33739
33745
  type: "integer",
@@ -33815,7 +33821,7 @@ var TOOL_SPECS = [
33815
33821
  },
33816
33822
  {
33817
33823
  name: "runtime_input",
33818
- description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: the pointer ops answer with pointer_note there, because get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
33824
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: every pointer answer there carries pointer_on_hidden_desktop, and the first of the run pointer_note saying why it matters: get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
33819
33825
  parameters: {
33820
33826
  projectPath: RUNNING_PROJECT_PATH,
33821
33827
  pid: RUNNING_PID,
@@ -33827,7 +33833,7 @@ var TOOL_SPECS = [
33827
33833
  says: {
33828
33834
  type: "string",
33829
33835
  ops: ["click"],
33830
- description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one."
33836
+ description: "click: the words on the control to click, matched the way runtime_inspect find matches says (a plain word is contained in the text, a pattern with * or ? is a glob over it, and | separates alternatives), among the controls on screen, and pressed in the same frame it was found in, so a panel rebuilding between a find and a click cannot free it first. Words on a label inside a button press the button. A control with something drawn over its centre that would take the pointer, such as a screen shown over it or a dialog stacked above its own, is not on screen; nor is one on a hidden canvas layer, one outside the viewport, or one a container clips away that a click does not scroll. The refusal counts those apart. The matches are ranked the way a person picks what to press: an enabled button before anything else, a disabled one included, then a control whose whole text, or one whole line of it such as the title on a card, is the words before one saying them as part of more, and the best one is pressed; found.picked says why. Several matches at the best rank are refused with each one listed, and index picks one. A match saying the words only as part of more is named under found.partOf, and is refused when a control saying exactly the words is hidden, off the screen or covered, since it would stand in for that one; index presses it anyway. An empty field says its placeholder. An item of an open menu is drawn by the menu rather than held as a control, so a click finds none, and the refusal names the menu and the choose call that picks it."
33831
33837
  },
33832
33838
  action: {
33833
33839
  type: "string",
@@ -34411,10 +34417,13 @@ function runtimeVerdict(endpoint, after) {
34411
34417
  return {
34412
34418
  listening: false,
34413
34419
  mayYetAnnounce: after.running,
34414
- note: after.running ? `nothing announced itself within ${after.budgetMs}ms${sized} and ${state}, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms${sized} and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
34420
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms${sized} and ${state}, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}. ${untilItAnnounces(after.servesAProject)}` : `nothing announced itself within ${after.budgetMs}ms${sized} and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
34415
34421
  heldAt: null
34416
34422
  };
34417
34423
  }
34424
+ function untilItAnnounces(servesAProject) {
34425
+ return servesAProject ? "Until it does, a runtime_* call for it waits briefly and is then refused, and another project's game answers only a call whose projectPath names that project." : "Until it does, a runtime_* call naming no projectPath reaches whichever game is the only one announced, another project's included, so pass projectPath to be sure the answer is this game's.";
34426
+ }
34418
34427
  function isOfType(value, kind) {
34419
34428
  switch (kind) {
34420
34429
  case "string":
@@ -34615,18 +34624,29 @@ function settingsNamedBy(op, args) {
34615
34624
  }
34616
34625
  var STALLED_AFTER_MS = 30000;
34617
34626
  var DEPENDED_FAILED = "Failed to compile depended scripts";
34627
+ function foldedScriptErrors(errors) {
34628
+ const scriptErrors = errors.filter((error) => !error.message.includes(DEPENDED_FAILED));
34629
+ const own = new Set(scriptErrors.map((error) => error.path));
34630
+ return {
34631
+ scriptErrors,
34632
+ failedThroughDependency: [...new Set(errors.map((error) => error.path))].filter((path) => !own.has(path))
34633
+ };
34634
+ }
34635
+ function nonEmpty(lists) {
34636
+ return Object.fromEntries(Object.entries(lists).filter(([, list]) => list.length > 0));
34637
+ }
34618
34638
  function scriptErrorsNote(errors) {
34639
+ const { scriptErrors: own, failedThroughDependency: dependents } = foldedScriptErrors(errors);
34619
34640
  const causes = new Map;
34620
- for (const error of errors) {
34621
- if (!error.message.includes(DEPENDED_FAILED) && !causes.has(error.path)) {
34641
+ for (const error of own) {
34642
+ if (!causes.has(error.path)) {
34622
34643
  causes.set(error.path, error);
34623
34644
  }
34624
34645
  }
34625
- const dependents = [...new Set(errors.map((error) => error.path))].filter((path) => !causes.has(path));
34626
34646
  const scripts = causes.size + dependents.length;
34627
34647
  const named = [...causes.values()].map((error) => `${error.line === null ? error.path : `${error.path}:${error.line}`} ${error.message}${/[.!?]\)?$/.test(error.message) ? "" : "."}`).join(" ");
34628
34648
  const following = dependents.length === 0 ? "" : causes.size === 0 ? `${dependents.join(", ")} ${dependents.length === 1 ? "depends" : "depend"} on a script that did not compile, and gdUnit4 named none with an error of its own: script_diagnostics on one of them finds it.` : `${dependents.length === 1 ? "One more" : `${dependents.length} more`} failed only because a script ${dependents.length === 1 ? "it depends" : "they depend"} on did: ${dependents.join(", ")}.`;
34629
- const more = errors.filter((error) => !error.message.includes(DEPENDED_FAILED)).length - causes.size;
34649
+ const more = own.length - causes.size;
34630
34650
  const rest = more > 0 ? `${more === 1 ? "One more error" : `${more} more errors`} in ${causes.size === 1 ? "that script" : "those scripts"} ${more === 1 ? "is" : "are"} under scriptErrors.` : "";
34631
34651
  return [
34632
34652
  `No tests ran: gdUnit4 could not load ${scripts === 1 ? "one script" : `${scripts} scripts`} while looking for suites, and it runs no suite at all when one fails to load.`,
@@ -35491,7 +35511,8 @@ class GodotServer {
35491
35511
  ...whichGame(args),
35492
35512
  path: readNonEmptyString(args, "nodePath") ?? "",
35493
35513
  method: readString(args, "method") ?? "",
35494
- args: readArray(args, "args") ?? []
35514
+ args: readArray(args, "args") ?? [],
35515
+ ...args["properties"] === undefined ? {} : { properties: args["properties"] }
35495
35516
  }, patience);
35496
35517
  }
35497
35518
  case "runtime_capture":
@@ -36285,9 +36306,9 @@ class GodotServer {
36285
36306
  if (!paths.ok) {
36286
36307
  return this.createErrorResponse(paths.reason, PATH_SOLUTIONS);
36287
36308
  }
36288
- const asked = paths.value;
36289
- const missing = asked.filter((path) => !existsSync16(join20(project.value.path, path.slice("res://".length))));
36290
- if (missing.length > 0) {
36309
+ const missing = paths.value.filter((path) => !existsSync16(join20(project.value.path, path.slice("res://".length))));
36310
+ const asked = paths.value.filter((path) => !missing.includes(path));
36311
+ if (asked.length === 0) {
36291
36312
  const verdict = `nothing at ${missing.join(" and ")}`;
36292
36313
  return {
36293
36314
  content: [
@@ -36404,7 +36425,10 @@ class GodotServer {
36404
36425
  const hung = cutShort?.hung ?? false;
36405
36426
  const scriptErrors = timedOut || exitCode !== 105 ? [] : scriptErrorsPrinted(said);
36406
36427
  const brokenScripts = new Set(scriptErrors.map((error) => error.path)).size;
36407
- const verdict = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
36428
+ const ranAs = cutShort !== null ? cutShort.verdict : nothingRan ?? (brokenScripts > 0 ? `script errors in ${brokenScripts === 1 ? "one script" : `${brokenScripts} scripts`}, so no suite ran` : undefined) ?? (exitCode === null ? undefined : verdicts[exitCode]) ?? (run.exitSignal === null ? `exit ${exitCode ?? "unknown"}` : howItExited(run));
36429
+ const nothingAt = missing.join(" and ");
36430
+ const verdict = missing.length === 0 ? ranAs : `${ranAs}; nothing at ${nothingAt}`;
36431
+ const missingNote = missing.length === 0 ? undefined : `Nothing at ${nothingAt}, so ${missing.length === 1 ? "it was" : "they were"} left out and only ${asked.join(", ")} ran; a run of fewer paths than were asked for is not called a pass.${elsewhereIn(project.value.path, missing[0] ?? "")}`;
36408
36432
  const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
36409
36433
  const marker = readNonEmptyString(args, "printed");
36410
36434
  const echoed = marker === undefined ? undefined : run.log.select({ severity: "info", sinceLastCall: false, contains: marker, limit: 200 });
@@ -36413,7 +36437,8 @@ class GodotServer {
36413
36437
  ...echoed.omitted > 0 ? { printedOmitted: echoed.omitted } : {}
36414
36438
  };
36415
36439
  if (report === null) {
36416
- const note = nothingRan !== null ? `No tests ran: ${verdict}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${verdict}.` : `The test run wrote no report (${verdict}).`;
36440
+ const said = nothingRan !== null ? `No tests ran: ${ranAs}.` : scriptErrors.length > 0 ? scriptErrorsNote(scriptErrors) : reportProblem !== null ? `The test run's report could not be read (${reportProblem}); the engine's own verdict was ${ranAs}.` : `The test run wrote no report (${ranAs}).`;
36441
+ const note = missingNote === undefined ? said : `${said} ${missingNote}`;
36417
36442
  return {
36418
36443
  content: [
36419
36444
  { type: "text", text: note },
@@ -36427,7 +36452,8 @@ class GodotServer {
36427
36452
  exitSignal: run.exitSignal ?? undefined,
36428
36453
  hung,
36429
36454
  ...timedOut ? { timedOut, silentForMs } : {},
36430
- ...scriptErrors.length > 0 ? { scriptErrors } : {},
36455
+ ...missing.length > 0 ? { missing } : {},
36456
+ ...nonEmpty(foldedScriptErrors(scriptErrors)),
36431
36457
  arguments: cmdArgs,
36432
36458
  entries: forAnswer(printed.slice(0, 60).map(aboveTheRunner)),
36433
36459
  ...printedLines,
@@ -36464,8 +36490,9 @@ class GodotServer {
36464
36490
  });
36465
36491
  const notRun = report.suites.reduce((sum, suite) => sum + Math.max(0, suite.discovered - suite.tests), 0);
36466
36492
  return this.jsonTextResponse({
36467
- passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0,
36493
+ passed: !timedOut && exitCode === 0 && nothingRan === null && report.tests > 0 && report.failures === 0 && report.errors === 0 && hookFailures.length === 0 && missing.length === 0,
36468
36494
  verdict,
36495
+ ...missing.length > 0 ? { missing } : {},
36469
36496
  ...timedOut ? { timedOut, hung, silentForMs } : {},
36470
36497
  exitCode,
36471
36498
  exitSignal: run.exitSignal ?? undefined,
@@ -36482,7 +36509,10 @@ class GodotServer {
36482
36509
  ...printedLines,
36483
36510
  savesNote,
36484
36511
  ...scanned,
36485
- note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true),
36512
+ note: [
36513
+ missingNote,
36514
+ ranAs.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRunNote(notRun, readBoolean(args, "failFast") === true)
36515
+ ].filter((part) => part !== undefined).join(" ") || undefined,
36486
36516
  suites: unclean.map((suite) => ({
36487
36517
  name: suite.name,
36488
36518
  path: suite.path,
@@ -37256,7 +37286,14 @@ class GodotServer {
37256
37286
  async runtimeUp(projectPath, before, budget, withArgs) {
37257
37287
  const budgetMs = budget.ms;
37258
37288
  if (!existsSync16(join20(projectPath, RUNTIME_AUTOLOAD.path))) {
37259
- return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
37289
+ return runtimeVerdict(null, {
37290
+ addon: false,
37291
+ budgetMs,
37292
+ heldAt: null,
37293
+ running: false,
37294
+ withArgs,
37295
+ servesAProject: this.ownProject !== null
37296
+ });
37260
37297
  }
37261
37298
  let asking = null;
37262
37299
  let editorAskedAt = 0;
@@ -37308,6 +37345,7 @@ class GodotServer {
37308
37345
  heldAt: this.dapClient?.whereItStopped() ?? null,
37309
37346
  running: endpoint !== null || going !== null && await this.runStillGoing(going),
37310
37347
  withArgs,
37348
+ servesAProject: this.ownProject !== null,
37311
37349
  ...going?.throughEditor === true ? { playingSeen: going.seenPlaying === true } : {}
37312
37350
  });
37313
37351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.45",
3
+ "version": "1.1.46",
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",