@kzheart_/mc-pilot 0.9.5 → 0.9.7

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "defaultVariant": "1.20.4-fabric",
3
3
  "buildSystem": "multi-module",
4
- "versions": ["1.18.2", "1.20.1", "1.20.2", "1.20.4", "1.21.1", "1.21.4"],
4
+ "versions": ["1.18.2", "1.20.1", "1.20.2", "1.20.4", "1.21.1", "1.21.4", "1.21.11"],
5
5
  "variants": [
6
6
  {
7
7
  "id": "1.18.2-fabric",
@@ -137,6 +137,18 @@
137
137
  "modVersion": "0.9.1",
138
138
  "neoforgeVersion": "21.4.75",
139
139
  "javaVersion": 21
140
+ },
141
+ {
142
+ "id": "1.21.11-fabric",
143
+ "minecraftVersion": "1.21.11",
144
+ "loader": "fabric",
145
+ "support": "ready",
146
+ "validation": "verified",
147
+ "modVersion": "0.9.1",
148
+ "fabricLoaderVersion": "0.19.2",
149
+ "yarnMappings": "1.21.11+build.5",
150
+ "javaVersion": 21,
151
+ "gradleModule": "version-1.21.11"
140
152
  }
141
153
  ]
142
154
  }
@@ -48,7 +48,7 @@ export function createClientCommand() {
48
48
  .option("--ws-port <port>", "WebSocket port (auto-assigned if omitted)", Number)
49
49
  .option("--account <account>", "Offline username or account identifier")
50
50
  .option("--headless", "Launch in headless mode")
51
- .option("--mute", "Mute all in-game audio for this client")
51
+ .option("--mute", "Mute all in-game audio for this client (default)")
52
52
  .option("--no-mute", "Keep in-game audio enabled for this client")
53
53
  .option("--java <command>", "Java command to use")
54
54
  .action(wrapCommand(async (_context, { args, options }) => {
@@ -74,7 +74,9 @@ export function createClientCommand() {
74
74
  env: {
75
75
  MCT_CLIENT_MOD_VARIANT: downloaded.variantId,
76
76
  MCT_CLIENT_MOD_JAR: downloaded.jar
77
- }
77
+ },
78
+ javaCommand: downloaded.javaCommand,
79
+ javaVersion: downloaded.javaVersion
78
80
  });
79
81
  return {
80
82
  created: true,
@@ -94,7 +96,7 @@ export function createClientCommand() {
94
96
  .option("--account <account>", "Offline username or account identifier")
95
97
  .option("--ws-port <port>", "WebSocket port override", Number)
96
98
  .option("--headless", "Launch in headless mode")
97
- .option("--mute", "Mute all in-game audio for this launch")
99
+ .option("--mute", "Mute all in-game audio for this launch (default)")
98
100
  .option("--no-mute", "Keep in-game audio enabled for this launch")
99
101
  .option("--force", "Kill any existing client with the same name before launching")
100
102
  .action(wrapCommand(async (context, { args, options }) => {
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  export declare function createCraftCommand(): Command;
3
+ export declare function createRecipeCommand(): Command;
3
4
  export declare function createAnvilCommand(): Command;
4
5
  export declare function createEnchantCommand(): Command;
5
6
  export declare function createTradeCommand(): Command;
@@ -1,15 +1,41 @@
1
1
  import { Command } from "commander";
2
+ import { MctError } from "../util/errors.js";
2
3
  import { createRequestAction, parseJson } from "./request-helpers.js";
4
+ function normalizeCraftRecipe(raw) {
5
+ if (Array.isArray(raw)) {
6
+ return raw;
7
+ }
8
+ if (raw && typeof raw === "object" && Array.isArray(raw.slots)) {
9
+ const slots = raw.slots;
10
+ if (slots.length !== 9) {
11
+ throw new MctError({ code: "INVALID_PARAMS", message: "recipe.slots must contain exactly 9 entries" }, 4);
12
+ }
13
+ return [slots.slice(0, 3), slots.slice(3, 6), slots.slice(6, 9)];
14
+ }
15
+ throw new MctError({
16
+ code: "INVALID_PARAMS",
17
+ message: "recipe must be a 3x3 row array or {\"slots\":[...9 entries...]}"
18
+ }, 4);
19
+ }
3
20
  export function createCraftCommand() {
4
21
  return new Command("craft")
5
22
  .description("Crafting table recipe\n" +
6
23
  "Prerequisite: open crafting table with \"block interact <x> <y> <z>\"\n" +
7
24
  "Auto-places materials from inventory, crafts, and moves result to inventory.\n" +
8
25
  "GUI closes automatically after crafting.")
9
- .requiredOption("--recipe <json>", "Recipe JSON: 9-slot array in row-major order (top-left to bottom-right).\n" +
26
+ .requiredOption("--recipe <json>", "Recipe JSON: 3 row arrays, or {\"slots\":[...9 entries...]} in row-major order.\n" +
10
27
  "Use item IDs without namespace (e.g. \"oak_planks\", not \"minecraft:oak_planks\").\n" +
11
- "Example: '{\"slots\":[\"oak_planks\",\"oak_planks\",null,\"oak_planks\",\"oak_planks\",null,null,null,null]}'")
12
- .action(createRequestAction("craft.craft", ({ options }) => ({ recipe: parseJson(String(options.recipe), "recipe") })));
28
+ "Example: '[[\"oak_planks\",null,null],[\"oak_planks\",null,null],[null,null,null]]'")
29
+ .action(createRequestAction("craft.craft", ({ options }) => ({ recipe: normalizeCraftRecipe(parseJson(String(options.recipe), "recipe")) })));
30
+ }
31
+ export function createRecipeCommand() {
32
+ const command = new Command("recipe").description("Task-level recipe helpers");
33
+ command
34
+ .command("craft-table")
35
+ .description("Craft with the currently open crafting table")
36
+ .requiredOption("--recipe <json>", "Recipe JSON: 3 row arrays or {\"slots\":[...9 entries...]}")
37
+ .action(createRequestAction("craft.craft", ({ options }) => ({ recipe: normalizeCraftRecipe(parseJson(String(options.recipe), "recipe")) })));
38
+ return command;
13
39
  }
14
40
  export function createAnvilCommand() {
15
41
  return new Command("anvil")
@@ -13,6 +13,20 @@ function resolveEventsFile(clientName) {
13
13
  }
14
14
  return path.join(resolveMctHome(), "logs", clientName, "events.jsonl");
15
15
  }
16
+ function resolveEventClientNames(context, explicitClient, allClients) {
17
+ if (allClients) {
18
+ const clients = context.activeProfile?.clients ?? [];
19
+ if (clients.length === 0) {
20
+ throw new MctError({ code: "INVALID_PARAMS", message: "--all-clients requires an active profile with clients" }, 4);
21
+ }
22
+ return clients;
23
+ }
24
+ const clientName = explicitClient ?? context.activeProfile?.clients[0];
25
+ if (!clientName) {
26
+ throw new MctError({ code: "INVALID_PARAMS", message: "Client name is required. Use --client <name> or set an active profile." }, 4);
27
+ }
28
+ return [clientName];
29
+ }
16
30
  function parseSince(raw, nowMs) {
17
31
  if (!raw)
18
32
  return undefined;
@@ -176,18 +190,29 @@ export function createEventsCommand() {
176
190
  command
177
191
  .command("clear")
178
192
  .description("Truncate the event log for the active client")
193
+ .option("--all-clients", "Clear event logs for every client in the active profile")
179
194
  .option("--file <path>", "Override the log file path")
180
195
  .action(wrapCommand(async (context, { options, globalOptions }) => {
181
196
  const { truncateSync, existsSync: exists, mkdirSync } = await import("node:fs");
182
- const clientName = globalOptions.client ?? context.activeProfile?.clients[0];
183
- const filePath = options.file ?? resolveEventsFile(clientName);
184
- if (exists(filePath)) {
185
- truncateSync(filePath, 0);
186
- return { cleared: true, file: filePath };
197
+ if (options.file) {
198
+ if (exists(options.file)) {
199
+ truncateSync(options.file, 0);
200
+ return { cleared: true, file: options.file };
201
+ }
202
+ mkdirSync(path.dirname(options.file), { recursive: true });
203
+ return { cleared: false, reason: "file_not_found", file: options.file };
187
204
  }
188
- // ensure directory exists for future writes
189
- mkdirSync(path.dirname(filePath), { recursive: true });
190
- return { cleared: false, reason: "file_not_found", file: filePath };
205
+ const clients = resolveEventClientNames(context, globalOptions.client, Boolean(options.allClients));
206
+ const results = clients.map((clientName) => {
207
+ const filePath = resolveEventsFile(clientName);
208
+ if (exists(filePath)) {
209
+ truncateSync(filePath, 0);
210
+ return { client: clientName, cleared: true, file: filePath };
211
+ }
212
+ mkdirSync(path.dirname(filePath), { recursive: true });
213
+ return { client: clientName, cleared: false, reason: "file_not_found", file: filePath };
214
+ });
215
+ return { cleared: results.some((result) => result.cleared), clients: results };
191
216
  }));
192
217
  command
193
218
  .command("path")
@@ -46,10 +46,27 @@ export function createGuiCommand() {
46
46
  .command("screenshot")
47
47
  .description("Take a screenshot of the current GUI")
48
48
  .option("--output <path>", "Output file path (default: project screenshot directory)")
49
+ .option("--timeout <seconds>", "Screenshot response timeout in seconds (default 30)", Number)
49
50
  .action(wrapCommand(async (context, { options, globalOptions }) => {
50
- return sendClientRequest(context, globalOptions.client ?? context.activeProfile?.clients[0], "gui.screenshot", {
51
- output: resolveScreenshotOutputPath(context, options.output, "gui")
52
- });
51
+ return sendClientRequest(context, globalOptions.client ?? context.activeProfile?.clients[0], "gui.screenshot", { output: resolveScreenshotOutputPath(context, options.output, "gui") }, options.timeout ?? Math.max(30, context.timeout("default")));
52
+ }));
53
+ command
54
+ .command("click-title")
55
+ .description("Click a GUI slot by title text match")
56
+ .argument("<title>", "Regex matched against item displayName or type")
57
+ .option("--button <button>", "Click button: left|right|middle|shift-left|shift-right", "left")
58
+ .action(wrapCommand(async (context, { args, options, globalOptions }) => {
59
+ const clientName = globalOptions.client ?? context.activeProfile?.clients[0];
60
+ const snapshot = await sendClientRequest(context, clientName, "gui.snapshot", {});
61
+ const slots = ((snapshot.data?.data?.slots)
62
+ ?? (snapshot.data?.slots)
63
+ ?? []);
64
+ const pattern = new RegExp(String(args[0]));
65
+ const found = slots.find((slot) => slot.item && (pattern.test(String(slot.item.displayName ?? "")) || pattern.test(String(slot.item.type ?? ""))));
66
+ if (!found || found.slot === undefined) {
67
+ return { clicked: false, matched: false, title: args[0], slots: slots.length };
68
+ }
69
+ return sendClientRequest(context, clientName, "gui.click", { slot: found.slot, button: options.button ?? "left" });
53
70
  }));
54
71
  return command;
55
72
  }
@@ -53,6 +53,8 @@ export function createUpCommand() {
53
53
  .description("Deploy plugins, start server and clients, wait for ready")
54
54
  .option("--profile <name>", "Profile name")
55
55
  .option("--eula", "Auto-accept EULA")
56
+ .option("--server-only-ok", "Only deploy/start/wait for the server; skip launching and waiting for clients")
57
+ .option("--skip-client-ready", "Launch/reconnect clients but do not wait for them to join a world")
56
58
  .action(wrapCommand(async (context, { options }) => {
57
59
  const { projectFile, projectId, projectRootDir } = context;
58
60
  if (!projectFile || !projectId || !projectRootDir) {
@@ -73,7 +75,12 @@ export function createUpCommand() {
73
75
  results.server = await serverManager.start(profile.server, { eula: options.eula });
74
76
  // 3. Wait for server
75
77
  const serverMeta = await serverManager.loadMeta(profile.server);
76
- await serverManager.waitReady(profile.server, context.timeout("serverReady"));
78
+ results.serverReady = await serverManager.waitReady(profile.server, context.timeout("serverReady"));
79
+ if (options.serverOnlyOk) {
80
+ results.ready = true;
81
+ results.clientsSkipped = true;
82
+ return results;
83
+ }
77
84
  // 4. Launch clients (reuse running clients via reconnect)
78
85
  const serverAddress = `localhost:${serverMeta.port}`;
79
86
  const clientResults = [];
@@ -89,8 +96,15 @@ export function createUpCommand() {
89
96
  }
90
97
  results.clients = clientResults;
91
98
  // 5. Wait for clients (WS connected + in-world)
92
- for (const clientName of profile.clients) {
93
- await clientManager.waitReady(clientName, context.timeout("clientReady"));
99
+ if (options.skipClientReady) {
100
+ results.clientReadySkipped = true;
101
+ }
102
+ else {
103
+ const readyClients = [];
104
+ for (const clientName of profile.clients) {
105
+ readyClients.push(await clientManager.waitReady(clientName, context.timeout("clientReady")));
106
+ }
107
+ results.clientReady = readyClients;
94
108
  }
95
109
  results.ready = true;
96
110
  return results;
@@ -1,17 +1,53 @@
1
1
  import { Command } from "commander";
2
+ import { ClientInstanceManager } from "../instance/ClientInstanceManager.js";
2
3
  import { wrapCommand } from "../util/command.js";
4
+ import { MctError } from "../util/errors.js";
3
5
  import { resolveScreenshotOutputPath, sendClientRequest } from "./request-helpers.js";
6
+ async function captureWithRetry(context, clientName, params, options) {
7
+ const timeout = options.timeout ?? Math.max(30, context.timeout("default"));
8
+ const retries = options.retries ?? 1;
9
+ let lastError;
10
+ for (let attempt = 0; attempt <= retries; attempt++) {
11
+ try {
12
+ return await sendClientRequest(context, clientName, "capture.screenshot", params, timeout);
13
+ }
14
+ catch (error) {
15
+ lastError = error;
16
+ if (attempt < retries) {
17
+ await new Promise((resolve) => setTimeout(resolve, 500));
18
+ }
19
+ }
20
+ }
21
+ const manager = new ClientInstanceManager(context.globalState);
22
+ const diagnostics = await manager.getClient(clientName).catch((error) => ({ unavailable: String(error.message) }));
23
+ throw new MctError({
24
+ code: "TIMEOUT",
25
+ message: `Screenshot request failed after ${retries + 1} attempt(s) with ${timeout}s timeout`,
26
+ details: {
27
+ client: clientName ?? context.activeProfile?.clients[0] ?? null,
28
+ timeout,
29
+ retries,
30
+ lastError: lastError instanceof Error ? lastError.message : String(lastError),
31
+ diagnostics
32
+ }
33
+ }, 2);
34
+ }
4
35
  export function createScreenshotCommand() {
5
36
  return new Command("screenshot")
6
37
  .description("Take a screenshot")
7
38
  .option("--output <path>", "Output file path (default: project screenshot directory)")
8
39
  .option("--region <region>", "Capture a sub-region, format: x,y,w,h")
9
40
  .option("--gui", "Capture the current GUI screen")
41
+ .option("--timeout <seconds>", "Screenshot response timeout in seconds (default 30)", Number)
42
+ .option("--retries <count>", "Retry count after timeout/failure (default 1)", Number)
10
43
  .action(wrapCommand(async (context, { options, globalOptions }) => {
11
- return sendClientRequest(context, globalOptions.client ?? context.activeProfile?.clients[0], "capture.screenshot", {
44
+ return captureWithRetry(context, globalOptions.client ?? context.activeProfile?.clients[0], {
12
45
  output: resolveScreenshotOutputPath(context, options.output, "screenshot"),
13
46
  region: options.region,
14
47
  gui: Boolean(options.gui)
48
+ }, {
49
+ timeout: options.timeout,
50
+ retries: options.retries
15
51
  });
16
52
  }));
17
53
  }
@@ -122,6 +122,16 @@ export function createServerCommand() {
122
122
  const manager = new ServerInstanceManager(context.globalState, project);
123
123
  return manager.waitReady(serverName, options.timeout ?? context.timeout("serverReady"));
124
124
  }));
125
+ command
126
+ .command("readiness")
127
+ .description("Report process, port and log readiness diagnostics")
128
+ .argument("[name]", "Server instance name (default: from active profile)")
129
+ .action(wrapCommand(async (context, { args }) => {
130
+ const project = requireProject(context);
131
+ const serverName = resolveServerName(context, args[0]);
132
+ const manager = new ServerInstanceManager(context.globalState, project);
133
+ return manager.readiness(serverName);
134
+ }));
125
135
  command
126
136
  .command("exec")
127
137
  .description("Send a console command directly to the server stdin FIFO (bypasses client chat)")
@@ -140,6 +150,8 @@ export function createServerCommand() {
140
150
  .option("--tail <n>", "Show only the last N lines", Number)
141
151
  .option("--grep <pattern>", "Filter lines by regex")
142
152
  .option("--since <lineNumber>", "Skip the first N lines (0-indexed)", Number)
153
+ .option("--since-start", "Only read log content written after the current server process was started")
154
+ .option("--after-marker <marker>", "Only read log lines after the last line containing this marker")
143
155
  .option("--follow", "Wait for new log lines (requires --timeout)")
144
156
  .option("--timeout <seconds>", "Max seconds to wait when --follow is set", Number)
145
157
  .option("--first-match", "With --follow, exit as soon as the first matching line appears")
@@ -161,8 +173,21 @@ export function createServerCommand() {
161
173
  tail: options.tail,
162
174
  grep: options.grep,
163
175
  since: options.since,
176
+ sinceStart: Boolean(options.sinceStart),
177
+ afterMarker: options.afterMarker,
164
178
  rawColors: Boolean(options.rawColors)
165
179
  });
166
180
  }));
181
+ command
182
+ .command("logs-mark")
183
+ .description("Append a marker line to the server log and return the marker")
184
+ .argument("[name]", "Server instance name (default: from active profile)")
185
+ .option("--label <label>", "Optional marker label")
186
+ .action(wrapCommand(async (context, { args, options }) => {
187
+ const project = requireProject(context);
188
+ const serverName = resolveServerName(context, args[0]);
189
+ const manager = new ServerInstanceManager(context.globalState, project);
190
+ return manager.markLogs(serverName, options.label);
191
+ }));
167
192
  return command;
168
193
  }
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createWaitLogCommand(): Command;
@@ -0,0 +1,31 @@
1
+ import { Command } from "commander";
2
+ import { ServerInstanceManager } from "../instance/ServerInstanceManager.js";
3
+ import { MctError } from "../util/errors.js";
4
+ import { wrapCommand } from "../util/command.js";
5
+ function requireProject(context) {
6
+ if (!context.projectId) {
7
+ throw new MctError({ code: "NO_PROJECT", message: "No project context. Run inside an mct project or use --project <id>." }, 4);
8
+ }
9
+ return context.projectId;
10
+ }
11
+ export function createWaitLogCommand() {
12
+ return new Command("wait-log")
13
+ .description("Wait for a server log line matching a regex")
14
+ .option("--server <name>", "Server instance name (default: from active profile)")
15
+ .requiredOption("--grep <pattern>", "Regex to match")
16
+ .option("--timeout <seconds>", "Timeout in seconds (default 30)", Number)
17
+ .option("--first-match", "Exit after the first matching line", true)
18
+ .action(wrapCommand(async (context, { options }) => {
19
+ const project = requireProject(context);
20
+ const serverName = options.server ?? context.activeProfile?.server;
21
+ if (!serverName) {
22
+ throw new MctError({ code: "INVALID_PARAMS", message: "Server name is required" }, 4);
23
+ }
24
+ const manager = new ServerInstanceManager(context.globalState, project);
25
+ return manager.followLogs(serverName, {
26
+ grep: options.grep,
27
+ timeoutSeconds: options.timeout ?? context.timeout("default"),
28
+ firstMatchOnly: options.firstMatch !== false
29
+ });
30
+ }));
31
+ }
@@ -1,5 +1,20 @@
1
1
  import { loadModVariantCatalogSync } from "./ModVariantCatalog.js";
2
2
  const VERSION_MATRIX = [
3
+ {
4
+ minecraftVersion: "1.21.11",
5
+ javaVersion: "21+",
6
+ servers: {
7
+ vanilla: { supported: true },
8
+ paper: { supported: true, latestBuild: 69 },
9
+ purpur: { supported: true, latestBuild: 2568 },
10
+ spigot: { supported: true, requiresBuildTools: true }
11
+ },
12
+ clients: {
13
+ fabric: { supported: true, loaderVersion: "0.19.2", modVersion: "0.9.1", validation: "verified" },
14
+ forge: { supported: false, notes: "不支持此版本" },
15
+ neoforge: { supported: false, validation: "planned", notes: "计划中" }
16
+ }
17
+ },
3
18
  {
4
19
  minecraftVersion: "1.21.4",
5
20
  javaVersion: "21+",
@@ -10,7 +10,10 @@ const DOWNLOAD_DISPATCHER = new Agent({
10
10
  connect: {
11
11
  timeout: 30_000
12
12
  },
13
- connections: 2
13
+ headersTimeout: 30_000,
14
+ bodyTimeout: 30_000,
15
+ connections: 1,
16
+ pipelining: 0
14
17
  }).compose(interceptors.retry({
15
18
  maxRetries: 4,
16
19
  minTimeout: 500,
@@ -74,8 +77,8 @@ async function prepareManagedRuntime(variant, runtimeOptions, dependencies, expe
74
77
  await installDependencies(resolvedVersion, {
75
78
  side: "client",
76
79
  dispatcher: DOWNLOAD_DISPATCHER,
77
- assetsDownloadConcurrency: 2,
78
- librariesDownloadConcurrency: 2
80
+ assetsDownloadConcurrency: 1,
81
+ librariesDownloadConcurrency: 1
79
82
  });
80
83
  await applyArm64LwjglPatch(runtimeRootDir, installedVersionId, { fetchImpl });
81
84
  await writeFile(readyMarker, new Date().toISOString(), "utf8");
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { createBookCommand } from "./commands/book.js";
7
7
  import { createClientCommand } from "./commands/client.js";
8
8
  import { createChatCommand } from "./commands/chat.js";
9
9
  import { createCombatCommand } from "./commands/combat.js";
10
- import { createAnvilCommand, createCraftCommand, createEnchantCommand, createTradeCommand } from "./commands/craft.js";
10
+ import { createAnvilCommand, createCraftCommand, createEnchantCommand, createRecipeCommand, createTradeCommand } from "./commands/craft.js";
11
11
  import { createEntityCommand } from "./commands/entity.js";
12
12
  import { createEventsCommand } from "./commands/events.js";
13
13
  import { createGuiCommand } from "./commands/gui.js";
@@ -27,6 +27,7 @@ import { createServerCommand } from "./commands/server.js";
27
27
  import { createSignCommand } from "./commands/sign.js";
28
28
  import { createStatusCommand } from "./commands/status.js";
29
29
  import { createWaitCommand } from "./commands/wait.js";
30
+ import { createWaitLogCommand } from "./commands/wait-log.js";
30
31
  import { createPluginCommand } from "./commands/plugin.js";
31
32
  import { createInitCommand, createDeployCommand, createUpCommand, createDownCommand, createUseCommand } from "./commands/project.js";
32
33
  import { attachGlobalOptions, wrapCommand } from "./util/command.js";
@@ -94,10 +95,12 @@ export function buildProgram() {
94
95
  program.addCommand(createResourcepackCommand());
95
96
  program.addCommand(createCombatCommand());
96
97
  program.addCommand(createCraftCommand());
98
+ program.addCommand(createRecipeCommand());
97
99
  program.addCommand(createAnvilCommand());
98
100
  program.addCommand(createEnchantCommand());
99
101
  program.addCommand(createTradeCommand());
100
102
  program.addCommand(createWaitCommand());
103
+ program.addCommand(createWaitLogCommand());
101
104
  program.addCommand(createEventsCommand());
102
105
  return program;
103
106
  }
@@ -10,6 +10,8 @@ export interface CreateClientOptions {
10
10
  mute?: boolean;
11
11
  launchArgs?: string[];
12
12
  env?: Record<string, string>;
13
+ javaCommand?: string;
14
+ javaVersion?: number;
13
15
  }
14
16
  export interface LaunchClientOptions {
15
17
  server?: string;
@@ -8,6 +8,7 @@ import { MctError } from "../util/errors.js";
8
8
  import { getListeningPids, isProcessRunning, killProcessTree } from "../util/process.js";
9
9
  import { WebSocketClient } from "../client/WebSocketClient.js";
10
10
  const INSTANCE_FILE = "instance.json";
11
+ const DEFAULT_CLIENT_MUTE = true;
11
12
  function getLaunchScriptPath() {
12
13
  const thisFile = fileURLToPath(import.meta.url);
13
14
  // dist/instance/ClientInstanceManager.js -> scripts/launch-fabric-client.mjs
@@ -30,9 +31,11 @@ export class ClientInstanceManager {
30
31
  wsPort,
31
32
  account: options.account,
32
33
  headless: options.headless,
33
- mute: options.mute,
34
+ mute: options.mute ?? DEFAULT_CLIENT_MUTE,
34
35
  launchArgs: options.launchArgs,
35
36
  env: options.env,
37
+ javaCommand: options.javaCommand,
38
+ javaVersion: options.javaVersion,
36
39
  createdAt: new Date().toISOString()
37
40
  };
38
41
  await writeFile(path.join(instanceDir, INSTANCE_FILE), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
@@ -58,7 +61,7 @@ export class ClientInstanceManager {
58
61
  const meta = await this.loadMeta(clientName);
59
62
  const instanceDir = resolveClientInstanceDir(clientName);
60
63
  const wsPort = options.wsPort ?? meta.wsPort;
61
- const mute = options.mute ?? meta.mute;
64
+ const mute = options.mute ?? meta.mute ?? DEFAULT_CLIENT_MUTE;
62
65
  if (!meta.launchArgs || meta.launchArgs.length === 0) {
63
66
  throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
64
67
  }
@@ -94,6 +97,7 @@ export class ClientInstanceManager {
94
97
  MCT_CLIENT_SERVER: options.server ?? "",
95
98
  MCT_CLIENT_WS_PORT: String(wsPort),
96
99
  MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false),
100
+ ...(meta.javaCommand ? { MCT_CLIENT_JAVA: meta.javaCommand } : {}),
97
101
  ...(mute === undefined ? {} : { MCT_CLIENT_MUTE: String(mute) })
98
102
  }
99
103
  });
@@ -9,6 +9,8 @@ export interface CreateServerOptions {
9
9
  version: string;
10
10
  port?: number;
11
11
  jvmArgs?: string[];
12
+ javaCommand?: string;
13
+ javaVersion?: number;
12
14
  eula?: boolean;
13
15
  cachedJarPath?: string;
14
16
  }
@@ -48,6 +50,7 @@ export declare class ServerInstanceManager {
48
50
  startedAt: string;
49
51
  logPath: string;
50
52
  instanceDir: string;
53
+ logStartOffset?: number;
51
54
  stdinPipe?: string;
52
55
  running: boolean;
53
56
  stale: boolean;
@@ -59,6 +62,7 @@ export declare class ServerInstanceManager {
59
62
  startedAt: string;
60
63
  logPath: string;
61
64
  instanceDir: string;
65
+ logStartOffset?: number;
62
66
  stdinPipe?: string;
63
67
  running: boolean;
64
68
  }>;
@@ -71,6 +75,11 @@ export declare class ServerInstanceManager {
71
75
  host: string;
72
76
  port: number;
73
77
  phase: string;
78
+ signals: {
79
+ processAlive: boolean;
80
+ portReachable: boolean;
81
+ readyLineSeen: boolean;
82
+ };
74
83
  logPath: string;
75
84
  lastLine: string | null;
76
85
  recentLines: string[];
@@ -84,6 +93,8 @@ export declare class ServerInstanceManager {
84
93
  tail?: number;
85
94
  grep?: string;
86
95
  since?: number;
96
+ sinceStart?: boolean;
97
+ afterMarker?: string;
87
98
  rawColors?: boolean;
88
99
  }): Promise<{
89
100
  logPath: string;
@@ -91,6 +102,10 @@ export declare class ServerInstanceManager {
91
102
  returnedLines: number;
92
103
  lines: string[];
93
104
  }>;
105
+ markLogs(serverName: string, label?: string): Promise<{
106
+ logPath: string;
107
+ marker: string;
108
+ }>;
94
109
  followLogs(serverName: string, options: {
95
110
  grep?: string;
96
111
  timeoutSeconds: number;
@@ -104,6 +119,24 @@ export declare class ServerInstanceManager {
104
119
  }>;
105
120
  private requireRuntimeEntry;
106
121
  private describeStartup;
122
+ readiness(serverName: string): Promise<{
123
+ process: {
124
+ alive: boolean;
125
+ pid: number;
126
+ };
127
+ port: {
128
+ reachable: boolean;
129
+ host: string;
130
+ port: number;
131
+ };
132
+ log: {
133
+ phase: string;
134
+ readyLineSeen: boolean;
135
+ lastLine: string | null;
136
+ recentLines: string[];
137
+ path: string;
138
+ };
139
+ }>;
107
140
  private requireRunning;
108
141
  list(): Promise<ServerInstanceMeta[]>;
109
142
  static listAll(globalState: GlobalStateStore): Promise<ServerInstanceMeta[]>;
@@ -67,6 +67,8 @@ export class ServerInstanceManager {
67
67
  mcVersion: options.version,
68
68
  port,
69
69
  jvmArgs: options.jvmArgs ?? [],
70
+ javaCommand: options.javaCommand,
71
+ javaVersion: options.javaVersion,
70
72
  createdAt: new Date().toISOString()
71
73
  };
72
74
  await writeFile(path.join(instanceDir, INSTANCE_FILE), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
@@ -95,8 +97,10 @@ export class ServerInstanceManager {
95
97
  mkdirSync(logsDir, { recursive: true });
96
98
  mkdirSync(stateDir, { recursive: true });
97
99
  const logPath = path.join(logsDir, `server-${this.project}-${serverName}.log`);
100
+ const logStartOffset = await stat(logPath).then((value) => value.size).catch(() => 0);
98
101
  const stdout = openSync(logPath, "a");
99
102
  const jvmArgs = options.jvmArgs ?? meta.jvmArgs;
103
+ const javaCommand = meta.javaCommand ?? "java";
100
104
  // Create a named pipe (FIFO) for stdin so external tools (GUI) can send commands
101
105
  const stdinPipe = path.join(stateDir, `stdin-${this.project}-${serverName}.fifo`);
102
106
  try {
@@ -109,7 +113,7 @@ export class ServerInstanceManager {
109
113
  // then exec java with stdin reading from the FIFO
110
114
  const child = spawn("bash", [
111
115
  "-c",
112
- 'exec 3<>"$MCT_STDIN_PIPE"; exec java "$@" 0<&3',
116
+ 'exec 3<>"$MCT_STDIN_PIPE"; exec "$MCT_SERVER_JAVA" "$@" 0<&3',
113
117
  "mct-server",
114
118
  ...jvmArgs, "-jar", jarFile, "nogui"
115
119
  ], {
@@ -119,7 +123,8 @@ export class ServerInstanceManager {
119
123
  env: {
120
124
  ...process.env,
121
125
  MCT_SERVER_PORT: String(meta.port),
122
- MCT_STDIN_PIPE: stdinPipe
126
+ MCT_STDIN_PIPE: stdinPipe,
127
+ MCT_SERVER_JAVA: javaCommand
123
128
  }
124
129
  });
125
130
  child.unref();
@@ -131,6 +136,7 @@ export class ServerInstanceManager {
131
136
  startedAt: new Date().toISOString(),
132
137
  logPath,
133
138
  instanceDir,
139
+ logStartOffset,
134
140
  stdinPipe
135
141
  };
136
142
  state.servers[stateKey] = entry;
@@ -232,6 +238,11 @@ export class ServerInstanceManager {
232
238
  host: "127.0.0.1",
233
239
  port: entry.port,
234
240
  phase: snapshot.phase,
241
+ signals: {
242
+ processAlive: true,
243
+ portReachable: true,
244
+ readyLineSeen: snapshot.phase === "ready"
245
+ },
235
246
  logPath: snapshot.logPath,
236
247
  lastLine: snapshot.lastLine,
237
248
  recentLines: snapshot.recentLines
@@ -247,6 +258,11 @@ export class ServerInstanceManager {
247
258
  host: "127.0.0.1",
248
259
  port: entry.port,
249
260
  phase: snapshot.phase,
261
+ signals: {
262
+ processAlive: isProcessRunning(entry.pid),
263
+ portReachable: false,
264
+ readyLineSeen: snapshot.phase === "ready"
265
+ },
250
266
  logPath: snapshot.logPath,
251
267
  lastLine: snapshot.lastLine,
252
268
  recentLines: snapshot.recentLines
@@ -282,11 +298,14 @@ export class ServerInstanceManager {
282
298
  async readLogs(serverName, options = {}) {
283
299
  const entry = await this.requireRuntimeEntry(serverName);
284
300
  const logPath = entry.logPath;
285
- const raw = await readFile(logPath, "utf8").catch((error) => {
301
+ let raw = await readFile(logPath, "utf8").catch((error) => {
286
302
  if (error.code === "ENOENT")
287
303
  return "";
288
304
  throw error;
289
305
  });
306
+ if (options.sinceStart && entry.logStartOffset && entry.logStartOffset > 0) {
307
+ raw = raw.slice(entry.logStartOffset);
308
+ }
290
309
  let lines = raw.split("\n");
291
310
  if (lines.length > 0 && lines[lines.length - 1] === "")
292
311
  lines = lines.slice(0, -1);
@@ -297,6 +316,18 @@ export class ServerInstanceManager {
297
316
  if (options.since !== undefined && options.since > 0) {
298
317
  lines = lines.slice(Math.max(0, options.since));
299
318
  }
319
+ if (options.afterMarker) {
320
+ let markerIndex = -1;
321
+ for (let index = lines.length - 1; index >= 0; index--) {
322
+ if (lines[index].includes(options.afterMarker)) {
323
+ markerIndex = index;
324
+ break;
325
+ }
326
+ }
327
+ if (markerIndex >= 0) {
328
+ lines = lines.slice(markerIndex + 1);
329
+ }
330
+ }
300
331
  if (options.grep) {
301
332
  const re = new RegExp(options.grep);
302
333
  lines = lines.filter((line) => re.test(line));
@@ -306,6 +337,12 @@ export class ServerInstanceManager {
306
337
  }
307
338
  return { logPath, totalLines: total, returnedLines: lines.length, lines };
308
339
  }
340
+ async markLogs(serverName, label) {
341
+ const entry = await this.requireRuntimeEntry(serverName);
342
+ const marker = `MCT_MARK ${new Date().toISOString()} ${label ?? ""}`.trim();
343
+ await writeFile(entry.logPath, `\n${marker}\n`, { flag: "a", encoding: "utf8" });
344
+ return { logPath: entry.logPath, marker };
345
+ }
309
346
  async followLogs(serverName, options) {
310
347
  const entry = await this.requireRuntimeEntry(serverName);
311
348
  const logPath = entry.logPath;
@@ -398,6 +435,23 @@ export class ServerInstanceManager {
398
435
  lastLine
399
436
  };
400
437
  }
438
+ async readiness(serverName) {
439
+ const entry = await this.requireRuntimeEntry(serverName);
440
+ const processAlive = isProcessRunning(entry.pid);
441
+ const portReachable = await isTcpPortReachable("127.0.0.1", entry.port);
442
+ const snapshot = await this.describeStartup(entry.logPath);
443
+ return {
444
+ process: { alive: processAlive, pid: entry.pid },
445
+ port: { reachable: portReachable, host: "127.0.0.1", port: entry.port },
446
+ log: {
447
+ phase: snapshot.phase,
448
+ readyLineSeen: snapshot.phase === "ready",
449
+ lastLine: snapshot.lastLine,
450
+ recentLines: snapshot.recentLines,
451
+ path: snapshot.logPath
452
+ }
453
+ };
454
+ }
401
455
  async requireRunning(serverName) {
402
456
  const entry = await this.requireRuntimeEntry(serverName);
403
457
  if (!isProcessRunning(entry.pid)) {
@@ -7,6 +7,8 @@ export interface ServerInstanceMeta {
7
7
  mcVersion: string;
8
8
  port: number;
9
9
  jvmArgs: string[];
10
+ javaCommand?: string;
11
+ javaVersion?: number;
10
12
  createdAt: string;
11
13
  }
12
14
  export interface ClientInstanceMeta {
@@ -19,6 +21,8 @@ export interface ClientInstanceMeta {
19
21
  mute?: boolean;
20
22
  launchArgs?: string[];
21
23
  env?: Record<string, string>;
24
+ javaCommand?: string;
25
+ javaVersion?: number;
22
26
  createdAt: string;
23
27
  }
24
28
  export interface ServerRuntimeEntry {
@@ -29,6 +33,7 @@ export interface ServerRuntimeEntry {
29
33
  startedAt: string;
30
34
  logPath: string;
31
35
  instanceDir: string;
36
+ logStartOffset?: number;
32
37
  stdinPipe?: string;
33
38
  }
34
39
  export interface ClientRuntimeEntry {
@@ -29,16 +29,23 @@ export async function loadProjectFileById(projectId) {
29
29
  return JSON.parse(raw);
30
30
  }
31
31
  export async function loadProjectFileForCwd(cwd) {
32
- const projectId = slugifyProjectId(normalizeProjectRoot(cwd));
33
- const projectFile = await loadProjectFileById(projectId);
34
- if (!projectFile) {
35
- return null;
32
+ let current = normalizeProjectRoot(cwd);
33
+ while (true) {
34
+ const projectId = slugifyProjectId(current);
35
+ const projectFile = await loadProjectFileById(projectId);
36
+ if (projectFile) {
37
+ return {
38
+ projectId,
39
+ filePath: resolveProjectFilePath(projectId),
40
+ projectFile
41
+ };
42
+ }
43
+ const parent = path.dirname(current);
44
+ if (parent === current) {
45
+ return null;
46
+ }
47
+ current = parent;
36
48
  }
37
- return {
38
- projectId,
39
- filePath: resolveProjectFilePath(projectId),
40
- projectFile
41
- };
42
49
  }
43
50
  export async function loadProjectFileForId(projectId) {
44
51
  const projectFile = await loadProjectFileById(projectId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Minecraft plugin/mod automated testing CLI – control a real Minecraft client to simulate player actions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,11 +7,12 @@ import { execFileSync } from "node:child_process";
7
7
  import path from "node:path";
8
8
  import { spawn } from "node:child_process";
9
9
  import { fileURLToPath } from "node:url";
10
- import { createQuickPlayMultiplayer, launch as launchMinecraft } from "@xmcl/core";
10
+ import { DEFAULT_EXTRA_JVM_ARGS, createQuickPlayMultiplayer, launch as launchMinecraft } from "@xmcl/core";
11
11
 
12
12
  const __filename = fileURLToPath(import.meta.url);
13
13
  const __dirname = path.dirname(__filename);
14
14
  const VARIANTS_PATH = path.join(__dirname, "..", "data", "variants.json");
15
+ const DEFAULT_CLIENT_LANGUAGE = "zh_cn";
15
16
 
16
17
  function readCatalog() {
17
18
  return JSON.parse(readFileSync(VARIANTS_PATH, "utf8"));
@@ -121,6 +122,30 @@ function parseOptionalBoolean(value) {
121
122
  return undefined;
122
123
  }
123
124
 
125
+ function getClientLanguage() {
126
+ return process.env.MCT_CLIENT_LANGUAGE || DEFAULT_CLIENT_LANGUAGE;
127
+ }
128
+
129
+ function localeJavaArgs(language) {
130
+ const [languageCode = "zh", countryCode] = String(language).replace("-", "_").split("_");
131
+ return [
132
+ `-Duser.language=${languageCode || "zh"}`,
133
+ ...(countryCode ? [`-Duser.country=${countryCode.toUpperCase()}`] : [])
134
+ ];
135
+ }
136
+
137
+ function normalizeLocaleJavaArgs(javaArgs, language) {
138
+ return [
139
+ ...javaArgs.filter((entry) => !entry.startsWith("-Duser.language=") && !entry.startsWith("-Duser.country=")),
140
+ ...localeJavaArgs(language)
141
+ ];
142
+ }
143
+
144
+ function xmclExtraJvmArgs(language, maxMemory) {
145
+ const defaultArgs = maxMemory ? DEFAULT_EXTRA_JVM_ARGS.filter((entry) => entry !== "-Xmx2G") : DEFAULT_EXTRA_JVM_ARGS;
146
+ return normalizeLocaleJavaArgs([...defaultArgs], language);
147
+ }
148
+
124
149
  async function ensureFile(filePath, downloadUrl) {
125
150
  try {
126
151
  await access(filePath);
@@ -185,7 +210,7 @@ async function syncConfiguredMod(gameDir) {
185
210
  await copyFile(sourceJar, targetJar);
186
211
  }
187
212
 
188
- async function ensureAutomationOptions(gameDir, server, mute) {
213
+ async function ensureAutomationOptions(gameDir, server, mute, language = DEFAULT_CLIENT_LANGUAGE) {
189
214
  const optionsPath = path.join(gameDir, "options.txt");
190
215
  const values = new Map();
191
216
 
@@ -206,6 +231,7 @@ async function ensureAutomationOptions(gameDir, server, mute) {
206
231
  values.set("joinedFirstServer", "true");
207
232
  values.set("tutorialStep", "none");
208
233
  values.set("pauseOnLostFocus", "false");
234
+ values.set("lang", language);
209
235
  if (mute !== undefined) {
210
236
  const volume = mute ? "0.0" : "1.0";
211
237
  for (const category of [
@@ -247,6 +273,7 @@ async function buildLaunchSpec(options) {
247
273
  const gameDir = path.join(instanceRoot, "minecraft");
248
274
  const packMeta = await readJson(path.join(instanceRoot, "mmc-pack.json"));
249
275
  const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
276
+ const language = getClientLanguage();
250
277
  await syncBuiltMod(instanceRoot, repoRoot, selectedVariant);
251
278
  const componentMetas = new Map();
252
279
  for (const component of packMeta.components) {
@@ -296,7 +323,7 @@ async function buildLaunchSpec(options) {
296
323
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
297
324
  const accountUuid = offlineUuid(accountName);
298
325
  const server = process.env.MCT_CLIENT_SERVER || "";
299
- await ensureAutomationOptions(gameDir, server, mute);
326
+ await ensureAutomationOptions(gameDir, server, mute, language);
300
327
  const [serverHost, serverPort = "25565"] = server.split(":");
301
328
  const classpath = [
302
329
  path.join(librariesRoot, mainJarPath),
@@ -334,7 +361,7 @@ async function buildLaunchSpec(options) {
334
361
  "-XstartOnFirstThread",
335
362
  "-Xms512m",
336
363
  `-Xmx${options["max-mem"] || "1024m"}`,
337
- "-Duser.language=en",
364
+ ...localeJavaArgs(language),
338
365
  `-Djava.library.path=${nativesDir}`,
339
366
  "-DFabricMcEmu=net.minecraft.client.main.Main"
340
367
  ]
@@ -350,12 +377,13 @@ async function buildManifestLaunchSpec(options) {
350
377
  const manifest = await readJson(manifestPath);
351
378
  const gameDir = manifest.gameDir;
352
379
  const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
380
+ const language = getClientLanguage();
353
381
  await syncConfiguredMod(gameDir);
354
382
 
355
383
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
356
384
  const accountUuid = offlineUuid(accountName);
357
385
  const server = process.env.MCT_CLIENT_SERVER || "";
358
- await ensureAutomationOptions(gameDir, server, mute);
386
+ await ensureAutomationOptions(gameDir, server, mute, language);
359
387
  const [serverHost, serverPort = "25565"] = server.split(":");
360
388
  const substitutions = {
361
389
  auth_player_name: accountName,
@@ -374,9 +402,9 @@ async function buildManifestLaunchSpec(options) {
374
402
  gameArgs.push("--quickPlayMultiplayer", `${serverHost}:${serverPort}`);
375
403
  }
376
404
 
377
- const javaArgs = manifest.javaArgs
405
+ const javaArgs = normalizeLocaleJavaArgs(manifest.javaArgs
378
406
  .map((entry) => substitute(entry, substitutions))
379
- .filter((entry) => !entry.includes("${"));
407
+ .filter((entry) => !entry.includes("${")), language);
380
408
 
381
409
  return {
382
410
  cwd: gameDir,
@@ -418,15 +446,19 @@ async function launchXmclManagedClient(options) {
418
446
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
419
447
  const accountUuid = offlineUuid(accountName).replaceAll("-", "");
420
448
  const server = process.env.MCT_CLIENT_SERVER || "";
421
- await ensureAutomationOptions(gameDir, server);
449
+ const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
450
+ const language = getClientLanguage();
451
+ await ensureAutomationOptions(gameDir, server, mute, language);
422
452
  const [serverHost, serverPort = "25565"] = server.split(":");
453
+ const maxMemory = parseMaxMemory(options["max-mem"]);
423
454
 
424
455
  return launchMinecraft({
425
456
  gamePath: gameDir,
426
457
  resourcePath: runtimeRoot,
427
458
  javaPath: options.java || process.env.MCT_CLIENT_JAVA || "java",
428
459
  minMemory: 512,
429
- maxMemory: parseMaxMemory(options["max-mem"]),
460
+ maxMemory,
461
+ extraJVMArgs: xmclExtraJvmArgs(language, maxMemory),
430
462
  version: versionId,
431
463
  gameProfile: {
432
464
  name: accountName,