@kzheart_/mc-pilot 0.3.4 → 0.4.1

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.
@@ -129,6 +129,7 @@ export function createClientCommand() {
129
129
  .option("--account <account>", "Offline username or account identifier")
130
130
  .option("--ws-port <port>", "WebSocket port override", Number)
131
131
  .option("--headless", "Launch in headless mode")
132
+ .option("--force", "Kill any existing client with the same name before launching")
132
133
  .action(wrapCommand(async (context, { args, options }) => {
133
134
  const clientName = args[0] ?? context.activeProfile?.clients[0];
134
135
  if (!clientName) {
@@ -154,16 +155,19 @@ export function createClientCommand() {
154
155
  }));
155
156
  command
156
157
  .command("wait-ready")
157
- .description("Wait until client WebSocket is connected")
158
+ .description("Wait until client WebSocket is connected AND the player has joined a world")
158
159
  .argument("[name]", "Client instance name (default: from active profile)")
159
160
  .option("--timeout <seconds>", "Timeout in seconds", Number)
161
+ .option("--no-world-check", "Only wait for WebSocket connection, skip the in-world check")
160
162
  .action(wrapCommand(async (context, { args, options }) => {
161
163
  const clientName = args[0] ?? context.activeProfile?.clients[0];
162
164
  if (!clientName) {
163
165
  throw new MctError({ code: "INVALID_PARAMS", message: "Client name is required" }, 4);
164
166
  }
165
167
  const manager = new ClientInstanceManager(context.globalState);
166
- return manager.waitReady(clientName, options.timeout ?? context.timeout("clientReady"));
168
+ return manager.waitReady(clientName, options.timeout ?? context.timeout("clientReady"), {
169
+ requireWorld: options.worldCheck !== false
170
+ });
167
171
  }));
168
172
  command
169
173
  .command("reconnect")
@@ -82,16 +82,21 @@ export function createUpCommand() {
82
82
  // 3. Wait for server
83
83
  const serverMeta = await serverManager.loadMeta(profile.server);
84
84
  await serverManager.waitReady(profile.server, context.timeout("serverReady"));
85
- // 4. Launch clients
85
+ // 4. Launch clients (reuse running clients via reconnect)
86
+ const serverAddress = `localhost:${serverMeta.port}`;
86
87
  const clientResults = [];
87
88
  for (const clientName of profile.clients) {
88
- const result = await clientManager.launch(clientName, {
89
- server: `localhost:${serverMeta.port}`
90
- });
91
- clientResults.push(result);
89
+ if (await clientManager.isAlreadyRunning(clientName)) {
90
+ const reconnected = await clientManager.reconnect(clientName, serverAddress);
91
+ clientResults.push(reconnected);
92
+ }
93
+ else {
94
+ const result = await clientManager.launch(clientName, { server: serverAddress });
95
+ clientResults.push(result);
96
+ }
92
97
  }
93
98
  results.clients = clientResults;
94
- // 5. Wait for clients
99
+ // 5. Wait for clients (WS connected + in-world)
95
100
  for (const clientName of profile.clients) {
96
101
  await clientManager.waitReady(clientName, context.timeout("clientReady"));
97
102
  }
@@ -123,7 +128,11 @@ export function createDownCommand() {
123
128
  }
124
129
  results.clients = clientResults;
125
130
  // Stop server
126
- results.server = await serverManager.stop(profile.server);
131
+ const serverResult = await serverManager.stop(profile.server);
132
+ results.server = serverResult;
133
+ const everythingAccountedFor = clientResults.every((r) => r.stopped || r.alreadyStopped) &&
134
+ (serverResult.stopped || serverResult.alreadyStopped);
135
+ results.allClean = Boolean(everythingAccountedFor);
127
136
  return results;
128
137
  }));
129
138
  }
@@ -15,6 +15,10 @@ export interface LaunchClientOptions {
15
15
  account?: string;
16
16
  wsPort?: number;
17
17
  headless?: boolean;
18
+ force?: boolean;
19
+ }
20
+ export interface WaitReadyOptions {
21
+ requireWorld?: boolean;
18
22
  }
