gdharness 1.0.14 → 1.0.15
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 +67 -12
- package/build/index.js +64 -9
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -16398,7 +16398,11 @@ var init_tool_definitions = __esm(() => {
|
|
|
16398
16398
|
description: "viewport: the Viewport node. Default the root viewport."
|
|
16399
16399
|
},
|
|
16400
16400
|
width: { type: "number", description: "Scale the image to this width." },
|
|
16401
|
-
height: { type: "number", description: "Scale the image to this height." }
|
|
16401
|
+
height: { type: "number", description: "Scale the image to this height." },
|
|
16402
|
+
outputPath: {
|
|
16403
|
+
type: "string",
|
|
16404
|
+
description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
|
|
16405
|
+
}
|
|
16402
16406
|
},
|
|
16403
16407
|
requires: [],
|
|
16404
16408
|
operations: {
|
|
@@ -16421,7 +16425,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16421
16425
|
action: {
|
|
16422
16426
|
type: "string",
|
|
16423
16427
|
ops: ["action"],
|
|
16424
|
-
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
|
|
16428
|
+
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with the key op and keycode Escape, or click its button."
|
|
16425
16429
|
},
|
|
16426
16430
|
pressed: {
|
|
16427
16431
|
type: "boolean",
|
|
@@ -16524,7 +16528,11 @@ var init_tool_definitions = __esm(() => {
|
|
|
16524
16528
|
ops: ["frames"],
|
|
16525
16529
|
description: "frames: how many to let pass, 1 to 600. More than that is refused."
|
|
16526
16530
|
},
|
|
16527
|
-
nodePath: {
|
|
16531
|
+
nodePath: {
|
|
16532
|
+
type: "string",
|
|
16533
|
+
ops: ["signal", "until"],
|
|
16534
|
+
description: "signal, until: the node. Needed for a signal and for a property; for says it defaults to /root, the whole screen."
|
|
16535
|
+
},
|
|
16528
16536
|
signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
|
|
16529
16537
|
property: {
|
|
16530
16538
|
type: "string",
|
|
@@ -16535,7 +16543,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16535
16543
|
says: {
|
|
16536
16544
|
type: "string",
|
|
16537
16545
|
ops: ["until"],
|
|
16538
|
-
description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
16546
|
+
description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
16539
16547
|
},
|
|
16540
16548
|
includeHidden: {
|
|
16541
16549
|
type: "boolean",
|
|
@@ -16557,7 +16565,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
16557
16565
|
},
|
|
16558
16566
|
until: {
|
|
16559
16567
|
summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
|
|
16560
|
-
requires: [
|
|
16568
|
+
requires: []
|
|
16561
16569
|
}
|
|
16562
16570
|
}
|
|
16563
16571
|
},
|
|
@@ -38025,6 +38033,8 @@ import { randomUUID as randomUUID2 } from "node:crypto";
|
|
|
38025
38033
|
import {
|
|
38026
38034
|
appendFileSync,
|
|
38027
38035
|
closeSync as closeSync2,
|
|
38036
|
+
constants,
|
|
38037
|
+
copyFileSync,
|
|
38028
38038
|
existsSync as existsSync15,
|
|
38029
38039
|
fstatSync,
|
|
38030
38040
|
mkdirSync as mkdirSync13,
|
|
@@ -38037,10 +38047,34 @@ import {
|
|
|
38037
38047
|
statSync as statSync8
|
|
38038
38048
|
} from "node:fs";
|
|
38039
38049
|
import { tmpdir as tmpdir5 } from "node:os";
|
|
38040
|
-
import { basename as basename4, dirname as dirname15, isAbsolute as isAbsolute3, join as join22, normalize as normalize3, relative as relative3 } from "node:path";
|
|
38050
|
+
import { basename as basename4, dirname as dirname15, extname as extname2, isAbsolute as isAbsolute3, join as join22, normalize as normalize3, relative as relative3 } from "node:path";
|
|
38041
38051
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
38042
38052
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
38043
38053
|
import { promisify as promisify7 } from "node:util";
|
|
38054
|
+
function captureDestinationRefusal(outputPath, projects) {
|
|
38055
|
+
if (/^(res|user):\/\//.test(outputPath)) {
|
|
38056
|
+
return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
|
|
38057
|
+
}
|
|
38058
|
+
if (!isAbsolute3(outputPath)) {
|
|
38059
|
+
return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
|
|
38060
|
+
}
|
|
38061
|
+
if (extname2(outputPath).toLowerCase() !== ".png") {
|
|
38062
|
+
return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
|
|
38063
|
+
}
|
|
38064
|
+
const directory = realPathOr(dirname15(outputPath));
|
|
38065
|
+
if (!existsSync15(directory)) {
|
|
38066
|
+
return `outputPath's directory ${dirname15(outputPath)} does not exist; a capture creates no directories.`;
|
|
38067
|
+
}
|
|
38068
|
+
const where = join22(directory, basename4(outputPath));
|
|
38069
|
+
const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
|
|
38070
|
+
if (inside !== undefined) {
|
|
38071
|
+
return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
|
|
38072
|
+
}
|
|
38073
|
+
if (existsSync15(where)) {
|
|
38074
|
+
return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
|
|
38075
|
+
}
|
|
38076
|
+
return null;
|
|
38077
|
+
}
|
|
38044
38078
|
function patienceForFrames(frames, atLeast) {
|
|
38045
38079
|
return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
|
|
38046
38080
|
}
|
|
@@ -41333,7 +41367,7 @@ class GodotServer {
|
|
|
41333
41367
|
return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
|
|
41334
41368
|
}
|
|
41335
41369
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
41336
|
-
const { op: _op, projectPath, pid, ...params } = asParams(args);
|
|
41370
|
+
const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
|
|
41337
41371
|
const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
|
|
41338
41372
|
if (waited !== null) {
|
|
41339
41373
|
return this.createErrorResponse(waited, [
|
|
@@ -41360,6 +41394,16 @@ class GodotServer {
|
|
|
41360
41394
|
]);
|
|
41361
41395
|
}
|
|
41362
41396
|
const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
|
|
41397
|
+
const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
|
|
41398
|
+
if (saveTo !== null) {
|
|
41399
|
+
const refusal = captureDestinationRefusal(saveTo, [
|
|
41400
|
+
choice.endpoint.project.path,
|
|
41401
|
+
...this.ownProject === null ? [] : [this.ownProject]
|
|
41402
|
+
]);
|
|
41403
|
+
if (refusal !== null) {
|
|
41404
|
+
return this.createErrorResponse(refusal);
|
|
41405
|
+
}
|
|
41406
|
+
}
|
|
41363
41407
|
const screenshotDir = expectsScreenshot ? mkdtempSync3(join22(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
|
|
41364
41408
|
const screenshotPath = screenshotDir ? join22(screenshotDir, "capture.png") : null;
|
|
41365
41409
|
try {
|
|
@@ -41379,11 +41423,18 @@ class GodotServer {
|
|
|
41379
41423
|
return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
|
|
41380
41424
|
}
|
|
41381
41425
|
const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
|
|
41426
|
+
if (saveTo !== null) {
|
|
41427
|
+
try {
|
|
41428
|
+
copyFileSync(screenshotPath, saveTo, constants.COPYFILE_EXCL);
|
|
41429
|
+
} catch (error) {
|
|
41430
|
+
return this.createErrorResponse(`The capture was taken and could not be saved at ${saveTo}: ${errorMessage(error)}`);
|
|
41431
|
+
}
|
|
41432
|
+
}
|
|
41382
41433
|
return {
|
|
41383
41434
|
content: [
|
|
41384
41435
|
{
|
|
41385
41436
|
type: "text",
|
|
41386
|
-
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
|
|
41437
|
+
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
|
|
41387
41438
|
},
|
|
41388
41439
|
{ type: "image", data: readFileSync20(screenshotPath).toString("base64"), mimeType: "image/png" }
|
|
41389
41440
|
]
|
|
@@ -41430,7 +41481,11 @@ class GodotServer {
|
|
|
41430
41481
|
return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
|
|
41431
41482
|
}
|
|
41432
41483
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
|
|
41433
|
-
const
|
|
41484
|
+
const asked = readNonEmptyString(args, "nodePath");
|
|
41485
|
+
if (asked === undefined && op === "until" && readNonEmptyString(args, "says") === undefined) {
|
|
41486
|
+
return this.createErrorResponse("runtime_wait until needs nodePath to wait for a property: the property belongs to that node.", ["With says instead of property, nodePath can be left out and the whole game is searched"]);
|
|
41487
|
+
}
|
|
41488
|
+
const nodePath = asked ?? "/root";
|
|
41434
41489
|
const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
|
|
41435
41490
|
return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
|
|
41436
41491
|
...whichGame(args),
|
|
@@ -43098,12 +43153,12 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
43098
43153
|
| What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
|
|
43099
43154
|
| Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments and walks into what it returned |
|
|
43100
43155
|
| Press a button | \`runtime_input click\`, which says what was under the pointer |
|
|
43101
|
-
| Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows, then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
|
|
43156
|
+
| Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows (no \`nodePath\` needed: without one the whole game is searched), then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
|
|
43102
43157
|
| Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
|
|
43103
43158
|
| Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
|
|
43104
43159
|
| Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says, rather than a click and keys: the menu opens as a window of its own that an injected key never reaches, so Enter closes it with nothing chosen |
|
|
43105
|
-
| Answer a dialog | \`runtime_input click\` on its button, or \`key\` Escape to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
|
|
43106
|
-
| Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
|
|
43160
|
+
| Answer a dialog | \`runtime_input click\` on its button, or \`runtime_input key\` with \`keycode\` \`Escape\` to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
|
|
43161
|
+
| Press a bound key | \`runtime_input action\` with the action's name, or \`runtime_input key\` with \`keycode\`, a whole press unless you hold it |
|
|
43107
43162
|
| Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
|
|
43108
43163
|
| Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels. The words have to be shown: a button that exists hidden through an animation does not satisfy it until it appears, unless \`includeHidden\` says otherwise |
|
|
43109
43164
|
| What every label on a screen says | \`runtime_inspect tree\` with \`properties\` naming \`text\`, which reads it off every node that has one; \`includeProperties\` is every stored property of every node and ran to seventy-six thousand characters for one row |
|
package/build/index.js
CHANGED
|
@@ -10182,6 +10182,8 @@ import { randomUUID as randomUUID2 } from "node:crypto";
|
|
|
10182
10182
|
import {
|
|
10183
10183
|
appendFileSync,
|
|
10184
10184
|
closeSync as closeSync2,
|
|
10185
|
+
constants,
|
|
10186
|
+
copyFileSync,
|
|
10185
10187
|
existsSync as existsSync10,
|
|
10186
10188
|
fstatSync,
|
|
10187
10189
|
mkdirSync as mkdirSync10,
|
|
@@ -10194,7 +10196,7 @@ import {
|
|
|
10194
10196
|
statSync as statSync7
|
|
10195
10197
|
} from "node:fs";
|
|
10196
10198
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10197
|
-
import { basename as basename3, dirname as dirname10, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10199
|
+
import { basename as basename3, dirname as dirname10, extname as extname2, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10198
10200
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
10199
10201
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
10200
10202
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -30472,7 +30474,11 @@ var TOOL_SPECS = [
|
|
|
30472
30474
|
description: "viewport: the Viewport node. Default the root viewport."
|
|
30473
30475
|
},
|
|
30474
30476
|
width: { type: "number", description: "Scale the image to this width." },
|
|
30475
|
-
height: { type: "number", description: "Scale the image to this height." }
|
|
30477
|
+
height: { type: "number", description: "Scale the image to this height." },
|
|
30478
|
+
outputPath: {
|
|
30479
|
+
type: "string",
|
|
30480
|
+
description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
|
|
30481
|
+
}
|
|
30476
30482
|
},
|
|
30477
30483
|
requires: [],
|
|
30478
30484
|
operations: {
|
|
@@ -30495,7 +30501,7 @@ var TOOL_SPECS = [
|
|
|
30495
30501
|
action: {
|
|
30496
30502
|
type: "string",
|
|
30497
30503
|
ops: ["action"],
|
|
30498
|
-
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
|
|
30504
|
+
description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with the key op and keycode Escape, or click its button."
|
|
30499
30505
|
},
|
|
30500
30506
|
pressed: {
|
|
30501
30507
|
type: "boolean",
|
|
@@ -30598,7 +30604,11 @@ var TOOL_SPECS = [
|
|
|
30598
30604
|
ops: ["frames"],
|
|
30599
30605
|
description: "frames: how many to let pass, 1 to 600. More than that is refused."
|
|
30600
30606
|
},
|
|
30601
|
-
nodePath: {
|
|
30607
|
+
nodePath: {
|
|
30608
|
+
type: "string",
|
|
30609
|
+
ops: ["signal", "until"],
|
|
30610
|
+
description: "signal, until: the node. Needed for a signal and for a property; for says it defaults to /root, the whole screen."
|
|
30611
|
+
},
|
|
30602
30612
|
signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
|
|
30603
30613
|
property: {
|
|
30604
30614
|
type: "string",
|
|
@@ -30609,7 +30619,7 @@ var TOOL_SPECS = [
|
|
|
30609
30619
|
says: {
|
|
30610
30620
|
type: "string",
|
|
30611
30621
|
ops: ["until"],
|
|
30612
|
-
description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
30622
|
+
description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
|
|
30613
30623
|
},
|
|
30614
30624
|
includeHidden: {
|
|
30615
30625
|
type: "boolean",
|
|
@@ -30631,7 +30641,7 @@ var TOOL_SPECS = [
|
|
|
30631
30641
|
},
|
|
30632
30642
|
until: {
|
|
30633
30643
|
summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
|
|
30634
|
-
requires: [
|
|
30644
|
+
requires: []
|
|
30635
30645
|
}
|
|
30636
30646
|
}
|
|
30637
30647
|
},
|
|
@@ -30733,6 +30743,30 @@ var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
|
|
|
30733
30743
|
var EDITOR_RESTART_TIMEOUT_MS = 90000;
|
|
30734
30744
|
var HOLD_PING_MS = 2000;
|
|
30735
30745
|
var SLOWEST_FRAME_RATE = 20;
|
|
30746
|
+
function captureDestinationRefusal(outputPath, projects) {
|
|
30747
|
+
if (/^(res|user):\/\//.test(outputPath)) {
|
|
30748
|
+
return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
|
|
30749
|
+
}
|
|
30750
|
+
if (!isAbsolute3(outputPath)) {
|
|
30751
|
+
return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
|
|
30752
|
+
}
|
|
30753
|
+
if (extname2(outputPath).toLowerCase() !== ".png") {
|
|
30754
|
+
return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
|
|
30755
|
+
}
|
|
30756
|
+
const directory = realPathOr(dirname10(outputPath));
|
|
30757
|
+
if (!existsSync10(directory)) {
|
|
30758
|
+
return `outputPath's directory ${dirname10(outputPath)} does not exist; a capture creates no directories.`;
|
|
30759
|
+
}
|
|
30760
|
+
const where = join16(directory, basename3(outputPath));
|
|
30761
|
+
const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
|
|
30762
|
+
if (inside !== undefined) {
|
|
30763
|
+
return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
|
|
30764
|
+
}
|
|
30765
|
+
if (existsSync10(where)) {
|
|
30766
|
+
return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
|
|
30767
|
+
}
|
|
30768
|
+
return null;
|
|
30769
|
+
}
|
|
30736
30770
|
function patienceForFrames(frames, atLeast) {
|
|
30737
30771
|
return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
|
|
30738
30772
|
}
|
|
@@ -34071,7 +34105,7 @@ class GodotServer {
|
|
|
34071
34105
|
return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
|
|
34072
34106
|
}
|
|
34073
34107
|
async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
|
|
34074
|
-
const { op: _op, projectPath, pid, ...params } = asParams(args);
|
|
34108
|
+
const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
|
|
34075
34109
|
const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
|
|
34076
34110
|
if (waited !== null) {
|
|
34077
34111
|
return this.createErrorResponse(waited, [
|
|
@@ -34098,6 +34132,16 @@ class GodotServer {
|
|
|
34098
34132
|
]);
|
|
34099
34133
|
}
|
|
34100
34134
|
const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
|
|
34135
|
+
const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
|
|
34136
|
+
if (saveTo !== null) {
|
|
34137
|
+
const refusal = captureDestinationRefusal(saveTo, [
|
|
34138
|
+
choice.endpoint.project.path,
|
|
34139
|
+
...this.ownProject === null ? [] : [this.ownProject]
|
|
34140
|
+
]);
|
|
34141
|
+
if (refusal !== null) {
|
|
34142
|
+
return this.createErrorResponse(refusal);
|
|
34143
|
+
}
|
|
34144
|
+
}
|
|
34101
34145
|
const screenshotDir = expectsScreenshot ? mkdtempSync2(join16(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
|
|
34102
34146
|
const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
|
|
34103
34147
|
try {
|
|
@@ -34117,11 +34161,18 @@ class GodotServer {
|
|
|
34117
34161
|
return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
|
|
34118
34162
|
}
|
|
34119
34163
|
const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
|
|
34164
|
+
if (saveTo !== null) {
|
|
34165
|
+
try {
|
|
34166
|
+
copyFileSync(screenshotPath, saveTo, constants.COPYFILE_EXCL);
|
|
34167
|
+
} catch (error) {
|
|
34168
|
+
return this.createErrorResponse(`The capture was taken and could not be saved at ${saveTo}: ${errorMessage(error)}`);
|
|
34169
|
+
}
|
|
34170
|
+
}
|
|
34120
34171
|
return {
|
|
34121
34172
|
content: [
|
|
34122
34173
|
{
|
|
34123
34174
|
type: "text",
|
|
34124
|
-
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
|
|
34175
|
+
text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
|
|
34125
34176
|
},
|
|
34126
34177
|
{ type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
|
|
34127
34178
|
]
|
|
@@ -34168,7 +34219,11 @@ class GodotServer {
|
|
|
34168
34219
|
return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
|
|
34169
34220
|
}
|
|
34170
34221
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
|
|
34171
|
-
const
|
|
34222
|
+
const asked = readNonEmptyString(args, "nodePath");
|
|
34223
|
+
if (asked === undefined && op === "until" && readNonEmptyString(args, "says") === undefined) {
|
|
34224
|
+
return this.createErrorResponse("runtime_wait until needs nodePath to wait for a property: the property belongs to that node.", ["With says instead of property, nodePath can be left out and the whole game is searched"]);
|
|
34225
|
+
}
|
|
34226
|
+
const nodePath = asked ?? "/root";
|
|
34172
34227
|
const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
|
|
34173
34228
|
return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
|
|
34174
34229
|
...whichGame(args),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
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",
|