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 +142 -34
- package/build/godot/addons/gdharness_editor/bridge_client.gd +31 -0
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +6 -3
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +35 -15
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +150 -48
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +31 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +8 -6
- package/build/index.js +194 -88
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
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
|
|
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: {
|
|
15838
|
-
|
|
15839
|
-
|
|
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: {
|
|
15849
|
-
|
|
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
|
-
|
|
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(
|
|
34315
|
-
defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS,
|
|
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
|
-
|
|
34336
|
-
constructor(port = DEFAULT_PORT, host = DEFAULT_HOST, timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
22
|
-
if
|
|
23
|
-
return
|
|
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
|
-
|
|
166
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
426
|
-
if
|
|
427
|
-
return
|
|
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
|
|
566
|
-
if
|
|
567
|
-
return
|
|
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:
|