19
23
  export declare class ClientInstanceManager {
20
24
  private readonly globalState;
@@ -23,12 +27,20 @@ export declare class ClientInstanceManager {
23
27
  launch(clientName: string, options?: LaunchClientOptions): Promise<ClientRuntimeEntry>;
24
28
  stop(clientName: string): Promise<{
25
29
  stopped: boolean;
30
+ alreadyStopped: boolean;
26
31
  name: string;
27
32
  pid?: undefined;
28
33
  } | {
29
34
  stopped: boolean;
30
35
  name: string;
31
36
  pid: number;
37
+ alreadyStopped?: undefined;
38
+ }>;
39
+ isAlreadyRunning(clientName: string): Promise<boolean>;
40
+ reconnect(clientName: string, address: string): Promise<{
41
+ reconnected: boolean;
42
+ name: string;
43
+ address: string;
32
44
  }>;
33
45
  list(): Promise<{
34
46
  defaultClient: string | undefined;
@@ -37,10 +49,19 @@ export declare class ClientInstanceManager {
37
49
  pid?: number;
38
50
  })[];
39
51
  }>;
40
- waitReady(clientName: string, timeoutSeconds: number): Promise<{
52
+ waitReady(clientName: string, timeoutSeconds: number, options?: WaitReadyOptions): Promise<{
53
+ connected: boolean;
54
+ url: string;
55
+ inWorld?: undefined;
56
+ position?: undefined;
57
+ } | {
41
58
  connected: boolean;
42
59
  url: string;
60
+ inWorld: boolean;
61
+ position: unknown;
43
62
  }>;
63
+ private buildDiagnostics;
64
+ private formatDiagnostics;
44
65
  getClient(name?: string): Promise<ClientRuntimeEntry>;
45
66
  loadMeta(clientName: string): Promise<ClientInstanceMeta>;
46
67
  updateMeta(clientName: string, updates: Partial<ClientInstanceMeta>): Promise<ClientInstanceMeta>;
@@ -3,7 +3,7 @@ import { mkdirSync, openSync } from "node:fs";
3
3
  import { spawn } from "node:child_process";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { resolveClientInstanceDir, resolveClientsDir } from "../util/paths.js";
6
+ import { resolveMctHome, resolveClientInstanceDir, resolveClientsDir } from "../util/paths.js";
7
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";
@@ -37,10 +37,20 @@ export class ClientInstanceManager {
37
37
  return meta;
38
38
  }
39
39
  async launch(clientName, options = {}) {
40
- const state = await this.globalState.readClientState();
40
+ let state = await this.globalState.readClientState();
41
41
  const existing = state.clients[clientName];
42
42
  if (existing && isProcessRunning(existing.pid)) {
43
- throw new MctError({ code: "CLIENT_ALREADY_RUNNING", message: `Client ${clientName} is already running`, details: existing }, 3);
43
+ if (options.force) {
44
+ await this.stop(clientName);
45
+ state = await this.globalState.readClientState();
46
+ }
47
+ else {
48
+ throw new MctError({
49
+ code: "CLIENT_ALREADY_RUNNING",
50
+ message: `Client ${clientName} is already running. Pass --force to kill and relaunch.`,
51
+ details: existing
52
+ }, 3);
53
+ }
44
54
  }
45
55
  const meta = await this.loadMeta(clientName);
46
56
  const instanceDir = resolveClientInstanceDir(clientName);
@@ -64,7 +74,7 @@ export class ClientInstanceManager {
64
74
  }
65
75
  const launchCommand = [process.execPath, getLaunchScriptPath(), ...meta.launchArgs];
66
76
  const minecraftDir = path.join(instanceDir, "minecraft");
67
- const logsDir = path.join(this.globalState.getRootDir(), "logs");
77
+ const logsDir = path.join(resolveMctHome(), "logs");
68
78
  mkdirSync(logsDir, { recursive: true });
69
79
  const logPath = path.join(logsDir, `client-${clientName}.log`);
70
80
  const stdout = openSync(logPath, "a");
@@ -101,7 +111,7 @@ export class ClientInstanceManager {
101
111
  const state = await this.globalState.readClientState();
102
112
  const entry = state.clients[clientName];
103
113
  if (!entry) {
104
- return { stopped: false, name: clientName };
114
+ return { stopped: false, alreadyStopped: true, name: clientName };
105
115
  }
106
116
  if (isProcessRunning(entry.pid)) {
107
117
  killProcessTree(entry.pid);
@@ -118,6 +128,27 @@ export class ClientInstanceManager {
118
128
  await this.globalState.writeClientState(state);
119
129
  return { stopped: true, name: clientName, pid: entry.pid };
120
130
  }
131
+ async isAlreadyRunning(clientName) {
132
+ const state = await this.globalState.readClientState();
133
+ const entry = state.clients[clientName];
134
+ return Boolean(entry && isProcessRunning(entry.pid));
135
+ }
136
+ async reconnect(clientName, address) {
137
+ const state = await this.globalState.readClientState();
138
+ const entry = state.clients[clientName];
139
+ if (!entry) {
140
+ throw new MctError({ code: "CLIENT_NOT_FOUND", message: `Client ${clientName} is not running` }, 3);
141
+ }
142
+ const ws = new WebSocketClient(`ws://127.0.0.1:${entry.wsPort}`);
143
+ const response = (await ws.send("client.reconnect", { address }, 5));
144
+ if (response.error) {
145
+ throw new MctError({
146
+ code: response.error.code || "INTERNAL_ERROR",
147
+ message: response.error.message || `Reconnect failed for ${clientName}`
148
+ }, 3);
149
+ }
150
+ return { reconnected: true, name: clientName, address };
151
+ }
121
152
  async list() {
122
153
  const state = await this.globalState.readClientState();
123
154
  const runningClients = state.clients;
@@ -148,7 +179,7 @@ export class ClientInstanceManager {
148
179
  clients: instances
149
180
  };
150
181
  }
151
- async waitReady(clientName, timeoutSeconds) {
182
+ async waitReady(clientName, timeoutSeconds, options = {}) {
152
183
  const state = await this.globalState.readClientState();
153
184
  const entry = state.clients[clientName];
154
185
  if (!entry) {
@@ -156,16 +187,91 @@ export class ClientInstanceManager {
156
187
  }
157
188
  const wsUrl = `ws://127.0.0.1:${entry.wsPort}`;
158
189
  const deadline = Date.now() + timeoutSeconds * 1000;
190
+ const requireWorld = options.requireWorld ?? true;
191
+ // 阶段 A:等 WS 连通
192
+ let connected = false;
193
+ let lastConnectError;
159
194
  while (Date.now() < deadline) {
160
195
  try {
161
196
  const ws = new WebSocketClient(wsUrl);
162
- return await ws.ping(1);
197
+ await ws.ping(1);
198
+ connected = true;
199
+ break;
163
200
  }
164
- catch {
201
+ catch (err) {
202
+ lastConnectError = err instanceof Error ? err.message : String(err);
165
203
  await new Promise((resolve) => setTimeout(resolve, 500));
166
204
  }
167
205
  }
168
- throw new MctError({ code: "TIMEOUT", message: `Timed out waiting for client ${clientName} on ${wsUrl}` }, 2);
206
+ if (!connected) {
207
+ const diag = this.buildDiagnostics(entry.pid, entry.wsPort, {
208
+ wsConnected: false,
209
+ inWorld: false,
210
+ lastError: lastConnectError
211
+ });
212
+ throw new MctError({
213
+ code: "TIMEOUT",
214
+ message: `Timed out after ${timeoutSeconds}s waiting for client ${clientName} (${wsUrl}). ${this.formatDiagnostics(diag)}`,
215
+ details: diag
216
+ }, 2);
217
+ }
218
+ if (!requireWorld) {
219
+ return { connected: true, url: wsUrl };
220
+ }
221
+ // 阶段 B:轮询 position.get 直到不再 NOT_IN_WORLD
222
+ let lastErrorCode = "NOT_IN_WORLD";
223
+ while (Date.now() < deadline) {
224
+ try {
225
+ const ws = new WebSocketClient(wsUrl);
226
+ const response = (await ws.send("position.get", {}, 1));
227
+ if (!response.error) {
228
+ return { connected: true, url: wsUrl, inWorld: true, position: response.data };
229
+ }
230
+ lastErrorCode = response.error.code || lastErrorCode;
231
+ if (lastErrorCode !== "NOT_IN_WORLD") {
232
+ break;
233
+ }
234
+ }
235
+ catch (err) {
236
+ lastErrorCode = err instanceof Error ? `WS_ERROR(${err.message})` : "WS_ERROR";
237
+ }
238
+ await new Promise((resolve) => setTimeout(resolve, 500));
239
+ }
240
+ const diag = this.buildDiagnostics(entry.pid, entry.wsPort, {
241
+ wsConnected: true,
242
+ inWorld: false,
243
+ lastError: lastErrorCode
244
+ });
245
+ throw new MctError({
246
+ code: "TIMEOUT",
247
+ message: `Timed out after ${timeoutSeconds}s waiting for client ${clientName} to join a world (${wsUrl}). ${this.formatDiagnostics(diag)} Tip: try \`mct client reconnect --address <server>\` or \`mct client launch --force\`.`,
248
+ details: diag
249
+ }, 2);
250
+ }
251
+ buildDiagnostics(pid, wsPort, extras) {
252
+ const processAlive = isProcessRunning(pid);
253
+ const portListening = getListeningPids(wsPort).length > 0;
254
+ return {
255
+ pid,
256
+ processAlive,
257
+ wsPort,
258
+ portListening,
259
+ wsConnected: extras.wsConnected,
260
+ inWorld: extras.inWorld,
261
+ lastError: extras.lastError
262
+ };
263
+ }
264
+ formatDiagnostics(diag) {
265
+ const parts = [
266
+ `processAlive=${diag.processAlive}`,
267
+ `portListening=${diag.portListening}`,
268
+ `wsConnected=${diag.wsConnected}`,
269
+ `inWorld=${diag.inWorld}`
270
+ ];
271
+ if (diag.lastError) {
272
+ parts.push(`lastError=${diag.lastError}`);
273
+ }
274
+ return `Diagnostics: ${parts.join(", ")}.`;
169
275
  }
170
276
  async getClient(name) {
171
277
  const state = await this.globalState.readClientState();
@@ -25,11 +25,13 @@ export declare class ServerInstanceManager {
25
25
  stop(serverName: string): Promise<{
26
26
  running: boolean;
27
27
  stopped: boolean;
28
+ alreadyStopped: boolean;
28
29
  pid?: undefined;
29
30
  } | {
30
31
  running: boolean;
31
32
  stopped: boolean;
32
33
  pid: number;
34
+ alreadyStopped?: undefined;
33
35
  }>;
34
36
  status(serverName?: string): Promise<{
35
37
  [key: string]: unknown;
@@ -2,7 +2,7 @@ import { copyFile, mkdir, readdir, readFile, writeFile, unlink } from "node:fs/p
2
2
  import { mkdirSync, openSync } from "node:fs";
3
3
  import { spawn, execSync } from "node:child_process";
4
4
  import path from "node:path";
5
- import { resolveProjectDir, resolveServerInstanceDir } from "../util/paths.js";
5
+ import { resolveMctHome, resolveProjectDir, resolveServerInstanceDir } from "../util/paths.js";
6
6
  import { MctError } from "../util/errors.js";
7
7
  import { waitForTcpPort } from "../util/net.js";
8
8
  import { isProcessRunning, killProcessTree } from "../util/process.js";
@@ -56,8 +56,9 @@ export class ServerInstanceManager {
56
56
  if (options.eula) {
57
57
  await writeFile(path.join(instanceDir, "eula.txt"), "eula=true\n", "utf8");
58
58
  }
59
- const logsDir = path.join(this.globalState.getRootDir(), "logs");
60
- const stateDir = path.join(this.globalState.getRootDir(), "state");
59
+ const mctHome = resolveMctHome();
60
+ const logsDir = path.join(mctHome, "logs");
61
+ const stateDir = path.join(mctHome, "state");
61
62
  mkdirSync(logsDir, { recursive: true });
62
63
  mkdirSync(stateDir, { recursive: true });
63
64
  const logPath = path.join(logsDir, `server-${this.project}-${serverName}.log`);
@@ -70,11 +71,12 @@ export class ServerInstanceManager {
70
71
  }
71
72
  catch { /* ignore */ }
72
73
  execSync(`mkfifo "${stdinPipe}"`);
73
- // Use bash wrapper: hold FIFO write end open (fd 3) to prevent EOF,
74
+ // Use bash wrapper: hold FIFO open in read-write mode (fd 3 <>) to prevent EOF
75
+ // without blocking (write-only > would block until a reader opens the other end),
74
76
  // then exec java with stdin reading from the FIFO
75
77
  const child = spawn("bash", [
76
78
  "-c",
77
- 'exec 3>"$MCT_STDIN_PIPE"; exec java "$@" <"$MCT_STDIN_PIPE"',
79
+ 'exec 3<>"$MCT_STDIN_PIPE"; exec java "$@" <"$MCT_STDIN_PIPE"',
78
80
  "mct-server",
79
81
  ...jvmArgs, "-jar", jarFile, "nogui"
80
82
  ], {
@@ -107,7 +109,7 @@ export class ServerInstanceManager {
107
109
  const state = await this.globalState.readServerState();
108
110
  const entry = state.servers[stateKey];
109
111
  if (!entry) {
110
- return { running: false, stopped: false };
112
+ return { running: false, stopped: false, alreadyStopped: true };
111
113
  }
112
114
  if (isProcessRunning(entry.pid)) {
113
115
  killProcessTree(entry.pid);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
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": {