gdharness 1.0.27 → 1.0.29
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 +134 -18
- package/build/godot/addons/gdharness_editor/tool_executor.gd +1 -0
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +26 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +25 -12
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +3 -22
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +2 -1
- package/build/godot/addons/gdharness_runtime/runtime_words.gd +163 -0
- package/build/godot/addons/gdharness_runtime/runtime_words.gd.uid +1 -0
- package/build/index.js +127 -17
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -16309,7 +16309,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16309
16309
|
},
|
|
16310
16310
|
{
|
|
16311
16311
|
name: "editor_run",
|
|
16312
|
-
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. 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.",
|
|
16312
|
+
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.",
|
|
16313
16313
|
parameters: {
|
|
16314
16314
|
projectPath: { ...PROJECT_PATH, ops: ["start", "check"] },
|
|
16315
16315
|
scene: {
|
|
@@ -16426,7 +16426,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16426
16426
|
},
|
|
16427
16427
|
{
|
|
16428
16428
|
name: "editor_status",
|
|
16429
|
-
description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
|
|
16429
|
+
description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
|
|
16430
16430
|
parameters: {},
|
|
16431
16431
|
requires: []
|
|
16432
16432
|
},
|
|
@@ -16538,7 +16538,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16538
16538
|
says: {
|
|
16539
16539
|
type: "string",
|
|
16540
16540
|
ops: ["find"],
|
|
16541
|
-
description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
|
|
16541
|
+
description: "find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
|
|
16542
16542
|
},
|
|
16543
16543
|
limit: {
|
|
16544
16544
|
type: "integer",
|
|
@@ -16567,7 +16567,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16567
16567
|
operations: {
|
|
16568
16568
|
tree: { summary: "the live scene tree", requires: [] },
|
|
16569
16569
|
text: {
|
|
16570
|
-
summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
|
|
16570
|
+
summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it. Every line reads as drawn: rich text without its tags, text only as far as it has been typed out, a translation rather than its key, an upper-case label in capitals and a secret field as its mask. Tab titles, menu bar titles, list items, a tree's column titles and rows, and an open menu's items read a line each",
|
|
16571
16571
|
requires: []
|
|
16572
16572
|
},
|
|
16573
16573
|
find: {
|
|
@@ -16716,7 +16716,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16716
16716
|
type: "string",
|
|
16717
16717
|
ops: ["text", "choose"],
|
|
16718
16718
|
blank: true,
|
|
16719
|
-
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.'
|
|
16719
|
+
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 as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
|
|
16720
16720
|
},
|
|
16721
16721
|
replace: {
|
|
16722
16722
|
type: "boolean",
|
|
@@ -16778,7 +16778,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16778
16778
|
requires: ["keycode"]
|
|
16779
16779
|
},
|
|
16780
16780
|
text: {
|
|
16781
|
-
summary: "type a string into the field being edited, a character at a time, and say what it landed in",
|
|
16781
|
+
summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask",
|
|
16782
16782
|
requires: ["text"]
|
|
16783
16783
|
},
|
|
16784
16784
|
mouse_click: {
|
|
@@ -38557,6 +38557,33 @@ function outsideTheirRanges(spec, args) {
|
|
|
38557
38557
|
}
|
|
38558
38558
|
return complaints;
|
|
38559
38559
|
}
|
|
38560
|
+
function scanWaitIn(payload) {
|
|
38561
|
+
const waited = readNumber(payload, "waitedForEditorScanMs");
|
|
38562
|
+
const note = readString(payload, "scanNote");
|
|
38563
|
+
return {
|
|
38564
|
+
...waited === undefined ? {} : { waitedForEditorScanMs: waited },
|
|
38565
|
+
...note === undefined ? {} : { scanNote: note }
|
|
38566
|
+
};
|
|
38567
|
+
}
|
|
38568
|
+
function withScanWait(outcome, scanned) {
|
|
38569
|
+
if (scanned.waitedMs === 0) {
|
|
38570
|
+
return outcome;
|
|
38571
|
+
}
|
|
38572
|
+
const said = scanWaitAnswer(scanned);
|
|
38573
|
+
if (outcome.ok) {
|
|
38574
|
+
return { ...outcome, payload: { ...outcome.payload, ...said } };
|
|
38575
|
+
}
|
|
38576
|
+
return said.scanNote === undefined ? outcome : { ...outcome, message: `${outcome.message} ${said.scanNote}` };
|
|
38577
|
+
}
|
|
38578
|
+
function scanWaitAnswer(scanned) {
|
|
38579
|
+
if (scanned.waitedMs === 0) {
|
|
38580
|
+
return {};
|
|
38581
|
+
}
|
|
38582
|
+
return {
|
|
38583
|
+
waitedForEditorScanMs: scanned.waitedMs,
|
|
38584
|
+
scanNote: scanned.stillScanning ? `The editor was still scanning the project after ${SCAN_WAIT_MS / 1000} seconds, so this ` + "game started during the scan and may have read a class cache being rewritten. If " + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : undefined
|
|
38585
|
+
};
|
|
38586
|
+
}
|
|
38560
38587
|
function escapingPropertyValue(properties) {
|
|
38561
38588
|
if (typeof properties !== "object" || properties === null) {
|
|
38562
38589
|
return;
|
|
@@ -39491,6 +39518,47 @@ class GodotServer {
|
|
|
39491
39518
|
}
|
|
39492
39519
|
return { ok: true, value: stale };
|
|
39493
39520
|
}
|
|
39521
|
+
async untilTheEditorsScanIsWritten(projectPath) {
|
|
39522
|
+
const open = this.godotBridge.getStatus().projectPath;
|
|
39523
|
+
if (!this.godotBridge.isConnected() || open === undefined || !isSameDirectory(open, projectPath)) {
|
|
39524
|
+
return { waitedMs: 0, stillScanning: false };
|
|
39525
|
+
}
|
|
39526
|
+
const started = Date.now();
|
|
39527
|
+
let sawAScan = false;
|
|
39528
|
+
let scanEndedAt = null;
|
|
39529
|
+
for (;; ) {
|
|
39530
|
+
const asked = Date.now();
|
|
39531
|
+
let status;
|
|
39532
|
+
try {
|
|
39533
|
+
status = asParams(await this.godotBridge.invokeTool("scan_status", {}));
|
|
39534
|
+
} catch {
|
|
39535
|
+
return { waitedMs: Date.now() - started, stillScanning: false };
|
|
39536
|
+
}
|
|
39537
|
+
if (status["scanning"] === true || status["importing"] === true || status["pending"] === true) {
|
|
39538
|
+
sawAScan = true;
|
|
39539
|
+
if (Date.now() - started >= SCAN_WAIT_MS) {
|
|
39540
|
+
return { waitedMs: Date.now() - started, stillScanning: true };
|
|
39541
|
+
}
|
|
39542
|
+
await new Promise((settle) => setTimeout(settle, 100));
|
|
39543
|
+
continue;
|
|
39544
|
+
}
|
|
39545
|
+
const since = readNumber(status, "sinceScanFinishedMs");
|
|
39546
|
+
scanEndedAt = since !== undefined && since >= 0 ? asked - since : sawAScan ? asked : null;
|
|
39547
|
+
break;
|
|
39548
|
+
}
|
|
39549
|
+
let waitedForTheWrite = false;
|
|
39550
|
+
if (scanEndedAt !== null) {
|
|
39551
|
+
const writeDue = scanEndedAt + CACHE_WRITE_MS;
|
|
39552
|
+
while (Date.now() < writeDue && (cacheWrittenAt(projectPath) ?? 0) < scanEndedAt) {
|
|
39553
|
+
waitedForTheWrite = true;
|
|
39554
|
+
await new Promise((settle) => setTimeout(settle, 50));
|
|
39555
|
+
}
|
|
39556
|
+
}
|
|
39557
|
+
return {
|
|
39558
|
+
waitedMs: sawAScan || waitedForTheWrite ? Date.now() - started : 0,
|
|
39559
|
+
stillScanning: false
|
|
39560
|
+
};
|
|
39561
|
+
}
|
|
39494
39562
|
containProjectFiles(args) {
|
|
39495
39563
|
const projectPath = readString(args, "projectPath");
|
|
39496
39564
|
if (!projectPath) {
|
|
@@ -39551,14 +39619,15 @@ class GodotServer {
|
|
|
39551
39619
|
return engine.response;
|
|
39552
39620
|
}
|
|
39553
39621
|
const projectPath = project.value.path;
|
|
39622
|
+
const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
|
|
39554
39623
|
const before = scriptsWithoutUid(projectPath);
|
|
39555
39624
|
const imported = await runImport(engine.value, projectPath);
|
|
39556
39625
|
if (!imported.ok) {
|
|
39557
|
-
return this.answer(imported);
|
|
39626
|
+
return this.answer(withScanWait(imported, scanned));
|
|
39558
39627
|
}
|
|
39559
39628
|
const after = scriptsWithoutUid(projectPath);
|
|
39560
39629
|
const given = before.filter((script) => !after.includes(script));
|
|
39561
|
-
return this.answer({
|
|
39630
|
+
return this.answer(withScanWait({
|
|
39562
39631
|
ok: true,
|
|
39563
39632
|
messages: imported.messages,
|
|
39564
39633
|
payload: {
|
|
@@ -39566,7 +39635,7 @@ class GodotServer {
|
|
|
39566
39635
|
stillWithoutUid: after,
|
|
39567
39636
|
note: uidsLeftNote(after.length)
|
|
39568
39637
|
}
|
|
39569
|
-
});
|
|
39638
|
+
}, scanned));
|
|
39570
39639
|
}
|
|
39571
39640
|
async operation(operation, params, projectPath) {
|
|
39572
39641
|
const engine = await this.engine();
|
|
@@ -39577,8 +39646,10 @@ class GodotServer {
|
|
|
39577
39646
|
messages: []
|
|
39578
39647
|
};
|
|
39579
39648
|
}
|
|
39649
|
+
const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
|
|
39580
39650
|
this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
|
|
39581
|
-
|
|
39651
|
+
const outcome = await runOperation2({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
|
|
39652
|
+
return withScanWait(outcome, scanned);
|
|
39582
39653
|
}
|
|
39583
39654
|
answer(outcome) {
|
|
39584
39655
|
if (!outcome.ok) {
|
|
@@ -39760,6 +39831,7 @@ class GodotServer {
|
|
|
39760
39831
|
if (!classes.ok) {
|
|
39761
39832
|
return this.answer(classes);
|
|
39762
39833
|
}
|
|
39834
|
+
const scanned = scanWaitIn(classes.payload);
|
|
39763
39835
|
const ours = `gdharness-reports/${randomUUID2()}`;
|
|
39764
39836
|
const reports = `res://.godot/${ours}`;
|
|
39765
39837
|
const ignored = readStringArray(args, "ignore") ?? [];
|
|
@@ -39850,7 +39922,8 @@ class GodotServer {
|
|
|
39850
39922
|
hung,
|
|
39851
39923
|
arguments: cmdArgs,
|
|
39852
39924
|
entries: forAnswer(printed.slice(0, 60)),
|
|
39853
|
-
savesNote
|
|
39925
|
+
savesNote,
|
|
39926
|
+
...scanned
|
|
39854
39927
|
}, null, 2)
|
|
39855
39928
|
}
|
|
39856
39929
|
],
|
|
@@ -39885,6 +39958,7 @@ class GodotServer {
|
|
|
39885
39958
|
orphans: orphans.total > 0 ? orphans.total : undefined,
|
|
39886
39959
|
notRun: notRun > 0 ? notRun : undefined,
|
|
39887
39960
|
savesNote,
|
|
39961
|
+
...scanned,
|
|
39888
39962
|
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." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
|
|
39889
39963
|
suites: unclean.map((suite) => ({
|
|
39890
39964
|
name: suite.name,
|
|
@@ -40183,6 +40257,28 @@ class GodotServer {
|
|
|
40183
40257
|
return null;
|
|
40184
40258
|
}
|
|
40185
40259
|
}
|
|
40260
|
+
async editorScanState() {
|
|
40261
|
+
if (!this.godotBridge.getStatus().connected) {
|
|
40262
|
+
return null;
|
|
40263
|
+
}
|
|
40264
|
+
try {
|
|
40265
|
+
const answer = await Promise.race([
|
|
40266
|
+
this.godotBridge.invokeTool("scan_status", {}),
|
|
40267
|
+
delay3(PLAYING_STATUS_MS).then(() => null)
|
|
40268
|
+
]);
|
|
40269
|
+
if (answer === null) {
|
|
40270
|
+
return null;
|
|
40271
|
+
}
|
|
40272
|
+
const since = readNumber(asParams(answer), "sinceScanFinishedMs");
|
|
40273
|
+
return {
|
|
40274
|
+
scanning: readBoolean(asParams(answer), "scanning") ?? false,
|
|
40275
|
+
importing: readBoolean(asParams(answer), "importing") ?? false,
|
|
40276
|
+
finishedMsAgo: since === undefined || since < 0 ? null : since
|
|
40277
|
+
};
|
|
40278
|
+
} catch {
|
|
40279
|
+
return null;
|
|
40280
|
+
}
|
|
40281
|
+
}
|
|
40186
40282
|
async handleEditorStatus() {
|
|
40187
40283
|
const located = await this.locator.find();
|
|
40188
40284
|
const announced = runtimesAnnounced();
|
|
@@ -40213,11 +40309,16 @@ class GodotServer {
|
|
|
40213
40309
|
hold: current === null ? {} : await this.holdOf(current, active)
|
|
40214
40310
|
};
|
|
40215
40311
|
})();
|
|
40216
|
-
const [games, { playing, active, hold }] = await Promise.all([
|
|
40312
|
+
const [games, { playing, active, hold }, scan] = await Promise.all([
|
|
40313
|
+
pinged,
|
|
40314
|
+
asked,
|
|
40315
|
+
this.editorScanState()
|
|
40316
|
+
]);
|
|
40217
40317
|
return this.jsonTextResponse({
|
|
40218
40318
|
editor: {
|
|
40219
40319
|
...this.getEditorStatusPayload(),
|
|
40220
|
-
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
|
|
40320
|
+
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort,
|
|
40321
|
+
scan
|
|
40221
40322
|
},
|
|
40222
40323
|
godot: {
|
|
40223
40324
|
path: located.ok ? located.path : null,
|
|
@@ -40438,13 +40539,14 @@ class GodotServer {
|
|
|
40438
40539
|
if (!engine.ok) {
|
|
40439
40540
|
return engine.response;
|
|
40440
40541
|
}
|
|
40542
|
+
const scanned = await this.untilTheEditorsScanIsWritten(project.value.path);
|
|
40441
40543
|
const refreshed = await this.refreshStaleClasses(project.value.path);
|
|
40442
40544
|
if (!refreshed.ok) {
|
|
40443
40545
|
return refreshed.response;
|
|
40444
40546
|
}
|
|
40445
40547
|
const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
|
|
40446
40548
|
if (op === "check") {
|
|
40447
|
-
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, asked.value);
|
|
40549
|
+
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value);
|
|
40448
40550
|
}
|
|
40449
40551
|
await this.pickUpWhatTheEditorIsPlaying();
|
|
40450
40552
|
let ended = null;
|
|
@@ -40463,7 +40565,7 @@ class GodotServer {
|
|
|
40463
40565
|
const runtimeWaitMs = this.announceBudget(args, project.value.path);
|
|
40464
40566
|
const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
|
|
40465
40567
|
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value)) {
|
|
40466
|
-
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
40568
|
+
return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
40467
40569
|
}
|
|
40468
40570
|
const cmdArgs = runArguments({
|
|
40469
40571
|
projectPath: project.value.path,
|
|
@@ -40488,6 +40590,7 @@ class GodotServer {
|
|
|
40488
40590
|
arguments: cmdArgs,
|
|
40489
40591
|
transcript: started.transcript,
|
|
40490
40592
|
refreshedClasses: refreshed.value,
|
|
40593
|
+
...scanWaitAnswer(scanned),
|
|
40491
40594
|
savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
40492
40595
|
endedPreviousRun: endedPreviousRun(ended),
|
|
40493
40596
|
runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
|
|
@@ -40569,7 +40672,7 @@ class GodotServer {
|
|
|
40569
40672
|
writeBootNote(run.projectPath, took);
|
|
40570
40673
|
}
|
|
40571
40674
|
}
|
|
40572
|
-
async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
|
|
40675
|
+
async playThroughEditor(scene, refreshedClasses, scanned, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
|
|
40573
40676
|
const log = new GameLog;
|
|
40574
40677
|
try {
|
|
40575
40678
|
await this.dap().connect();
|
|
@@ -40620,6 +40723,7 @@ class GodotServer {
|
|
|
40620
40723
|
through: "editor",
|
|
40621
40724
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
40622
40725
|
refreshedClasses,
|
|
40726
|
+
...scanWaitAnswer(scanned),
|
|
40623
40727
|
debugPort: readNumber(playAnswer, "debugPort"),
|
|
40624
40728
|
endedPreviousRun: endedPreviousRun(ended),
|
|
40625
40729
|
breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
|
|
@@ -41123,7 +41227,7 @@ class GodotServer {
|
|
|
41123
41227
|
currentRun() {
|
|
41124
41228
|
return this.activeProcess ?? this.adoptRecordedRun();
|
|
41125
41229
|
}
|
|
41126
|
-
async checkBoot(godotPath, projectPath, scene, args, userArgs, asked = {}) {
|
|
41230
|
+
async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}) {
|
|
41127
41231
|
const frames = readPositiveNumber(args, "frames") ?? 3;
|
|
41128
41232
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
|
|
41129
41233
|
const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
|
|
@@ -41153,6 +41257,7 @@ class GodotServer {
|
|
|
41153
41257
|
frames,
|
|
41154
41258
|
errors,
|
|
41155
41259
|
warnings,
|
|
41260
|
+
...scanWaitAnswer(scanned),
|
|
41156
41261
|
savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
41157
41262
|
entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
|
|
41158
41263
|
});
|
|
@@ -41536,6 +41641,10 @@ class GodotServer {
|
|
|
41536
41641
|
return now === null ? [] : lost.filter((name) => now.has(name));
|
|
41537
41642
|
}
|
|
41538
41643
|
async rebuildClassCache(projectPath) {
|
|
41644
|
+
const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
|
|
41645
|
+
return withScanWait(await this.rebuildClassCacheFromDisk(projectPath), scanned);
|
|
41646
|
+
}
|
|
41647
|
+
async rebuildClassCacheFromDisk(projectPath) {
|
|
41539
41648
|
const before = cachedClasses(projectPath);
|
|
41540
41649
|
const writtenBefore = cacheWrittenAt(projectPath);
|
|
41541
41650
|
const noted = readClassNote(projectPath);
|
|
@@ -41924,7 +42033,7 @@ async function runGodotServer() {
|
|
|
41924
42033
|
const server = new GodotServer;
|
|
41925
42034
|
await server.run();
|
|
41926
42035
|
}
|
|
41927
|
-
var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
|
|
42036
|
+
var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
|
|
41928
42037
|
var init_server3 = __esm(() => {
|
|
41929
42038
|
init_mcp();
|
|
41930
42039
|
init_stdio2();
|
|
@@ -43697,6 +43806,13 @@ a class it cannot resolve would go out of that file with it. The answer names th
|
|
|
43697
43806
|
\`cacheRestored\`, so no fresh engine, CI run or clone inherits the short file. That is the same
|
|
43698
43807
|
class \`unseenByEditor\` names, and the loss is not a reason to refuse the scan.
|
|
43699
43808
|
|
|
43809
|
+
The editor also scans on its own, when files change under it, and it rewrites that file just after
|
|
43810
|
+
the scan ends. Every engine this server starts on the project (a run, a boot check, a test run, a
|
|
43811
|
+
headless operation) waits until a scan in progress and the write after it are done, and says how
|
|
43812
|
+
long under \`waitedForEditorScanMs\`. A \`scanNote\` means a scan was still going after thirty
|
|
43813
|
+
seconds and the engine started anyway: a "Could not find type" from that engine is the scan, not
|
|
43814
|
+
the script, and \`editor_rescan\` followed by the same call again is the cure.
|
|
43815
|
+
|
|
43700
43816
|
A diagnostic about a member rather than a type, \`Static function "x()" not found in base "Y"\`
|
|
43701
43817
|
about something the engine compiles, is a different thing and is no longer stale here: each ask
|
|
43702
43818
|
gives the document back when its answer arrives, so the next one is read off disk. If one turns
|
|
@@ -79,6 +79,7 @@ func _init_tools() -> void:
|
|
|
79
79
|
"disconnect_signal": [_scene_tools, "disconnect_signal"],
|
|
80
80
|
"list_connections": [_scene_tools, "list_connections"],
|
|
81
81
|
"rescan_filesystem": [_scene_tools, "rescan_filesystem"],
|
|
82
|
+
"scan_status": [_scene_tools, "scan_status"],
|
|
82
83
|
"global_classes": [_class_tools, "global_classes"],
|
|
83
84
|
"reload_script": [_class_tools, "reload_script"],
|
|
84
85
|
# Settings, for a caller that asked the editor rather than the file on disk
|
|
@@ -18,6 +18,12 @@ var _scans_finished: int = 0
|
|
|
18
18
|
var _scans_finished_at_request: int = 0
|
|
19
19
|
var _scan_pending: bool = false
|
|
20
20
|
|
|
21
|
+
## When the last scan finished, on the engine's clock, or -1 before the first. The editor writes
|
|
22
|
+
## the class cache a frame after it stops reporting a scan, measured on 4.7.2, so a game started
|
|
23
|
+
## in between reads a file being rewritten; how long ago the scan ended is what tells a caller
|
|
24
|
+
## whether that write can still be coming.
|
|
25
|
+
var _scan_finished_at_msec: int = -1
|
|
26
|
+
|
|
21
27
|
|
|
22
28
|
func set_editor_plugin(plugin: EditorPlugin) -> void:
|
|
23
29
|
_editor_plugin = plugin
|
|
@@ -31,6 +37,7 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
|
|
|
31
37
|
func _scan_finished() -> void:
|
|
32
38
|
_scans_finished += 1
|
|
33
39
|
_scan_pending = false
|
|
40
|
+
_scan_finished_at_msec = Time.get_ticks_msec()
|
|
34
41
|
|
|
35
42
|
|
|
36
43
|
func _refresh_and_reload(scene_path: String) -> void:
|
|
@@ -675,3 +682,22 @@ func rescan_filesystem(args: Dictionary) -> Dictionary:
|
|
|
675
682
|
"pending": _scan_pending,
|
|
676
683
|
"scansFinished": _scans_finished,
|
|
677
684
|
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
## Whether the editor is scanning, and how long ago its last scan finished, without asking for one.
|
|
688
|
+
##
|
|
689
|
+
## For a caller about to start a game: the editor rewrites the class cache a frame after a scan
|
|
690
|
+
## stops reporting, so a game booted during the scan or in that frame reads a file being rewritten.
|
|
691
|
+
## Apart from `rescan_filesystem` because asking must not start or count anything.
|
|
692
|
+
func scan_status(_args: Dictionary) -> Dictionary:
|
|
693
|
+
if not _editor_plugin:
|
|
694
|
+
return {"ok": false, "error": "Editor plugin unavailable"}
|
|
695
|
+
var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
|
|
696
|
+
return {
|
|
697
|
+
"ok": true,
|
|
698
|
+
"scanning": filesystem.is_scanning(),
|
|
699
|
+
"importing": filesystem.is_importing(),
|
|
700
|
+
"pending": _scan_pending,
|
|
701
|
+
"sinceScanFinishedMs":
|
|
702
|
+
-1 if _scan_finished_at_msec < 0 else Time.get_ticks_msec() - _scan_finished_at_msec,
|
|
703
|
+
}
|
|
@@ -10,6 +10,7 @@ const Values = preload("runtime_values.gd")
|
|
|
10
10
|
## reports are the same place by construction rather than by agreement.
|
|
11
11
|
const Queries = preload("runtime_queries.gd")
|
|
12
12
|
const Read = preload("reading.gd")
|
|
13
|
+
const Words = preload("runtime_words.gd")
|
|
13
14
|
|
|
14
15
|
## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
|
|
15
16
|
const TO_SMALL: int = 32
|
|
@@ -239,11 +240,12 @@ static func _select_everything(focused: Control) -> bool:
|
|
|
239
240
|
## What the field says now, or null where the focus is not a field at all.
|
|
240
241
|
##
|
|
241
242
|
## A spin box rewrites its own text out of the number it parsed, so this is the only thing that
|
|
242
|
-
## says whether what was typed became what the field means.
|
|
243
|
+
## says whether what was typed became what the field means. A secret field answers with its mask,
|
|
244
|
+
## which still says how many characters it took.
|
|
243
245
|
static func _what_it_holds(focused: Control) -> Variant:
|
|
244
246
|
var field: LineEdit = focused as LineEdit
|
|
245
247
|
if field != null:
|
|
246
|
-
return field.text
|
|
248
|
+
return Words.masked(field) if field.secret else field.text
|
|
247
249
|
var box: TextEdit = focused as TextEdit
|
|
248
250
|
if box == null:
|
|
249
251
|
return null
|
|
@@ -666,7 +668,7 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
666
668
|
if menu.is_item_disabled(index):
|
|
667
669
|
return {
|
|
668
670
|
"type": "error",
|
|
669
|
-
"message": "%s item %d, %s, is disabled" % [node_path, index,
|
|
671
|
+
"message": "%s item %d, %s, is disabled" % [node_path, index, Words.item_says(menu, index)]
|
|
670
672
|
}
|
|
671
673
|
|
|
672
674
|
# Shown first, because a menu nobody has opened has no focus to move and Enter would go to
|
|
@@ -689,7 +691,7 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
689
691
|
"type": "chosen",
|
|
690
692
|
"path": node_path,
|
|
691
693
|
"index": index,
|
|
692
|
-
"text":
|
|
694
|
+
"text": Words.item_says(menu, index),
|
|
693
695
|
"id": menu.get_item_id(index),
|
|
694
696
|
"opened": opened,
|
|
695
697
|
"menu": str(menu.get_path()),
|
|
@@ -699,7 +701,7 @@ func choose(params: Dictionary) -> Dictionary:
|
|
|
699
701
|
var chooser: OptionButton = node as OptionButton
|
|
700
702
|
if chooser != null:
|
|
701
703
|
answer["selected"] = chooser.get_selected()
|
|
702
|
-
answer["shows"] = chooser
|
|
704
|
+
answer["shows"] = Words.said_by(chooser)
|
|
703
705
|
return answer
|
|
704
706
|
|
|
705
707
|
|
|
@@ -718,6 +720,11 @@ static func _menu_of(node: Node) -> PopupMenu:
|
|
|
718
720
|
|
|
719
721
|
## Which item was asked for: `text`, matched exactly and then case-insensitively, or `index`.
|
|
720
722
|
## Minus one when neither names one that is there.
|
|
723
|
+
##
|
|
724
|
+
## `text` is matched against the words the item shows before the ones it holds. In a game with
|
|
725
|
+
## translations they differ, and the shown ones are what a caller has read off the screen: an item
|
|
726
|
+
## held as `ACT_ENGAGE` and shown as "Hire" refused "Hire" and listed the keys. The held words
|
|
727
|
+
## still count after them, for a caller that has the key from the source.
|
|
721
728
|
static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
|
|
722
729
|
if params.has("index"):
|
|
723
730
|
var asked: int = Read.as_int(params.get("index", -1), -1)
|
|
@@ -725,20 +732,26 @@ static func _wanted_item(menu: PopupMenu, params: Dictionary) -> int:
|
|
|
725
732
|
var wanted: String = str(params.get("text", ""))
|
|
726
733
|
if wanted.is_empty():
|
|
727
734
|
return -1
|
|
735
|
+
var shown: Array[String] = []
|
|
736
|
+
var held: Array[String] = []
|
|
728
737
|
for index: int in menu.get_item_count():
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
for
|
|
732
|
-
|
|
733
|
-
|
|
738
|
+
shown.append(Words.item_says(menu, index))
|
|
739
|
+
held.append(menu.get_item_text(index))
|
|
740
|
+
for words: Array[String] in [shown, held]:
|
|
741
|
+
for index: int in words.size():
|
|
742
|
+
if words[index] == wanted:
|
|
743
|
+
return index
|
|
744
|
+
for index: int in words.size():
|
|
745
|
+
if words[index].nocasecmp_to(wanted) == 0:
|
|
746
|
+
return index
|
|
734
747
|
return -1
|
|
735
748
|
|
|
736
749
|
|
|
737
|
-
## What the menu
|
|
750
|
+
## What the menu shows, for a refusal that names the choices rather than the miss.
|
|
738
751
|
static func _items_of(menu: PopupMenu) -> Array[String]:
|
|
739
752
|
var said: Array[String] = []
|
|
740
753
|
for index: int in menu.get_item_count():
|
|
741
|
-
said.append("%d: %s" % [index,
|
|
754
|
+
said.append("%d: %s" % [index, Words.item_says(menu, index)])
|
|
742
755
|
return said
|
|
743
756
|
|
|
744
757
|
|
|
@@ -6,6 +6,7 @@ extends RefCounted
|
|
|
6
6
|
const Paths = preload("runtime_paths.gd")
|
|
7
7
|
const Read = preload("reading.gd")
|
|
8
8
|
const Values = preload("runtime_values.gd")
|
|
9
|
+
const Words = preload("runtime_words.gd")
|
|
9
10
|
|
|
10
11
|
## The most nodes one find answers with, unless asked for fewer: enough for any real query and
|
|
11
12
|
## far short of the tree dump a query exists to avoid.
|
|
@@ -251,7 +252,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
|
|
|
251
252
|
# What this node says, rather than everything said underneath it. A row is then found by the
|
|
252
253
|
# label in it, and the path answered is that label's, which is where the words a caller is
|
|
253
254
|
# looking at actually are: matching every container above it would answer with the screen.
|
|
254
|
-
if not wanted["says"].is_empty() and not _says(said_by(node), wanted["says"]):
|
|
255
|
+
if not wanted["says"].is_empty() and not _says(Words.said_by(node), wanted["says"]):
|
|
255
256
|
return false
|
|
256
257
|
var script: Variant = node.get_script()
|
|
257
258
|
if not wanted["script"].is_empty():
|
|
@@ -346,8 +347,7 @@ func _read_into(node: Node, include_hidden: bool, most: int, into: Array[String]
|
|
|
346
347
|
if not include_hidden and not _drawn(node):
|
|
347
348
|
return 0
|
|
348
349
|
var left_out: int = 0
|
|
349
|
-
|
|
350
|
-
if not said.is_empty():
|
|
350
|
+
for said: String in Words.lines_said_by(node):
|
|
351
351
|
if into.size() < most:
|
|
352
352
|
into.append(said)
|
|
353
353
|
else:
|
|
@@ -373,25 +373,6 @@ static func _drawn(node: Node) -> bool:
|
|
|
373
373
|
return window == null or window.visible
|
|
374
374
|
|
|
375
375
|
|
|
376
|
-
## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
|
|
377
|
-
## is every label, button and field the interface is built out of.
|
|
378
|
-
##
|
|
379
|
-
## Public because three questions are the same question: what a screen reads as, which nodes say a
|
|
380
|
-
## given word, and whether anything has come to say it yet. Two copies of what a node says is how
|
|
381
|
-
## the three of them come to disagree about a SpinBox.
|
|
382
|
-
##
|
|
383
|
-
## Read rather than looked up in the property list, which the engine builds afresh on every call: a
|
|
384
|
-
## wait asks this of every node on the screen every frame, and on a hall of 3,500 nodes the lookup
|
|
385
|
-
## took the game it was watching from 60 frames a second to 11. A node without the property reads
|
|
386
|
-
## as null, which is not a string.
|
|
387
|
-
static func said_by(node: Node) -> String:
|
|
388
|
-
var text: Variant = node.get("text")
|
|
389
|
-
if not text is String:
|
|
390
|
-
return ""
|
|
391
|
-
var words: String = text
|
|
392
|
-
return words.strip_edges()
|
|
393
|
-
|
|
394
|
-
|
|
395
376
|
func get_rect(params: Dictionary) -> Dictionary:
|
|
396
377
|
var node_path: String = str(params.get("path", ""))
|
|
397
378
|
if node_path.is_empty():
|
|
@@ -7,6 +7,7 @@ const Paths = preload("runtime_paths.gd")
|
|
|
7
7
|
const Queries = preload("runtime_queries.gd")
|
|
8
8
|
const Read = preload("reading.gd")
|
|
9
9
|
const Values = preload("runtime_values.gd")
|
|
10
|
+
const Words = preload("runtime_words.gd")
|
|
10
11
|
|
|
11
12
|
## The longest one wait may last, whatever the request says: past this the server has long
|
|
12
13
|
## since given up on the reply.
|
|
@@ -291,7 +292,7 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
|
|
|
291
292
|
var pending: Array[Node] = [root]
|
|
292
293
|
while not pending.is_empty():
|
|
293
294
|
var node: Node = pending.pop_back()
|
|
294
|
-
if
|
|
295
|
+
if Words.said_by(node).containsn(said) and (include_hidden or Queries.shown(node)):
|
|
295
296
|
return true
|
|
296
297
|
pending.append_array(node.get_children(true))
|
|
297
298
|
return false
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
extends RefCounted
|
|
2
|
+
|
|
3
|
+
## What a node says to a player, read the way it is drawn: the parsed words of rich text, as much of
|
|
4
|
+
## a line as is typed out so far, the translation rather than its key, a mask over a secret, and the
|
|
5
|
+
## items of the controls that draw a list.
|
|
6
|
+
##
|
|
7
|
+
## One module because three questions are the same question: what a screen reads as, which nodes say
|
|
8
|
+
## a given word, and whether anything has come to say it yet. Two copies of what a node says is how
|
|
9
|
+
## the three of them come to disagree about a SpinBox.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## What one node says, or "" for a node that says nothing. Anything with a `text` property, which
|
|
13
|
+
## is every label, button and field the interface is built out of.
|
|
14
|
+
##
|
|
15
|
+
## Read rather than looked up in the property list, which the engine builds afresh on every call: a
|
|
16
|
+
## wait asks this of every node on the screen every frame, and on a hall of 3,500 nodes the lookup
|
|
17
|
+
## took the game it was watching from 60 frames a second to 11. A node without the property reads
|
|
18
|
+
## as null, which is not a string.
|
|
19
|
+
##
|
|
20
|
+
## A label is asked about first and read directly, because a screen is mostly labels and this runs
|
|
21
|
+
## for each of them on every look a wait takes.
|
|
22
|
+
static func said_by(node: Node) -> String:
|
|
23
|
+
var label: Label = node as Label
|
|
24
|
+
if label != null:
|
|
25
|
+
return _label_says(label)
|
|
26
|
+
if _draws_a_list(node):
|
|
27
|
+
return "\n".join(lines_said_by(node))
|
|
28
|
+
return _text_of(node)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
## Every line [param node] draws for a player to read, one to an entry: a label's one line, or each
|
|
32
|
+
## item of a control that draws a list of them. Tab titles, list items, tree rows, menu titles and
|
|
33
|
+
## the items of an open menu are held by the control rather than in any `text`, so a screen read as
|
|
34
|
+
## every label on it and none of those, and a wait for a tab's title or a list item was never met.
|
|
35
|
+
static func lines_said_by(node: Node) -> Array[String]:
|
|
36
|
+
var lines: Array[String] = []
|
|
37
|
+
if node is TabBar:
|
|
38
|
+
var bar: TabBar = node
|
|
39
|
+
for tab: int in bar.tab_count:
|
|
40
|
+
if not bar.is_tab_hidden(tab):
|
|
41
|
+
_keep(node.atr(bar.get_tab_title(tab)), lines)
|
|
42
|
+
elif node is MenuBar:
|
|
43
|
+
var menus: MenuBar = node
|
|
44
|
+
for menu: int in menus.get_menu_count():
|
|
45
|
+
if not menus.is_menu_hidden(menu):
|
|
46
|
+
_keep(node.atr(menus.get_menu_title(menu)), lines)
|
|
47
|
+
elif node is ItemList:
|
|
48
|
+
var list: ItemList = node
|
|
49
|
+
for item: int in list.item_count:
|
|
50
|
+
_keep(_as_drawn(node, list.get_item_text(item), list.get_item_auto_translate_mode(item)), lines)
|
|
51
|
+
elif node is PopupMenu:
|
|
52
|
+
var menu: PopupMenu = node
|
|
53
|
+
for item: int in menu.item_count:
|
|
54
|
+
if not menu.is_item_separator(item):
|
|
55
|
+
_keep(item_says(menu, item), lines)
|
|
56
|
+
elif node is Tree:
|
|
57
|
+
var tree: Tree = node
|
|
58
|
+
if tree.column_titles_visible:
|
|
59
|
+
var titles: Array[String] = []
|
|
60
|
+
for column: int in tree.columns:
|
|
61
|
+
_keep(node.atr(tree.get_column_title(column)), titles)
|
|
62
|
+
_keep(" ".join(titles), lines)
|
|
63
|
+
_tree_rows(tree, tree.get_root(), tree.hide_root, lines)
|
|
64
|
+
else:
|
|
65
|
+
_keep(_text_of(node), lines)
|
|
66
|
+
return lines
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
## What item [param index] of [param menu] shows: the translation where the item has one. A menu is
|
|
70
|
+
## chosen from by these words, since they are the ones a caller has read off the screen.
|
|
71
|
+
static func item_says(menu: PopupMenu, index: int) -> String:
|
|
72
|
+
return _as_drawn(menu, menu.get_item_text(index), menu.get_item_auto_translate_mode(index))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
## What a secret [param field] draws: one mask character for each character it holds. Reading the
|
|
76
|
+
## words back would hand a password to whoever reads the answer, which the player cannot see either.
|
|
77
|
+
static func masked(field: LineEdit) -> String:
|
|
78
|
+
return field.secret_character.repeat(field.text.length())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
static func _draws_a_list(node: Node) -> bool:
|
|
82
|
+
return node is TabBar or node is MenuBar or node is ItemList or node is PopupMenu or node is Tree
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
static func _keep(line: String, into: Array[String]) -> void:
|
|
86
|
+
var trimmed: String = line.strip_edges()
|
|
87
|
+
if not trimmed.is_empty():
|
|
88
|
+
into.append(trimmed)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
## [param words] as [param node] draws them, which for a game with translations is the translation
|
|
92
|
+
## rather than the key it holds: a control set to `UI_HIRE` shows "Hire", and a wait for "Hire" was
|
|
93
|
+
## never met. [param mode] is an item's own setting, which the engine reads before the node's.
|
|
94
|
+
static func _as_drawn(
|
|
95
|
+
node: Node, words: String, mode: Node.AutoTranslateMode = Node.AUTO_TRANSLATE_MODE_INHERIT
|
|
96
|
+
) -> String:
|
|
97
|
+
match mode:
|
|
98
|
+
Node.AUTO_TRANSLATE_MODE_ALWAYS:
|
|
99
|
+
return node.tr(words)
|
|
100
|
+
Node.AUTO_TRANSLATE_MODE_DISABLED:
|
|
101
|
+
return words
|
|
102
|
+
return node.atr(words)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## The rows of a tree from [param item] down, a row being its columns' text side by side, and
|
|
106
|
+
## nothing under a collapsed row or a hidden one, since neither is drawn. A hidden root is only the
|
|
107
|
+
## place its children hang from.
|
|
108
|
+
static func _tree_rows(tree: Tree, item: TreeItem, skip: bool, into: Array[String]) -> void:
|
|
109
|
+
if item == null or not item.visible:
|
|
110
|
+
return
|
|
111
|
+
if not skip:
|
|
112
|
+
var cells: Array[String] = []
|
|
113
|
+
for column: int in tree.columns:
|
|
114
|
+
_keep(_as_drawn(tree, item.get_text(column), item.get_auto_translate_mode(column)), cells)
|
|
115
|
+
_keep(" ".join(cells), into)
|
|
116
|
+
if item.collapsed:
|
|
117
|
+
return
|
|
118
|
+
var child: TreeItem = item.get_first_child()
|
|
119
|
+
while child != null:
|
|
120
|
+
_tree_rows(tree, child, false, into)
|
|
121
|
+
child = child.get_next()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
## What a node with a `text` says, as it is drawn.
|
|
125
|
+
static func _text_of(node: Node) -> String:
|
|
126
|
+
var label: Label = node as Label
|
|
127
|
+
if label != null:
|
|
128
|
+
return _label_says(label)
|
|
129
|
+
# A RichTextLabel's text is its markup when it reads BBCode, and nobody reads the tags: a
|
|
130
|
+
# dossier line came back as "[b][color=#4fc2d4]Mollum Telken[/color], [/b]..." and a phrase
|
|
131
|
+
# running across a tag could not be found or waited for. The parsed text is what is drawn, and
|
|
132
|
+
# it also holds what a game added with append_text(), which the property never shows. It is
|
|
133
|
+
# parsed from the translation already.
|
|
134
|
+
if node is RichTextLabel:
|
|
135
|
+
var rich: RichTextLabel = node
|
|
136
|
+
return _shown_part(rich.get_parsed_text(), rich.visible_characters)
|
|
137
|
+
var text: Variant = node.get("text")
|
|
138
|
+
if not text is String:
|
|
139
|
+
return ""
|
|
140
|
+
var words: String = text
|
|
141
|
+
# What somebody typed is drawn as typed, never translated.
|
|
142
|
+
if node is LineEdit:
|
|
143
|
+
var field: LineEdit = node
|
|
144
|
+
return masked(field) if field.secret else words.strip_edges()
|
|
145
|
+
if node is TextEdit:
|
|
146
|
+
return words.strip_edges()
|
|
147
|
+
words = node.atr(words)
|
|
148
|
+
if node is Label3D:
|
|
149
|
+
var sign_text: Label3D = node
|
|
150
|
+
return (words.to_upper() if sign_text.uppercase else words).strip_edges()
|
|
151
|
+
return words.strip_edges()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
static func _label_says(label: Label) -> String:
|
|
155
|
+
var words: String = label.atr(label.text)
|
|
156
|
+
return _shown_part(words.to_upper() if label.uppercase else words, label.visible_characters)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
## As much of [param words] as is drawn: a label typing a line out shows [param drawn] characters
|
|
160
|
+
## of it, and -1 is all of them. Read whole, a words wait was met on the first frame of a line being
|
|
161
|
+
## typed out, before the player could read any of it.
|
|
162
|
+
static func _shown_part(words: String, drawn: int) -> String:
|
|
163
|
+
return (words if drawn < 0 else words.substr(0, drawn)).strip_edges()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uid://u3ml7sk81frh
|
package/build/index.js
CHANGED
|
@@ -30316,7 +30316,7 @@ var TOOL_SPECS = [
|
|
|
30316
30316
|
},
|
|
30317
30317
|
{
|
|
30318
30318
|
name: "editor_run",
|
|
30319
|
-
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. 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.",
|
|
30319
|
+
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.",
|
|
30320
30320
|
parameters: {
|
|
30321
30321
|
projectPath: { ...PROJECT_PATH, ops: ["start", "check"] },
|
|
30322
30322
|
scene: {
|
|
@@ -30433,7 +30433,7 @@ var TOOL_SPECS = [
|
|
|
30433
30433
|
},
|
|
30434
30434
|
{
|
|
30435
30435
|
name: "editor_status",
|
|
30436
|
-
description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
|
|
30436
|
+
description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
|
|
30437
30437
|
parameters: {},
|
|
30438
30438
|
requires: []
|
|
30439
30439
|
},
|
|
@@ -30545,7 +30545,7 @@ var TOOL_SPECS = [
|
|
|
30545
30545
|
says: {
|
|
30546
30546
|
type: "string",
|
|
30547
30547
|
ops: ["find"],
|
|
30548
|
-
description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
|
|
30548
|
+
description: "find: part of what the node has written on it as drawn, the way the text op reads it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern. A label with a line break is matched with the break in the words, and a backslash followed by n counts as one."
|
|
30549
30549
|
},
|
|
30550
30550
|
limit: {
|
|
30551
30551
|
type: "integer",
|
|
@@ -30574,7 +30574,7 @@ var TOOL_SPECS = [
|
|
|
30574
30574
|
operations: {
|
|
30575
30575
|
tree: { summary: "the live scene tree", requires: [] },
|
|
30576
30576
|
text: {
|
|
30577
|
-
summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it",
|
|
30577
|
+
summary: "every line of text under nodePath, the values in its fields included, in the order somebody reads the screen, leaving out what is hidden and everything under it. Every line reads as drawn: rich text without its tags, text only as far as it has been typed out, a translation rather than its key, an upper-case label in capitals and a secret field as its mask. Tab titles, menu bar titles, list items, a tree's column titles and rows, and an open menu's items read a line each",
|
|
30578
30578
|
requires: []
|
|
30579
30579
|
},
|
|
30580
30580
|
find: {
|
|
@@ -30723,7 +30723,7 @@ var TOOL_SPECS = [
|
|
|
30723
30723
|
type: "string",
|
|
30724
30724
|
ops: ["text", "choose"],
|
|
30725
30725
|
blank: true,
|
|
30726
|
-
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.'
|
|
30726
|
+
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 as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
|
|
30727
30727
|
},
|
|
30728
30728
|
replace: {
|
|
30729
30729
|
type: "boolean",
|
|
@@ -30785,7 +30785,7 @@ var TOOL_SPECS = [
|
|
|
30785
30785
|
requires: ["keycode"]
|
|
30786
30786
|
},
|
|
30787
30787
|
text: {
|
|
30788
|
-
summary: "type a string into the field being edited, a character at a time, and say what it landed in",
|
|
30788
|
+
summary: "type a string into the field being edited, a character at a time, and say what it landed in and what it holds afterwards, a secret field as its mask",
|
|
30789
30789
|
requires: ["text"]
|
|
30790
30790
|
},
|
|
30791
30791
|
mouse_click: {
|
|
@@ -31263,6 +31263,34 @@ var SUCCESSOR_CHECK_MS = 1e4;
|
|
|
31263
31263
|
var HANDOVER_MS = SUCCESSOR_CHECK_MS + 5000;
|
|
31264
31264
|
var HANDOVER_POLL_MS = 250;
|
|
31265
31265
|
var CACHE_WRITE_MS = 2000;
|
|
31266
|
+
var SCAN_WAIT_MS = 30000;
|
|
31267
|
+
function scanWaitIn(payload) {
|
|
31268
|
+
const waited = readNumber(payload, "waitedForEditorScanMs");
|
|
31269
|
+
const note = readString(payload, "scanNote");
|
|
31270
|
+
return {
|
|
31271
|
+
...waited === undefined ? {} : { waitedForEditorScanMs: waited },
|
|
31272
|
+
...note === undefined ? {} : { scanNote: note }
|
|
31273
|
+
};
|
|
31274
|
+
}
|
|
31275
|
+
function withScanWait(outcome, scanned) {
|
|
31276
|
+
if (scanned.waitedMs === 0) {
|
|
31277
|
+
return outcome;
|
|
31278
|
+
}
|
|
31279
|
+
const said = scanWaitAnswer(scanned);
|
|
31280
|
+
if (outcome.ok) {
|
|
31281
|
+
return { ...outcome, payload: { ...outcome.payload, ...said } };
|
|
31282
|
+
}
|
|
31283
|
+
return said.scanNote === undefined ? outcome : { ...outcome, message: `${outcome.message} ${said.scanNote}` };
|
|
31284
|
+
}
|
|
31285
|
+
function scanWaitAnswer(scanned) {
|
|
31286
|
+
if (scanned.waitedMs === 0) {
|
|
31287
|
+
return {};
|
|
31288
|
+
}
|
|
31289
|
+
return {
|
|
31290
|
+
waitedForEditorScanMs: scanned.waitedMs,
|
|
31291
|
+
scanNote: scanned.stillScanning ? `The editor was still scanning the project after ${SCAN_WAIT_MS / 1000} seconds, so this ` + "game started during the scan and may have read a class cache being rewritten. If " + 'editor_output shows "Could not find type" for a class that is declared, editor_rescan ' + "waits for the scan to finish, and a start after it reads the finished cache." : undefined
|
|
31292
|
+
};
|
|
31293
|
+
}
|
|
31266
31294
|
var PLAYING_STATUS_MS = 1000;
|
|
31267
31295
|
var SCAN_START_MS = 2000;
|
|
31268
31296
|
var WAIT_FOR_RUN_MS = 600000;
|
|
@@ -32230,6 +32258,47 @@ class GodotServer {
|
|
|
32230
32258
|
}
|
|
32231
32259
|
return { ok: true, value: stale };
|
|
32232
32260
|
}
|
|
32261
|
+
async untilTheEditorsScanIsWritten(projectPath) {
|
|
32262
|
+
const open = this.godotBridge.getStatus().projectPath;
|
|
32263
|
+
if (!this.godotBridge.isConnected() || open === undefined || !isSameDirectory(open, projectPath)) {
|
|
32264
|
+
return { waitedMs: 0, stillScanning: false };
|
|
32265
|
+
}
|
|
32266
|
+
const started = Date.now();
|
|
32267
|
+
let sawAScan = false;
|
|
32268
|
+
let scanEndedAt = null;
|
|
32269
|
+
for (;; ) {
|
|
32270
|
+
const asked = Date.now();
|
|
32271
|
+
let status;
|
|
32272
|
+
try {
|
|
32273
|
+
status = asParams(await this.godotBridge.invokeTool("scan_status", {}));
|
|
32274
|
+
} catch {
|
|
32275
|
+
return { waitedMs: Date.now() - started, stillScanning: false };
|
|
32276
|
+
}
|
|
32277
|
+
if (status["scanning"] === true || status["importing"] === true || status["pending"] === true) {
|
|
32278
|
+
sawAScan = true;
|
|
32279
|
+
if (Date.now() - started >= SCAN_WAIT_MS) {
|
|
32280
|
+
return { waitedMs: Date.now() - started, stillScanning: true };
|
|
32281
|
+
}
|
|
32282
|
+
await new Promise((settle) => setTimeout(settle, 100));
|
|
32283
|
+
continue;
|
|
32284
|
+
}
|
|
32285
|
+
const since = readNumber(status, "sinceScanFinishedMs");
|
|
32286
|
+
scanEndedAt = since !== undefined && since >= 0 ? asked - since : sawAScan ? asked : null;
|
|
32287
|
+
break;
|
|
32288
|
+
}
|
|
32289
|
+
let waitedForTheWrite = false;
|
|
32290
|
+
if (scanEndedAt !== null) {
|
|
32291
|
+
const writeDue = scanEndedAt + CACHE_WRITE_MS;
|
|
32292
|
+
while (Date.now() < writeDue && (cacheWrittenAt(projectPath) ?? 0) < scanEndedAt) {
|
|
32293
|
+
waitedForTheWrite = true;
|
|
32294
|
+
await new Promise((settle) => setTimeout(settle, 50));
|
|
32295
|
+
}
|
|
32296
|
+
}
|
|
32297
|
+
return {
|
|
32298
|
+
waitedMs: sawAScan || waitedForTheWrite ? Date.now() - started : 0,
|
|
32299
|
+
stillScanning: false
|
|
32300
|
+
};
|
|
32301
|
+
}
|
|
32233
32302
|
containProjectFiles(args) {
|
|
32234
32303
|
const projectPath = readString(args, "projectPath");
|
|
32235
32304
|
if (!projectPath) {
|
|
@@ -32290,14 +32359,15 @@ class GodotServer {
|
|
|
32290
32359
|
return engine.response;
|
|
32291
32360
|
}
|
|
32292
32361
|
const projectPath = project.value.path;
|
|
32362
|
+
const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
|
|
32293
32363
|
const before = scriptsWithoutUid(projectPath);
|
|
32294
32364
|
const imported = await runImport(engine.value, projectPath);
|
|
32295
32365
|
if (!imported.ok) {
|
|
32296
|
-
return this.answer(imported);
|
|
32366
|
+
return this.answer(withScanWait(imported, scanned));
|
|
32297
32367
|
}
|
|
32298
32368
|
const after = scriptsWithoutUid(projectPath);
|
|
32299
32369
|
const given = before.filter((script) => !after.includes(script));
|
|
32300
|
-
return this.answer({
|
|
32370
|
+
return this.answer(withScanWait({
|
|
32301
32371
|
ok: true,
|
|
32302
32372
|
messages: imported.messages,
|
|
32303
32373
|
payload: {
|
|
@@ -32305,7 +32375,7 @@ class GodotServer {
|
|
|
32305
32375
|
stillWithoutUid: after,
|
|
32306
32376
|
note: uidsLeftNote(after.length)
|
|
32307
32377
|
}
|
|
32308
|
-
});
|
|
32378
|
+
}, scanned));
|
|
32309
32379
|
}
|
|
32310
32380
|
async operation(operation, params, projectPath) {
|
|
32311
32381
|
const engine = await this.engine();
|
|
@@ -32316,8 +32386,10 @@ class GodotServer {
|
|
|
32316
32386
|
messages: []
|
|
32317
32387
|
};
|
|
32318
32388
|
}
|
|
32389
|
+
const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
|
|
32319
32390
|
this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
|
|
32320
|
-
|
|
32391
|
+
const outcome = await runOperation({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
|
|
32392
|
+
return withScanWait(outcome, scanned);
|
|
32321
32393
|
}
|
|
32322
32394
|
answer(outcome) {
|
|
32323
32395
|
if (!outcome.ok) {
|
|
@@ -32499,6 +32571,7 @@ class GodotServer {
|
|
|
32499
32571
|
if (!classes.ok) {
|
|
32500
32572
|
return this.answer(classes);
|
|
32501
32573
|
}
|
|
32574
|
+
const scanned = scanWaitIn(classes.payload);
|
|
32502
32575
|
const ours = `gdharness-reports/${randomUUID2()}`;
|
|
32503
32576
|
const reports = `res://.godot/${ours}`;
|
|
32504
32577
|
const ignored = readStringArray(args, "ignore") ?? [];
|
|
@@ -32589,7 +32662,8 @@ class GodotServer {
|
|
|
32589
32662
|
hung,
|
|
32590
32663
|
arguments: cmdArgs,
|
|
32591
32664
|
entries: forAnswer(printed.slice(0, 60)),
|
|
32592
|
-
savesNote
|
|
32665
|
+
savesNote,
|
|
32666
|
+
...scanned
|
|
32593
32667
|
}, null, 2)
|
|
32594
32668
|
}
|
|
32595
32669
|
],
|
|
@@ -32624,6 +32698,7 @@ class GodotServer {
|
|
|
32624
32698
|
orphans: orphans.total > 0 ? orphans.total : undefined,
|
|
32625
32699
|
notRun: notRun > 0 ? notRun : undefined,
|
|
32626
32700
|
savesNote,
|
|
32701
|
+
...scanned,
|
|
32627
32702
|
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." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
|
|
32628
32703
|
suites: unclean.map((suite) => ({
|
|
32629
32704
|
name: suite.name,
|
|
@@ -32922,6 +32997,28 @@ class GodotServer {
|
|
|
32922
32997
|
return null;
|
|
32923
32998
|
}
|
|
32924
32999
|
}
|
|
33000
|
+
async editorScanState() {
|
|
33001
|
+
if (!this.godotBridge.getStatus().connected) {
|
|
33002
|
+
return null;
|
|
33003
|
+
}
|
|
33004
|
+
try {
|
|
33005
|
+
const answer = await Promise.race([
|
|
33006
|
+
this.godotBridge.invokeTool("scan_status", {}),
|
|
33007
|
+
delay3(PLAYING_STATUS_MS).then(() => null)
|
|
33008
|
+
]);
|
|
33009
|
+
if (answer === null) {
|
|
33010
|
+
return null;
|
|
33011
|
+
}
|
|
33012
|
+
const since = readNumber(asParams(answer), "sinceScanFinishedMs");
|
|
33013
|
+
return {
|
|
33014
|
+
scanning: readBoolean(asParams(answer), "scanning") ?? false,
|
|
33015
|
+
importing: readBoolean(asParams(answer), "importing") ?? false,
|
|
33016
|
+
finishedMsAgo: since === undefined || since < 0 ? null : since
|
|
33017
|
+
};
|
|
33018
|
+
} catch {
|
|
33019
|
+
return null;
|
|
33020
|
+
}
|
|
33021
|
+
}
|
|
32925
33022
|
async handleEditorStatus() {
|
|
32926
33023
|
const located = await this.locator.find();
|
|
32927
33024
|
const announced = runtimesAnnounced();
|
|
@@ -32952,11 +33049,16 @@ class GodotServer {
|
|
|
32952
33049
|
hold: current === null ? {} : await this.holdOf(current, active)
|
|
32953
33050
|
};
|
|
32954
33051
|
})();
|
|
32955
|
-
const [games, { playing, active, hold }] = await Promise.all([
|
|
33052
|
+
const [games, { playing, active, hold }, scan] = await Promise.all([
|
|
33053
|
+
pinged,
|
|
33054
|
+
asked,
|
|
33055
|
+
this.editorScanState()
|
|
33056
|
+
]);
|
|
32956
33057
|
return this.jsonTextResponse({
|
|
32957
33058
|
editor: {
|
|
32958
33059
|
...this.getEditorStatusPayload(),
|
|
32959
|
-
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
|
|
33060
|
+
debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort,
|
|
33061
|
+
scan
|
|
32960
33062
|
},
|
|
32961
33063
|
godot: {
|
|
32962
33064
|
path: located.ok ? located.path : null,
|
|
@@ -33177,13 +33279,14 @@ class GodotServer {
|
|
|
33177
33279
|
if (!engine.ok) {
|
|
33178
33280
|
return engine.response;
|
|
33179
33281
|
}
|
|
33282
|
+
const scanned = await this.untilTheEditorsScanIsWritten(project.value.path);
|
|
33180
33283
|
const refreshed = await this.refreshStaleClasses(project.value.path);
|
|
33181
33284
|
if (!refreshed.ok) {
|
|
33182
33285
|
return refreshed.response;
|
|
33183
33286
|
}
|
|
33184
33287
|
const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
|
|
33185
33288
|
if (op === "check") {
|
|
33186
|
-
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, asked.value);
|
|
33289
|
+
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, scanned, asked.value);
|
|
33187
33290
|
}
|
|
33188
33291
|
await this.pickUpWhatTheEditorIsPlaying();
|
|
33189
33292
|
let ended = null;
|
|
@@ -33202,7 +33305,7 @@ class GodotServer {
|
|
|
33202
33305
|
const runtimeWaitMs = this.announceBudget(args, project.value.path);
|
|
33203
33306
|
const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
|
|
33204
33307
|
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value)) {
|
|
33205
|
-
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
33308
|
+
return await this.playThroughEditor(sceneArgument, refreshed.value, scanned, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
33206
33309
|
}
|
|
33207
33310
|
const cmdArgs = runArguments({
|
|
33208
33311
|
projectPath: project.value.path,
|
|
@@ -33227,6 +33330,7 @@ class GodotServer {
|
|
|
33227
33330
|
arguments: cmdArgs,
|
|
33228
33331
|
transcript: started.transcript,
|
|
33229
33332
|
refreshedClasses: refreshed.value,
|
|
33333
|
+
...scanWaitAnswer(scanned),
|
|
33230
33334
|
savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
33231
33335
|
endedPreviousRun: endedPreviousRun(ended),
|
|
33232
33336
|
runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
|
|
@@ -33308,7 +33412,7 @@ class GodotServer {
|
|
|
33308
33412
|
writeBootNote(run.projectPath, took);
|
|
33309
33413
|
}
|
|
33310
33414
|
}
|
|
33311
|
-
async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
|
|
33415
|
+
async playThroughEditor(scene, refreshedClasses, scanned, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
|
|
33312
33416
|
const log = new GameLog;
|
|
33313
33417
|
try {
|
|
33314
33418
|
await this.dap().connect();
|
|
@@ -33359,6 +33463,7 @@ class GodotServer {
|
|
|
33359
33463
|
through: "editor",
|
|
33360
33464
|
scene: scene === null ? "the main scene" : `res://${scene}`,
|
|
33361
33465
|
refreshedClasses,
|
|
33466
|
+
...scanWaitAnswer(scanned),
|
|
33362
33467
|
debugPort: readNumber(playAnswer, "debugPort"),
|
|
33363
33468
|
endedPreviousRun: endedPreviousRun(ended),
|
|
33364
33469
|
breakpoints: breakpoints.applied.length === 0 ? undefined : this.breakpointsAsProjectSpells(projectPath, breakpoints.applied),
|
|
@@ -33862,7 +33967,7 @@ class GodotServer {
|
|
|
33862
33967
|
currentRun() {
|
|
33863
33968
|
return this.activeProcess ?? this.adoptRecordedRun();
|
|
33864
33969
|
}
|
|
33865
|
-
async checkBoot(godotPath, projectPath, scene, args, userArgs, asked = {}) {
|
|
33970
|
+
async checkBoot(godotPath, projectPath, scene, args, userArgs, scanned, asked = {}) {
|
|
33866
33971
|
const frames = readPositiveNumber(args, "frames") ?? 3;
|
|
33867
33972
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
|
|
33868
33973
|
const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
|
|
@@ -33892,6 +33997,7 @@ class GodotServer {
|
|
|
33892
33997
|
frames,
|
|
33893
33998
|
errors,
|
|
33894
33999
|
warnings,
|
|
34000
|
+
...scanWaitAnswer(scanned),
|
|
33895
34001
|
savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
33896
34002
|
entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
|
|
33897
34003
|
});
|
|
@@ -34275,6 +34381,10 @@ class GodotServer {
|
|
|
34275
34381
|
return now === null ? [] : lost.filter((name) => now.has(name));
|
|
34276
34382
|
}
|
|
34277
34383
|
async rebuildClassCache(projectPath) {
|
|
34384
|
+
const scanned = await this.untilTheEditorsScanIsWritten(projectPath);
|
|
34385
|
+
return withScanWait(await this.rebuildClassCacheFromDisk(projectPath), scanned);
|
|
34386
|
+
}
|
|
34387
|
+
async rebuildClassCacheFromDisk(projectPath) {
|
|
34278
34388
|
const before = cachedClasses(projectPath);
|
|
34279
34389
|
const writtenBefore = cacheWrittenAt(projectPath);
|
|
34280
34390
|
const noted = readClassNote(projectPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.29",
|
|
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",
|