gdharness 1.0.9 → 1.0.11
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 +88 -16
- package/build/index.js +80 -8
- package/package.json +1 -1
package/build/cli.js
CHANGED
|
@@ -201,7 +201,11 @@ function userDataIn(home, variables = process.env) {
|
|
|
201
201
|
function savesStayPut(platform = process.platform) {
|
|
202
202
|
return platform === "darwin";
|
|
203
203
|
}
|
|
204
|
-
|
|
204
|
+
function environmentFor(options, variables = process.env) {
|
|
205
|
+
const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
|
|
206
|
+
return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
|
|
207
|
+
}
|
|
208
|
+
var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
|
|
205
209
|
|
|
206
210
|
// node_modules/yaml/dist/nodes/identity.js
|
|
207
211
|
var require_identity = __commonJS(function(exports) {
|
|
@@ -15344,6 +15348,9 @@ function projectPathSentence(without = callsWithoutProjectPath()) {
|
|
|
15344
15348
|
const named = without.map((name) => `\`${name}\``).join(", ");
|
|
15345
15349
|
return `Every call takes \`projectPath\` except ${named}, which ${without.length === 1 ? "takes" : "take"} none`;
|
|
15346
15350
|
}
|
|
15351
|
+
function theGamePicked() {
|
|
15352
|
+
return "the game this server started or is playing when it is among those running, named under " + "`answeredBy` where there was a choice; else the only game there is; else a refusal naming " + "every game, which is what a machine running two projects at once gets";
|
|
15353
|
+
}
|
|
15347
15354
|
function toolSpec(name) {
|
|
15348
15355
|
return SPECS_BY_NAME[name];
|
|
15349
15356
|
}
|
|
@@ -15404,7 +15411,7 @@ var init_tool_definitions = __esm(() => {
|
|
|
15404
15411
|
};
|
|
15405
15412
|
RUNNING_PROJECT_PATH = {
|
|
15406
15413
|
type: "string",
|
|
15407
|
-
description:
|
|
15414
|
+
description: `Which game, when more than one is running: the project directory it was started from. The pick without it, in order: ${theGamePicked().replaceAll("`", "")}. Not needed with one game, nor for a call to this server's own.`
|
|
15408
15415
|
};
|
|
15409
15416
|
RUNNING_PID = {
|
|
15410
15417
|
type: "number",
|
|
@@ -16012,6 +16019,16 @@ var init_tool_definitions = __esm(() => {
|
|
|
16012
16019
|
description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
|
|
16013
16020
|
},
|
|
16014
16021
|
headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
|
|
16022
|
+
savesIn: {
|
|
16023
|
+
type: "string",
|
|
16024
|
+
ops: ["start", "check"],
|
|
16025
|
+
description: "An absolute directory for this run's user://, so a game that saves writes nowhere near the copy somebody plays: opening a save writes the one being left, and a game that autosaves writes on its own. Godot takes no flag for it, so this is the environment, and the run is started by this server rather than played by the editor, which cannot be given one. It does not have to exist: the engine makes it, and the Godot/app_userdata/<project> tree under it, measured on 4.7.2. On macOS the engine reads user:// off HOME and ignores it, also measured, and the answer says so under savesNote rather than leaving the caller to find out from the player's save list."
|
|
16026
|
+
},
|
|
16027
|
+
env: {
|
|
16028
|
+
type: "object",
|
|
16029
|
+
ops: ["start", "check"],
|
|
16030
|
+
description: "Environment variables for this run alone, as a map of strings, over the server's own environment. Like savesIn, a run carrying any is started by this server rather than played by the editor. Names beginning with GDHARNESS_ are refused: they are this server's contract with the addon in the game, and the runtime directory is set here whatever the rest of the environment says, so a game that moves TEMP still announces where this server looks."
|
|
16031
|
+
},
|
|
16015
16032
|
runtimeWaitMs: {
|
|
16016
16033
|
type: "number",
|
|
16017
16034
|
ops: ["start"],
|
|
@@ -37650,7 +37667,7 @@ import {
|
|
|
37650
37667
|
statSync as statSync5
|
|
37651
37668
|
} from "node:fs";
|
|
37652
37669
|
import { tmpdir as tmpdir5 } from "node:os";
|
|
37653
|
-
import { basename as basename4, dirname as dirname14, join as join21, normalize as normalize3, relative as relative3 } from "node:path";
|
|
37670
|
+
import { basename as basename4, dirname as dirname14, isAbsolute as isAbsolute3, join as join21, normalize as normalize3, relative as relative3 } from "node:path";
|
|
37654
37671
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
37655
37672
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
37656
37673
|
import { promisify as promisify7 } from "node:util";
|
|
@@ -37932,6 +37949,9 @@ function camelCased(params) {
|
|
|
37932
37949
|
}
|
|
37933
37950
|
return result;
|
|
37934
37951
|
}
|
|
37952
|
+
function needsItsOwnProcess(asked) {
|
|
37953
|
+
return asked.savesIn !== undefined || asked.env !== undefined;
|
|
37954
|
+
}
|
|
37935
37955
|
|
|
37936
37956
|
class GodotServer {
|
|
37937
37957
|
mcp;
|
|
@@ -38627,6 +38647,51 @@ class GodotServer {
|
|
|
38627
38647
|
}
|
|
38628
38648
|
return { ok: true, value: given };
|
|
38629
38649
|
}
|
|
38650
|
+
runEnvironment(args) {
|
|
38651
|
+
const savesIn = readNonEmptyString(args, "savesIn");
|
|
38652
|
+
if (savesIn !== undefined && !isAbsolute3(savesIn)) {
|
|
38653
|
+
return {
|
|
38654
|
+
ok: false,
|
|
38655
|
+
response: this.createErrorResponse(`savesIn must be an absolute path, not "${savesIn}".`, [
|
|
38656
|
+
"Name the directory user:// should be under, such as a temporary directory of this run's own"
|
|
38657
|
+
])
|
|
38658
|
+
};
|
|
38659
|
+
}
|
|
38660
|
+
if (args["env"] === undefined) {
|
|
38661
|
+
return { ok: true, value: savesIn === undefined ? {} : { savesIn } };
|
|
38662
|
+
}
|
|
38663
|
+
const given = readParams(args, "env");
|
|
38664
|
+
if (given === undefined) {
|
|
38665
|
+
return {
|
|
38666
|
+
ok: false,
|
|
38667
|
+
response: this.createErrorResponse("env must be a map of names to string values.", [
|
|
38668
|
+
'Pass the variables this run needs, such as {"MY_FLAG": "1"}'
|
|
38669
|
+
])
|
|
38670
|
+
};
|
|
38671
|
+
}
|
|
38672
|
+
const env = {};
|
|
38673
|
+
for (const [name, value] of Object.entries(given)) {
|
|
38674
|
+
if (typeof value !== "string") {
|
|
38675
|
+
return {
|
|
38676
|
+
ok: false,
|
|
38677
|
+
response: this.createErrorResponse(`env values must be strings, and ${name} is ${typeof value}.`, [
|
|
38678
|
+
'Pass every value as a string, such as {"WORKERS": "4"}'
|
|
38679
|
+
])
|
|
38680
|
+
};
|
|
38681
|
+
}
|
|
38682
|
+
if (name.toUpperCase().startsWith(RESERVED_VARIABLE_PREFIX)) {
|
|
38683
|
+
return {
|
|
38684
|
+
ok: false,
|
|
38685
|
+
response: this.createErrorResponse(`env may not set ${name}: names beginning with ${RESERVED_VARIABLE_PREFIX} are this server's own.`, [
|
|
38686
|
+
"GDHARNESS_RUNTIME_DIR is where the game announces itself and this server looks for it",
|
|
38687
|
+
"Name the directory for user:// with savesIn rather than by moving the environment yourself"
|
|
38688
|
+
])
|
|
38689
|
+
};
|
|
38690
|
+
}
|
|
38691
|
+
env[name] = value;
|
|
38692
|
+
}
|
|
38693
|
+
return { ok: true, value: { ...savesIn === undefined ? {} : { savesIn }, env } };
|
|
38694
|
+
}
|
|
38630
38695
|
async debuggedGame(intent) {
|
|
38631
38696
|
const game = this.activeProcess;
|
|
38632
38697
|
if (game !== null && game.exitCode !== null) {
|
|
@@ -39614,6 +39679,10 @@ class GodotServer {
|
|
|
39614
39679
|
if (!given.ok) {
|
|
39615
39680
|
return given.response;
|
|
39616
39681
|
}
|
|
39682
|
+
const asked = this.runEnvironment(args);
|
|
39683
|
+
if (!asked.ok) {
|
|
39684
|
+
return asked.response;
|
|
39685
|
+
}
|
|
39617
39686
|
if (!sceneToRun && !hasMainScene(project.value.file)) {
|
|
39618
39687
|
return this.createErrorResponse("The project sets no main scene, so there is nothing to run.", [
|
|
39619
39688
|
"Pass scene to run one scene",
|
|
@@ -39630,7 +39699,7 @@ class GodotServer {
|
|
|
39630
39699
|
}
|
|
39631
39700
|
const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
|
|
39632
39701
|
if (op === "check") {
|
|
39633
|
-
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
|
|
39702
|
+
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, asked.value);
|
|
39634
39703
|
}
|
|
39635
39704
|
await this.pickUpWhatTheEditorIsPlaying();
|
|
39636
39705
|
let ended = null;
|
|
@@ -39648,7 +39717,7 @@ class GodotServer {
|
|
|
39648
39717
|
});
|
|
39649
39718
|
const runtimeWaitMs = this.announceBudget(args, project.value.path);
|
|
39650
39719
|
const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
|
|
39651
|
-
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
|
|
39720
|
+
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value)) {
|
|
39652
39721
|
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
39653
39722
|
}
|
|
39654
39723
|
const cmdArgs = runArguments({
|
|
@@ -39658,10 +39727,10 @@ class GodotServer {
|
|
|
39658
39727
|
userArgs: given.value
|
|
39659
39728
|
});
|
|
39660
39729
|
this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
|
|
39661
|
-
const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
|
|
39730
|
+
const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
|
|
39662
39731
|
started.announcedBefore = alreadyPlaying;
|
|
39663
39732
|
this.activeProcess = started;
|
|
39664
|
-
const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
|
|
39733
|
+
const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
|
|
39665
39734
|
const endedForThis = endedToStartThis(ended);
|
|
39666
39735
|
const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
|
|
39667
39736
|
const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
|
|
@@ -39674,6 +39743,7 @@ class GodotServer {
|
|
|
39674
39743
|
arguments: cmdArgs,
|
|
39675
39744
|
transcript: started.transcript,
|
|
39676
39745
|
refreshedClasses: refreshed.value,
|
|
39746
|
+
savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
39677
39747
|
endedPreviousRun: endedPreviousRun(ended),
|
|
39678
39748
|
runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
|
|
39679
39749
|
message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
|
|
@@ -40280,11 +40350,11 @@ class GodotServer {
|
|
|
40280
40350
|
currentRun() {
|
|
40281
40351
|
return this.activeProcess ?? this.adoptRecordedRun();
|
|
40282
40352
|
}
|
|
40283
|
-
async checkBoot(godotPath, projectPath, scene, args, userArgs) {
|
|
40353
|
+
async checkBoot(godotPath, projectPath, scene, args, userArgs, asked = {}) {
|
|
40284
40354
|
const frames = readPositiveNumber(args, "frames") ?? 3;
|
|
40285
40355
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
|
|
40286
40356
|
const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
|
|
40287
|
-
const boot = this.spawnGame(godotPath, cmdArgs);
|
|
40357
|
+
const boot = this.spawnGame(godotPath, cmdArgs, needsItsOwnProcess(asked) ? environmentFor({ ...asked, runtimeDirectory: runtimeDirectory() }) : undefined);
|
|
40288
40358
|
const hung = await new Promise((resolve) => {
|
|
40289
40359
|
const timer = setTimeout(() => {
|
|
40290
40360
|
boot.process.kill();
|
|
@@ -40309,6 +40379,7 @@ class GodotServer {
|
|
|
40309
40379
|
frames,
|
|
40310
40380
|
errors,
|
|
40311
40381
|
warnings,
|
|
40382
|
+
savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
40312
40383
|
entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
|
|
40313
40384
|
});
|
|
40314
40385
|
}
|
|
@@ -41643,7 +41714,7 @@ function displayPath(harness, platform = process3.platform) {
|
|
|
41643
41714
|
return `${harness.scope === "home" ? "~/" : ""}${file.replaceAll("\\", "/")}`;
|
|
41644
41715
|
}
|
|
41645
41716
|
var OURS = ["GODOT_PATH", "GDHARNESS_PROJECT"];
|
|
41646
|
-
function
|
|
41717
|
+
function environmentFor2(launch, held) {
|
|
41647
41718
|
const kept = {};
|
|
41648
41719
|
if (typeof held === "object" && held !== null && !Array.isArray(held)) {
|
|
41649
41720
|
for (const [name, value] of Object.entries(held)) {
|
|
@@ -41661,7 +41732,7 @@ function entryFor(shape, launch, held) {
|
|
|
41661
41732
|
...before,
|
|
41662
41733
|
type: "local",
|
|
41663
41734
|
command: [launch.command, ...launch.args],
|
|
41664
|
-
environment:
|
|
41735
|
+
environment: environmentFor2(launch, before["environment"]),
|
|
41665
41736
|
enabled: true
|
|
41666
41737
|
};
|
|
41667
41738
|
}
|
|
@@ -41670,7 +41741,7 @@ function entryFor(shape, launch, held) {
|
|
|
41670
41741
|
...shape === "typed" ? { type: "stdio" } : {},
|
|
41671
41742
|
command: launch.command,
|
|
41672
41743
|
args: [...launch.args],
|
|
41673
|
-
env:
|
|
41744
|
+
env: environmentFor2(launch, before["env"])
|
|
41674
41745
|
};
|
|
41675
41746
|
}
|
|
41676
41747
|
function merged(existing, harness, launch) {
|
|
@@ -42375,7 +42446,8 @@ function renderToolsMarkdown() {
|
|
|
42375
42446
|
"`op`. An unknown op or argument is refused with the valid set listed. Generated from the server.",
|
|
42376
42447
|
"",
|
|
42377
42448
|
`${projectPathSentence()}: the \`runtime_*\` and \`debug_*\` ones pick between running games`,
|
|
42378
|
-
"instead, and the others answer about the editor on this server.
|
|
42449
|
+
"instead, and the others answer about the editor on this server. The pick, when a call names",
|
|
42450
|
+
`neither \`projectPath\` nor \`pid\`: ${theGamePicked()}. An argument a tool does not`,
|
|
42379
42451
|
"declare is refused rather than ignored. Answers are read from the engine after the change, not",
|
|
42380
42452
|
"echoed from the request. Engine stderr comes back under `engine_messages`.",
|
|
42381
42453
|
"",
|
|
@@ -42435,9 +42507,8 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
42435
42507
|
|
|
42436
42508
|
- ${projectPathSentence()}. The \`runtime_*\` and \`debug_*\` ones pick between running
|
|
42437
42509
|
games instead, by \`projectPath\`, or by \`pid\` when several run from one project, as a bench
|
|
42438
|
-
and its workers do
|
|
42439
|
-
|
|
42440
|
-
connected to. There is no ambient project, and an argument a tool does not declare is refused
|
|
42510
|
+
and its workers do. Naming neither picks ${theGamePicked()}; the others answer about the editor
|
|
42511
|
+
this server is connected to. There is no ambient project, and an argument a tool does not declare is refused
|
|
42441
42512
|
rather than ignored.
|
|
42442
42513
|
- \`editor_status\` says whether an editor is connected and whether its addon matches the server.
|
|
42443
42514
|
\`addonIsStale\` means they differ, and \`staleNote\` says which half is behind: an editor that
|
|
@@ -42489,6 +42560,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
|
|
|
42489
42560
|
| Whether this engine has a method or property, and what it takes | \`editor_classes info\` with \`member\`, which answers that one member and which class declares it rather than the whole class |
|
|
42490
42561
|
| Wait for a run to finish | \`editor_run wait\`, never a sleep or a shell loop on the pid: it answers as \`editor_output\` does, once the run is over |
|
|
42491
42562
|
| End a bench and the workers it opened | \`editor_run stop\` with \`andChildren\`, never a process listing by scene name: it ends every process under the run's, to any depth, that is a game of the project, announced as one or the project's engine run with \`--path\` on it, names them under \`endedChildren\` with what identified each, and leaves and names any other |
|
|
42563
|
+
| Play a game without touching the player's saves | \`editor_run start\` with \`savesIn\`, a directory for this run's \`user://\`: the editor plays with its own environment, so a run given one is started by the server, and the answer says under \`savesNote\` when the platform ignores it |
|
|
42492
42564
|
| A picture, for a person who asked to see one | \`runtime_capture\` |
|
|
42493
42565
|
|
|
42494
42566
|
These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
|
package/build/index.js
CHANGED
|
@@ -10185,7 +10185,7 @@ import {
|
|
|
10185
10185
|
statSync as statSync5
|
|
10186
10186
|
} from "node:fs";
|
|
10187
10187
|
import { tmpdir as tmpdir4 } from "node:os";
|
|
10188
|
-
import { basename as basename3, dirname as dirname9, join as join15, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10188
|
+
import { basename as basename3, dirname as dirname9, isAbsolute as isAbsolute3, join as join15, normalize as normalize2, relative as relative3 } from "node:path";
|
|
10189
10189
|
import { setTimeout as delay3 } from "node:timers/promises";
|
|
10190
10190
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
10191
10191
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -24811,6 +24811,11 @@ function userDataIn(home, variables = process.env) {
|
|
|
24811
24811
|
function savesStayPut(platform = process.platform) {
|
|
24812
24812
|
return platform === "darwin";
|
|
24813
24813
|
}
|
|
24814
|
+
var RESERVED_VARIABLE_PREFIX = "GDHARNESS_";
|
|
24815
|
+
function environmentFor(options, variables = process.env) {
|
|
24816
|
+
const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
|
|
24817
|
+
return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
|
|
24818
|
+
}
|
|
24814
24819
|
var SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
|
|
24815
24820
|
|
|
24816
24821
|
// src/process-children.ts
|
|
@@ -29205,13 +29210,16 @@ function opTakes(spec, op, name) {
|
|
|
29205
29210
|
function argumentsOf(spec, op) {
|
|
29206
29211
|
return Object.keys(spec.parameters).filter((name) => opTakes(spec, op, name));
|
|
29207
29212
|
}
|
|
29213
|
+
function theGamePicked() {
|
|
29214
|
+
return "the game this server started or is playing when it is among those running, named under " + "`answeredBy` where there was a choice; else the only game there is; else a refusal naming " + "every game, which is what a machine running two projects at once gets";
|
|
29215
|
+
}
|
|
29208
29216
|
var PROJECT_PATH = {
|
|
29209
29217
|
type: "string",
|
|
29210
29218
|
description: "Absolute path to the project directory, the one holding project.godot."
|
|
29211
29219
|
};
|
|
29212
29220
|
var RUNNING_PROJECT_PATH = {
|
|
29213
29221
|
type: "string",
|
|
29214
|
-
description:
|
|
29222
|
+
description: `Which game, when more than one is running: the project directory it was started from. The pick without it, in order: ${theGamePicked().replaceAll("`", "")}. Not needed with one game, nor for a call to this server's own.`
|
|
29215
29223
|
};
|
|
29216
29224
|
var RUNNING_PID = {
|
|
29217
29225
|
type: "number",
|
|
@@ -29819,6 +29827,16 @@ var TOOL_SPECS = [
|
|
|
29819
29827
|
description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
|
|
29820
29828
|
},
|
|
29821
29829
|
headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
|
|
29830
|
+
savesIn: {
|
|
29831
|
+
type: "string",
|
|
29832
|
+
ops: ["start", "check"],
|
|
29833
|
+
description: "An absolute directory for this run's user://, so a game that saves writes nowhere near the copy somebody plays: opening a save writes the one being left, and a game that autosaves writes on its own. Godot takes no flag for it, so this is the environment, and the run is started by this server rather than played by the editor, which cannot be given one. It does not have to exist: the engine makes it, and the Godot/app_userdata/<project> tree under it, measured on 4.7.2. On macOS the engine reads user:// off HOME and ignores it, also measured, and the answer says so under savesNote rather than leaving the caller to find out from the player's save list."
|
|
29834
|
+
},
|
|
29835
|
+
env: {
|
|
29836
|
+
type: "object",
|
|
29837
|
+
ops: ["start", "check"],
|
|
29838
|
+
description: "Environment variables for this run alone, as a map of strings, over the server's own environment. Like savesIn, a run carrying any is started by this server rather than played by the editor. Names beginning with GDHARNESS_ are refused: they are this server's contract with the addon in the game, and the runtime directory is set here whatever the rest of the environment says, so a game that moves TEMP still announces where this server looks."
|
|
29839
|
+
},
|
|
29822
29840
|
runtimeWaitMs: {
|
|
29823
29841
|
type: "number",
|
|
29824
29842
|
ops: ["start"],
|
|
@@ -30669,6 +30687,9 @@ function camelCased(params) {
|
|
|
30669
30687
|
}
|
|
30670
30688
|
return result;
|
|
30671
30689
|
}
|
|
30690
|
+
function needsItsOwnProcess(asked) {
|
|
30691
|
+
return asked.savesIn !== undefined || asked.env !== undefined;
|
|
30692
|
+
}
|
|
30672
30693
|
|
|
30673
30694
|
class GodotServer {
|
|
30674
30695
|
mcp;
|
|
@@ -31364,6 +31385,51 @@ class GodotServer {
|
|
|
31364
31385
|
}
|
|
31365
31386
|
return { ok: true, value: given };
|
|
31366
31387
|
}
|
|
31388
|
+
runEnvironment(args) {
|
|
31389
|
+
const savesIn = readNonEmptyString(args, "savesIn");
|
|
31390
|
+
if (savesIn !== undefined && !isAbsolute3(savesIn)) {
|
|
31391
|
+
return {
|
|
31392
|
+
ok: false,
|
|
31393
|
+
response: this.createErrorResponse(`savesIn must be an absolute path, not "${savesIn}".`, [
|
|
31394
|
+
"Name the directory user:// should be under, such as a temporary directory of this run's own"
|
|
31395
|
+
])
|
|
31396
|
+
};
|
|
31397
|
+
}
|
|
31398
|
+
if (args["env"] === undefined) {
|
|
31399
|
+
return { ok: true, value: savesIn === undefined ? {} : { savesIn } };
|
|
31400
|
+
}
|
|
31401
|
+
const given = readParams(args, "env");
|
|
31402
|
+
if (given === undefined) {
|
|
31403
|
+
return {
|
|
31404
|
+
ok: false,
|
|
31405
|
+
response: this.createErrorResponse("env must be a map of names to string values.", [
|
|
31406
|
+
'Pass the variables this run needs, such as {"MY_FLAG": "1"}'
|
|
31407
|
+
])
|
|
31408
|
+
};
|
|
31409
|
+
}
|
|
31410
|
+
const env = {};
|
|
31411
|
+
for (const [name, value] of Object.entries(given)) {
|
|
31412
|
+
if (typeof value !== "string") {
|
|
31413
|
+
return {
|
|
31414
|
+
ok: false,
|
|
31415
|
+
response: this.createErrorResponse(`env values must be strings, and ${name} is ${typeof value}.`, [
|
|
31416
|
+
'Pass every value as a string, such as {"WORKERS": "4"}'
|
|
31417
|
+
])
|
|
31418
|
+
};
|
|
31419
|
+
}
|
|
31420
|
+
if (name.toUpperCase().startsWith(RESERVED_VARIABLE_PREFIX)) {
|
|
31421
|
+
return {
|
|
31422
|
+
ok: false,
|
|
31423
|
+
response: this.createErrorResponse(`env may not set ${name}: names beginning with ${RESERVED_VARIABLE_PREFIX} are this server's own.`, [
|
|
31424
|
+
"GDHARNESS_RUNTIME_DIR is where the game announces itself and this server looks for it",
|
|
31425
|
+
"Name the directory for user:// with savesIn rather than by moving the environment yourself"
|
|
31426
|
+
])
|
|
31427
|
+
};
|
|
31428
|
+
}
|
|
31429
|
+
env[name] = value;
|
|
31430
|
+
}
|
|
31431
|
+
return { ok: true, value: { ...savesIn === undefined ? {} : { savesIn }, env } };
|
|
31432
|
+
}
|
|
31367
31433
|
async debuggedGame(intent) {
|
|
31368
31434
|
const game = this.activeProcess;
|
|
31369
31435
|
if (game !== null && game.exitCode !== null) {
|
|
@@ -32351,6 +32417,10 @@ class GodotServer {
|
|
|
32351
32417
|
if (!given.ok) {
|
|
32352
32418
|
return given.response;
|
|
32353
32419
|
}
|
|
32420
|
+
const asked = this.runEnvironment(args);
|
|
32421
|
+
if (!asked.ok) {
|
|
32422
|
+
return asked.response;
|
|
32423
|
+
}
|
|
32354
32424
|
if (!sceneToRun && !hasMainScene(project.value.file)) {
|
|
32355
32425
|
return this.createErrorResponse("The project sets no main scene, so there is nothing to run.", [
|
|
32356
32426
|
"Pass scene to run one scene",
|
|
@@ -32367,7 +32437,7 @@ class GodotServer {
|
|
|
32367
32437
|
}
|
|
32368
32438
|
const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
|
|
32369
32439
|
if (op === "check") {
|
|
32370
|
-
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
|
|
32440
|
+
return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value, asked.value);
|
|
32371
32441
|
}
|
|
32372
32442
|
await this.pickUpWhatTheEditorIsPlaying();
|
|
32373
32443
|
let ended = null;
|
|
@@ -32385,7 +32455,7 @@ class GodotServer {
|
|
|
32385
32455
|
});
|
|
32386
32456
|
const runtimeWaitMs = this.announceBudget(args, project.value.path);
|
|
32387
32457
|
const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
|
|
32388
|
-
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
|
|
32458
|
+
if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0 && !needsItsOwnProcess(asked.value)) {
|
|
32389
32459
|
return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
|
|
32390
32460
|
}
|
|
32391
32461
|
const cmdArgs = runArguments({
|
|
@@ -32395,10 +32465,10 @@ class GodotServer {
|
|
|
32395
32465
|
userArgs: given.value
|
|
32396
32466
|
});
|
|
32397
32467
|
this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
|
|
32398
|
-
const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
|
|
32468
|
+
const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path, needsItsOwnProcess(asked.value) ? environmentFor({ ...asked.value, runtimeDirectory: runtimeDirectory() }) : undefined);
|
|
32399
32469
|
started.announcedBefore = alreadyPlaying;
|
|
32400
32470
|
this.activeProcess = started;
|
|
32401
|
-
const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
|
|
32471
|
+
const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : this.godotBridge.isConnected() && editorWouldPlay && needsItsOwnProcess(asked.value) ? " The editor plays a game with its own environment, so a run given one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
|
|
32402
32472
|
const endedForThis = endedToStartThis(ended);
|
|
32403
32473
|
const stranded = wereHereFirst.filter((one) => one.pid !== ended?.pid && alive(one.pid));
|
|
32404
32474
|
const named = stranded.map((one) => one.protocol === null ? `pid ${one.pid}` : `pid ${one.pid} on protocol ${one.protocol}`).join(", ");
|
|
@@ -32411,6 +32481,7 @@ class GodotServer {
|
|
|
32411
32481
|
arguments: cmdArgs,
|
|
32412
32482
|
transcript: started.transcript,
|
|
32413
32483
|
refreshedClasses: refreshed.value,
|
|
32484
|
+
savesNote: asked.value.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
32414
32485
|
endedPreviousRun: endedPreviousRun(ended),
|
|
32415
32486
|
runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
|
|
32416
32487
|
message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}${alsoRunning}`
|
|
@@ -33017,11 +33088,11 @@ class GodotServer {
|
|
|
33017
33088
|
currentRun() {
|
|
33018
33089
|
return this.activeProcess ?? this.adoptRecordedRun();
|
|
33019
33090
|
}
|
|
33020
|
-
async checkBoot(godotPath, projectPath, scene, args, userArgs) {
|
|
33091
|
+
async checkBoot(godotPath, projectPath, scene, args, userArgs, asked = {}) {
|
|
33021
33092
|
const frames = readPositiveNumber(args, "frames") ?? 3;
|
|
33022
33093
|
const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 60000;
|
|
33023
33094
|
const cmdArgs = runArguments({ projectPath, headless: true, scene, quitAfter: frames, userArgs });
|
|
33024
|
-
const boot = this.spawnGame(godotPath, cmdArgs);
|
|
33095
|
+
const boot = this.spawnGame(godotPath, cmdArgs, needsItsOwnProcess(asked) ? environmentFor({ ...asked, runtimeDirectory: runtimeDirectory() }) : undefined);
|
|
33025
33096
|
const hung = await new Promise((resolve) => {
|
|
33026
33097
|
const timer = setTimeout(() => {
|
|
33027
33098
|
boot.process.kill();
|
|
@@ -33046,6 +33117,7 @@ class GodotServer {
|
|
|
33046
33117
|
frames,
|
|
33047
33118
|
errors,
|
|
33048
33119
|
warnings,
|
|
33120
|
+
savesNote: asked.savesIn !== undefined && savesStayPut() ? SAVES_NOT_MOVED_NOTE : undefined,
|
|
33049
33121
|
entries: forAnswer(boot.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
|
|
33050
33122
|
});
|
|
33051
33123
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.11",
|
|
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",
|