gdharness 0.9.0 → 0.9.2

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
@@ -14711,6 +14711,9 @@ var init_types = __esm(() => {
14711
14711
 
14712
14712
  // src/paths.ts
14713
14713
  import { isAbsolute, relative, resolve, sep } from "node:path";
14714
+ function isSameDirectory(onePath, otherPath) {
14715
+ return relative(resolve(onePath), resolve(otherPath)) === "";
14716
+ }
14714
14717
  function isWithinRoot(rootPath, candidatePath) {
14715
14718
  const step = relative(resolve(rootPath), resolve(candidatePath));
14716
14719
  return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
@@ -15066,7 +15069,7 @@ function buildToolDefinitions() {
15066
15069
  };
15067
15070
  }
15068
15071
  for (const [name, schema] of Object.entries(spec.parameters)) {
15069
- const { ops: _ops, ...carried } = schema;
15072
+ const { ops: _ops, blank: _blank, ...carried } = schema;
15070
15073
  properties[name] = carried;
15071
15074
  }
15072
15075
  const required = [...spec.requires];
@@ -15258,7 +15261,8 @@ var init_tool_definitions = __esm(() => {
15258
15261
  projectPath: PROJECT_PATH,
15259
15262
  setting: { type: "string", description: 'Setting path, such as "display/window/size/viewport_width".' },
15260
15263
  value: {
15261
- description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}.'
15264
+ blank: true,
15265
+ description: 'The value to write. Engine types may be tagged, {"_type": "Vector2", "x": 1, "y": 2}. "" writes an empty string.'
15262
15266
  },
15263
15267
  name: { type: "string", description: "Autoload name." },
15264
15268
  path: { type: "string", description: "Autoload script or scene inside the project." },
@@ -15763,7 +15767,7 @@ var init_tool_definitions = __esm(() => {
15763
15767
  property: {
15764
15768
  type: "string",
15765
15769
  ops: ["property", "find"],
15766
- 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."
15770
+ 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 the objects a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about; a step that is not there is named.'
15767
15771
  },
15768
15772
  depth: { type: "number", ops: ["tree"], description: "tree: levels to descend. Default 3." },
15769
15773
  includeProperties: {
@@ -15780,7 +15784,7 @@ var init_tool_definitions = __esm(() => {
15780
15784
  namePattern: {
15781
15785
  type: "string",
15782
15786
  ops: ["find"],
15783
- description: 'find: a case-insensitive glob on the node name, such as "Enemy*".'
15787
+ description: 'find: a case-insensitive glob on the node name, such as "Enemy*". Matched against the whole name, so a bare word finds only a node called exactly that; an answer of none says how many names contain it and what glob would have found them.'
15784
15788
  },
15785
15789
  group: { type: "string", ops: ["find"], description: "find: a group the node is in." },
15786
15790
  says: {
@@ -15821,7 +15825,7 @@ var init_tool_definitions = __esm(() => {
15821
15825
  requires: ["nodePath"]
15822
15826
  },
15823
15827
  property: {
15824
- summary: "what one property reads on a node, refusing a property the node does not have",
15828
+ summary: "what one property reads on a node, or through the objects it holds, refusing a name nothing along the way has",
15825
15829
  requires: ["nodePath", "property"]
15826
15830
  },
15827
15831
  metrics: { summary: "frame time, memory, draw calls and the rest", requires: [] }
@@ -15834,9 +15838,21 @@ var init_tool_definitions = __esm(() => {
15834
15838
  parameters: {
15835
15839
  projectPath: RUNNING_PROJECT_PATH,
15836
15840
  nodePath: { type: "string", description: 'Absolute node path, such as "/root/Main/Player".' },
15837
- property: { type: "string", ops: ["set"] },
15838
- value: { ops: ["set"], description: "set: the value, fitted to the property's type." },
15839
- method: { type: "string", ops: ["call"] },
15841
+ property: {
15842
+ type: "string",
15843
+ ops: ["set"],
15844
+ description: 'set: which one to write. Colons write through the objects a node holds, "_game:run:day", and the answer reads back off the same holder, so a write a typed container refused shows as an unchanged value.'
15845
+ },
15846
+ value: {
15847
+ ops: ["set"],
15848
+ blank: true,
15849
+ description: `set: the value, fitted to the property's type. "" writes an empty string.`
15850
+ },
15851
+ method: {
15852
+ type: "string",
15853
+ ops: ["call"],
15854
+ description: 'call: which one to call. Colons call through the objects a node holds, "_game:run:advance", the same way a property is written through them.'
15855
+ },
15840
15856
  args: {
15841
15857
  type: "array",
15842
15858
  ops: ["call"],
@@ -15845,8 +15861,14 @@ var init_tool_definitions = __esm(() => {
15845
15861
  },
15846
15862
  requires: ["nodePath"],
15847
15863
  operations: {
15848
- set: { summary: "set a property", requires: ["property", "value"] },
15849
- call: { summary: "call a method and return its result", requires: ["method"] }
15864
+ set: {
15865
+ summary: "set a property, on a node or on an object it holds",
15866
+ requires: ["property", "value"]
15867
+ },
15868
+ call: {
15869
+ summary: "call a method, on a node or on an object it holds, and return its result",
15870
+ requires: ["method"]
15871
+ }
15850
15872
  }
15851
15873
  },
15852
15874
  {
@@ -15898,7 +15920,8 @@ var init_tool_definitions = __esm(() => {
15898
15920
  text: {
15899
15921
  type: "string",
15900
15922
  ops: ["text", "choose"],
15901
- description: "text: what to type. A newline is Enter and a tab is Tab. choose: the item to take, by what it says."
15923
+ blank: true,
15924
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says.'
15902
15925
  },
15903
15926
  replace: {
15904
15927
  type: "boolean",
@@ -30759,11 +30782,29 @@ var init_stdio2 = __esm(() => {
30759
30782
  });
30760
30783
 
30761
30784
  // src/bridge-announce.ts
30762
- import { mkdirSync as mkdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30785
+ import { mkdirSync as mkdirSync5, readFileSync as readFileSync9, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
30763
30786
  import { dirname as dirname6, join as join11 } from "node:path";
30764
30787
  function announcementPath(projectPath) {
30765
30788
  return join11(projectPath, ".godot", "gdharness-bridge.json");
30766
30789
  }
30790
+ function readAnnouncement(path) {
30791
+ try {
30792
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
30793
+ if (typeof parsed !== "object" || parsed === null) {
30794
+ return null;
30795
+ }
30796
+ const said = parsed;
30797
+ if (typeof said.port !== "number" || typeof said.pid !== "number") {
30798
+ return null;
30799
+ }
30800
+ return said;
30801
+ } catch {
30802
+ return null;
30803
+ }
30804
+ }
30805
+ function announcementIsOurs(path) {
30806
+ return readAnnouncement(path)?.pid === process.pid;
30807
+ }
30767
30808
  function announceBridge(projectPath, bridge) {
30768
30809
  const path = announcementPath(projectPath);
30769
30810
  try {
@@ -30785,7 +30826,7 @@ function announceBridge(projectPath, bridge) {
30785
30826
  }
30786
30827
  }
30787
30828
  function withdrawBridge(path) {
30788
- if (path === null) {
30829
+ if (path === null || !announcementIsOurs(path)) {
30789
30830
  return;
30790
30831
  }
30791
30832
  try {
@@ -34311,14 +34352,15 @@ function resolveDefaultBridgeHost() {
34311
34352
  function servedPort(said) {
34312
34353
  return typeof said === "number" && Number.isInteger(said) && said > 0 && said <= 65535 ? said : undefined;
34313
34354
  }
34314
- function getDefaultBridge(mayMove = false) {
34315
- defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, mayMove);
34355
+ function getDefaultBridge(ownProject = null) {
34356
+ defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
34316
34357
  return defaultBridge;
34317
34358
  }
34318
- var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, GodotBridge, defaultBridge = null;
34359
+ var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, GodotBridge, defaultBridge = null;
34319
34360
  var init_godot_bridge = __esm(() => {
34320
34361
  init_wrapper();
34321
34362
  init_errors();
34363
+ init_paths();
34322
34364
  init_ports();
34323
34365
  GodotBridge = class GodotBridge extends EventEmitter {
34324
34366
  httpServer = null;
@@ -34332,13 +34374,16 @@ var init_godot_bridge = __esm(() => {
34332
34374
  boundPort = null;
34333
34375
  host;
34334
34376
  timeoutMs;
34335
- mayMove;
34336
- constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, mayMove = false) {
34377
+ ownProject;
34378
+ constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS, ownProject = null) {
34337
34379
  super();
34338
34380
  this.wantedPort = port;
34339
34381
  this.host = host;
34340
34382
  this.timeoutMs = timeoutMs;
34341
- this.mayMove = mayMove;
34383
+ this.ownProject = ownProject;
34384
+ }
34385
+ get mayMove() {
34386
+ return this.ownProject !== null;
34342
34387
  }
34343
34388
  get port() {
34344
34389
  return this.boundPort ?? this.wantedPort;
@@ -34584,6 +34629,10 @@ var init_godot_bridge = __esm(() => {
34584
34629
  }
34585
34630
  case "godot_ready":
34586
34631
  if (this.connectionInfo) {
34632
+ if (this.belongsElsewhere(message.project_path)) {
34633
+ this.sendElsewhere(message.project_path);
34634
+ return;
34635
+ }
34587
34636
  this.connectionInfo.projectPath = message.project_path;
34588
34637
  this.connectionInfo.addonVersion = message.addon_version ?? "";
34589
34638
  this.connectionInfo.editorPid = message.editor_pid;
@@ -34666,6 +34715,21 @@ var init_godot_bridge = __esm(() => {
34666
34715
  clearInterval(this.pingInterval);
34667
34716
  this.pingInterval = null;
34668
34717
  }
34718
+ belongsElsewhere(theirProject) {
34719
+ if (this.ownProject === null || theirProject.trim() === "") {
34720
+ return false;
34721
+ }
34722
+ return !isSameDirectory(this.ownProject, theirProject);
34723
+ }
34724
+ sendElsewhere(theirProject) {
34725
+ const said = `this server serves ${this.ownProject ?? ""}, and that editor has ${theirProject} open`;
34726
+ this.log("warn", `Turning away an editor from another project: ${said}`);
34727
+ const stranger = this.socket;
34728
+ this.socket = null;
34729
+ this.connectionInfo = null;
34730
+ this.stopKeepalive();
34731
+ stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
34732
+ }
34669
34733
  handleDisconnect(disconnectedSocket, reason) {
34670
34734
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
34671
34735
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -35590,7 +35654,7 @@ var init_lsp_client = __esm(() => {
35590
35654
  });
35591
35655
 
35592
35656
  // src/project-scan.ts
35593
- import { readdirSync as readdirSync4, readFileSync as readFileSync9 } from "node:fs";
35657
+ import { readdirSync as readdirSync4, readFileSync as readFileSync10 } from "node:fs";
35594
35658
  import { join as join12 } from "node:path";
35595
35659
  function projectStructure(projectPath) {
35596
35660
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
@@ -35653,7 +35717,7 @@ function searchProject(projectPath, options) {
35653
35717
  }
35654
35718
  result.summary.files_searched += 1;
35655
35719
  const matches = [];
35656
- for (const [index, line] of readFileSync9(entryPath, "utf8").split(`
35720
+ for (const [index, line] of readFileSync10(entryPath, "utf8").split(`
35657
35721
  `).entries()) {
35658
35722
  if (full()) {
35659
35723
  break;
@@ -35681,7 +35745,7 @@ var init_project_scan = __esm(() => {
35681
35745
  });
35682
35746
 
35683
35747
  // src/runtime-client.ts
35684
- import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10, unlinkSync } from "node:fs";
35748
+ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync11, unlinkSync } from "node:fs";
35685
35749
  import { createConnection as createConnection3 } from "node:net";
35686
35750
  import { tmpdir as tmpdir4 } from "node:os";
35687
35751
  import { join as join13, resolve as resolve4 } from "node:path";
@@ -35712,7 +35776,7 @@ function processAlive(pid) {
35712
35776
  function parseAnnouncement(file, pid) {
35713
35777
  let fields;
35714
35778
  try {
35715
- fields = asParams(JSON.parse(readFileSync10(file, "utf8")));
35779
+ fields = asParams(JSON.parse(readFileSync11(file, "utf8")));
35716
35780
  } catch {
35717
35781
  return { kind: "rubbish" };
35718
35782
  }
@@ -35905,14 +35969,14 @@ var init_runtime_client = __esm(() => {
35905
35969
  });
35906
35970
 
35907
35971
  // src/setup.ts
35908
- import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35972
+ import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35909
35973
  import { dirname as dirname8, join as join14 } from "node:path";
35910
35974
  function installedAddonVersion(projectPath) {
35911
35975
  const marker = join14(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
35912
35976
  if (!existsSync11(marker)) {
35913
35977
  return null;
35914
35978
  }
35915
- const said = readFileSync11(marker, "utf8").trim();
35979
+ const said = readFileSync12(marker, "utf8").trim();
35916
35980
  return said === "" ? null : said;
35917
35981
  }
35918
35982
  var ADDONS2, VERSION_MARKER2 = ".gdharness-version";
@@ -35926,7 +35990,7 @@ var init_setup = __esm(() => {
35926
35990
 
35927
35991
  // src/server.ts
35928
35992
  import { execFile as execFile5, spawn } from "node:child_process";
35929
- import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35993
+ import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35930
35994
  import { tmpdir as tmpdir5 } from "node:os";
35931
35995
  import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
35932
35996
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -36007,11 +36071,11 @@ function aboveTheRunner(entry) {
36007
36071
  };
36008
36072
  }
36009
36073
  function hasMainScene(projectFile) {
36010
- const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
36074
+ const scene = parseProjectGodot(readFileSync13(projectFile, "utf8"))["application"]?.["run/main_scene"];
36011
36075
  return typeof scene === "string" && scene !== "";
36012
36076
  }
36013
36077
  function editorPlaysHeadless(projectFile) {
36014
- const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36078
+ const runArgs = parseProjectGodot(readFileSync13(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36015
36079
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
36016
36080
  }
36017
36081
  function camelCased(params) {
@@ -36039,13 +36103,14 @@ class GodotServer {
36039
36103
  dapClient = null;
36040
36104
  bridgeStartupError = null;
36041
36105
  bridgeRetry = null;
36106
+ successorWatch = null;
36042
36107
  lastProjectPath = null;
36043
36108
  shutdownInitiated = false;
36044
36109
  ownProject;
36045
36110
  announcedAt = null;
36046
36111
  constructor() {
36047
36112
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
36048
- this.godotBridge = getDefaultBridge(this.ownProject !== null);
36113
+ this.godotBridge = getDefaultBridge(this.ownProject);
36049
36114
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
36050
36115
  this.setupToolHandlers();
36051
36116
  setupResourceHandlers(this.mcp, () => this.lastProjectPath);
@@ -36138,6 +36203,7 @@ class GodotServer {
36138
36203
  port: status.port,
36139
36204
  version: SERVER_VERSION
36140
36205
  });
36206
+ this.watchForASuccessor();
36141
36207
  }
36142
36208
  stopTryingTheBridge() {
36143
36209
  if (this.bridgeRetry !== null) {
@@ -36145,9 +36211,46 @@ class GodotServer {
36145
36211
  this.bridgeRetry = null;
36146
36212
  }
36147
36213
  }
36214
+ watchForASuccessor() {
36215
+ if (this.ownProject === null || this.successorWatch !== null) {
36216
+ return;
36217
+ }
36218
+ const watch = setInterval(() => {
36219
+ const successor = this.supersededBy();
36220
+ if (successor === null) {
36221
+ return;
36222
+ }
36223
+ console.error(`[SERVER] pid ${successor} now serves ${this.ownProject}; this server is finished and is stopping.`);
36224
+ this.handleShutdown(`superseded by ${successor}`, 0);
36225
+ }, SUCCESSOR_CHECK_MS);
36226
+ watch.unref();
36227
+ this.successorWatch = watch;
36228
+ }
36229
+ supersededBy() {
36230
+ if (this.ownProject === null) {
36231
+ return null;
36232
+ }
36233
+ const announced = readAnnouncement(announcementPath(this.ownProject));
36234
+ if (announced === null || announced.pid === process.pid) {
36235
+ return null;
36236
+ }
36237
+ try {
36238
+ process.kill(announced.pid, 0);
36239
+ return announced.pid;
36240
+ } catch {
36241
+ return null;
36242
+ }
36243
+ }
36244
+ stopWatchingForASuccessor() {
36245
+ if (this.successorWatch !== null) {
36246
+ clearInterval(this.successorWatch);
36247
+ this.successorWatch = null;
36248
+ }
36249
+ }
36148
36250
  async cleanup() {
36149
36251
  this.logDebug("Cleaning up resources");
36150
36252
  this.stopTryingTheBridge();
36253
+ this.stopWatchingForASuccessor();
36151
36254
  withdrawBridge(this.announcedAt);
36152
36255
  this.announcedAt = null;
36153
36256
  if (this.activeProcess) {
@@ -36360,7 +36463,11 @@ class GodotServer {
36360
36463
  const required = [...spec.requires, ...op !== null ? spec.operations?.[op]?.requires ?? [] : []];
36361
36464
  const missing = required.filter((field) => {
36362
36465
  const value = Object.hasOwn(args, field) ? args[field] : undefined;
36363
- return value === undefined || value === null || typeof value === "string" && value.trim() === "";
36466
+ if (value === undefined || value === null) {
36467
+ return true;
36468
+ }
36469
+ const blank = typeof value === "string" && value.trim() === "";
36470
+ return blank && spec.parameters[field]?.blank !== true;
36364
36471
  });
36365
36472
  if (missing.length > 0) {
36366
36473
  const where = op !== null ? `${spec.name} ${op}` : spec.name;
@@ -36725,7 +36832,7 @@ class GodotServer {
36725
36832
  if (!engine.ok) {
36726
36833
  return engine.response;
36727
36834
  }
36728
- const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
36835
+ const application = parseProjectGodot(readFileSync13(project.value.file, "utf8"))["application"] ?? {};
36729
36836
  const name = application["config/name"];
36730
36837
  const mainScene = application["run/main_scene"];
36731
36838
  const info = {
@@ -36900,7 +37007,7 @@ class GodotServer {
36900
37007
  const written = existsSync12(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36901
37008
  const newest = written.at(-1);
36902
37009
  if (newest !== undefined) {
36903
- report = parseJUnit(readFileSync12(join15(reportsDir, newest, "results.xml"), "utf8"));
37010
+ report = parseJUnit(readFileSync13(join15(reportsDir, newest, "results.xml"), "utf8"));
36904
37011
  }
36905
37012
  } catch (error) {
36906
37013
  reportProblem = errorMessage(error);
@@ -37490,7 +37597,7 @@ class GodotServer {
37490
37597
  return {
37491
37598
  content: [
37492
37599
  { type: "text", text: `Screenshot captured: ${dimensions}` },
37493
- { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
37600
+ { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
37494
37601
  ]
37495
37602
  };
37496
37603
  } finally {
@@ -37556,7 +37663,7 @@ async function runGodotServer() {
37556
37663
  const server = new GodotServer;
37557
37664
  await server.run();
37558
37665
  }
37559
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37666
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run5, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, HEADLESS_OPERATIONS, PROJECT_INFO_SECTIONS, RUNNER_DIRECTORY = "addons/gdUnit4/";
37560
37667
  var init_server3 = __esm(() => {
37561
37668
  init_mcp();
37562
37669
  init_stdio2();
@@ -38926,6 +39033,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
38926
39033
  | Find a control by the word on it | \`runtime_inspect find\` with \`says\`, rather than listing a screen and reading each one |
38927
39034
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
38928
39035
  | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
39036
+ | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
38929
39037
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
38930
39038
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
38931
39039
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
@@ -18,6 +18,10 @@ const ANNOUNCE_PROTOCOL: int = 1
18
18
  const RECONNECT_DELAY: float = 3.0
19
19
  const MAX_RECONNECT_DELAY: float = 30.0
20
20
 
21
+ ## What a server closes with when the editor that said hello belongs to another project. Kept in
22
+ ## step with `OTHER_PROJECT_CLOSE_CODE` in src/godot-bridge.ts.
23
+ const ELSEWHERE_CLOSE_CODE: int = 4001
24
+
21
25
  ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
22
26
  ## file is one for every editor on the machine, so two open at once want the same three and the
23
27
  ## second one binds nothing.
@@ -67,6 +71,10 @@ var _since_looked: float = 0.0
67
71
  ## anything connects or the project names a different one.
68
72
  var _refused_url: String = ""
69
73
  var _tried_announced: bool = false
74
+
75
+ ## The address this editor has already been told is another project's, so a reconnect loop says it
76
+ ## once rather than every few seconds for the rest of the session.
77
+ var _told_about_url: String = ""
70
78
  var _connecting_for: float = 0.0
71
79
 
72
80
 
@@ -343,12 +351,35 @@ func _loaded_version() -> String:
343
351
 
344
352
  func _handle_disconnect() -> void:
345
353
  _is_connected = false
354
+ _said_elsewhere()
346
355
  disconnected.emit()
347
356
 
348
357
  if _should_reconnect:
349
358
  _schedule_reconnect()
350
359
 
351
360
 
361
+ ## Says so when the server turned this editor away as another project's.
362
+ ##
363
+ ## The fallback address is tried by every project whose own server has not run yet and so has
364
+ ## announced nothing, which means any server holding it took the connection and served it. It is
365
+ ## refused now, and the refusal is worth a person's attention rather than a quiet backoff: nothing
366
+ ## about waiting reaches a server that is not this project's, and the answer is to start one.
367
+ ##
368
+ ## Said once per address. This is a reconnect loop, and a line every few seconds for the rest of
369
+ ## the session is a line nobody reads.
370
+ func _said_elsewhere() -> void:
371
+ if socket.get_close_code() != ELSEWHERE_CLOSE_CODE:
372
+ return
373
+ if _told_about_url == server_url:
374
+ return
375
+ _told_about_url = server_url
376
+ var said: String = (
377
+ "[gdharness] %s is another project's server, so this editor is not connected: %s."
378
+ + " Start this project's own server, or point it at one with GDHARNESS_BRIDGE_PORT."
379
+ )
380
+ push_warning(said % [server_url, socket.get_close_reason()])
381
+
382
+
352
383
  func _schedule_reconnect() -> void:
353
384
  if _reconnect_timer == null:
354
385
  return
@@ -2,6 +2,8 @@ extends RefCounted
2
2
 
3
3
  ## A picture of the running game, as a PNG written where the server asked for it.
4
4
 
5
+ const Values = preload("runtime_values.gd")
6
+
5
7
  var _host: Node
6
8
 
7
9
 
@@ -18,9 +20,10 @@ func capture_viewport(params: Dictionary) -> Dictionary:
18
20
  if viewport_path.is_empty():
19
21
  return capture_screenshot(params)
20
22
 
21
- var node: Node = _host.get_tree().root.get_node_or_null(viewport_path)
22
- if node == null:
23
- return {"type": "error", "message": "Viewport not found: " + viewport_path}
23
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, viewport_path)
24
+ if standing.has("message"):
25
+ return standing
26
+ var node: Node = standing["node"]
24
27
  if not node is Viewport:
25
28
  return {"type": "error", "message": "Node is not a Viewport: " + viewport_path}
26
29
  return _capture(node, params)
@@ -162,8 +162,9 @@ func inject_key(params: Dictionary) -> Dictionary:
162
162
  ## back now rather than echoed from the request, so a call that typed somewhere unhelpful says so.
163
163
  func inject_text(params: Dictionary) -> Dictionary:
164
164
  var text: String = String(params.get("text", ""))
165
- if text.is_empty():
166
- return {"type": "error", "message": "text required"}
165
+ var over: bool = bool(params.get("replace", false))
166
+ if text.is_empty() and not over:
167
+ return {"type": "error", "message": "text needs something to type, or replace to empty a field"}
167
168
 
168
169
  var viewport: Viewport = _host.get_tree().root
169
170
  var focused: Control = viewport.gui_get_focus_owner()
@@ -171,14 +172,13 @@ func inject_text(params: Dictionary) -> Dictionary:
171
172
  if not shut.is_empty():
172
173
  return {"type": "error", "message": shut}
173
174
 
174
- var replaced: bool = bool(params.get("replace", false)) and _select_everything(focused)
175
+ var replaced: bool = over and _select_everything(focused)
176
+ # Emptying a field is a real thing to ask for and the one shape of filling one in that types no
177
+ # characters. The selection is standing, so the key a player presses over one is what clears it.
178
+ if replaced and text.is_empty():
179
+ _press(viewport, _held_down(KEY_DELETE))
175
180
  for index: int in text.length():
176
- var down: InputEventKey = _typed(text.unicode_at(index))
177
- viewport.push_input(down)
178
- # The release as well, so nothing is left held down behind the caller.
179
- var up: InputEventKey = down.duplicate()
180
- up.pressed = false
181
- viewport.push_input(up)
181
+ _press(viewport, _typed(text.unicode_at(index)))
182
182
 
183
183
  # Where it went, which is the one thing a caller cannot see from here. Null is a game reading
184
184
  # keys for itself with nothing focused, which is a real thing to be typing at.
@@ -253,6 +253,24 @@ static func _shut_to_typing(focused: Control) -> String:
253
253
 
254
254
 
255
255
  ## The key press that produces [param glyph], as a keyboard would send it.
256
+ ## A whole press: down, then the release, so nothing is left held down behind the caller.
257
+ static func _press(viewport: Viewport, down: InputEventKey) -> void:
258
+ viewport.push_input(down)
259
+ var up: InputEventKey = down.duplicate()
260
+ up.pressed = false
261
+ viewport.push_input(up)
262
+
263
+
264
+ ## A key held down by its keycode, for the ones that stand for an edit rather than a character.
265
+ static func _held_down(code: Key) -> InputEventKey:
266
+ var event: InputEventKey = InputEventKey.new()
267
+ event.pressed = true
268
+ event.keycode = code
269
+ event.physical_keycode = code
270
+ event.key_label = code
271
+ return event
272
+
273
+
256
274
  func _typed(glyph: int) -> InputEventKey:
257
275
  var event: InputEventKey = InputEventKey.new()
258
276
  event.pressed = true
@@ -422,9 +440,10 @@ func click(params: Dictionary) -> Dictionary:
422
440
  if node_path.is_empty():
423
441
  return {"type": "error", "message": "Node path required"}
424
442
 
425
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
426
- if node == null:
427
- return {"type": "error", "message": "Node not found: " + node_path}
443
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
444
+ if standing.has("message"):
445
+ return standing
446
+ var node: Node = standing["node"]
428
447
  if node is Node3D:
429
448
  return await _click_in_the_world(node_path, node, params)
430
449
  if not node is Control:
@@ -562,9 +581,10 @@ func choose(params: Dictionary) -> Dictionary:
562
581
  var node_path: String = str(params.get("path", ""))
563
582
  if node_path.is_empty():
564
583
  return {"type": "error", "message": "Node path required"}
565
- var node: Node = _host.get_tree().root.get_node_or_null(node_path)
566
- if node == null:
567
- return {"type": "error", "message": "Node not found: " + node_path}
584
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
585
+ if standing.has("message"):
586
+ return standing
587
+ var node: Node = standing["node"]
568
588
 
569
589
  var menu: PopupMenu = _menu_of(node)
570
590
  if menu == null: