@kzheart_/mc-pilot 0.3.3 → 0.4.0

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.
@@ -10,7 +10,7 @@
10
10
  "support": "ready",
11
11
  "validation": "verified",
12
12
  "modVersion": "0.1.1",
13
- "fabricLoaderVersion": "0.16.10",
13
+ "fabricLoaderVersion": "0.16.14",
14
14
  "yarnMappings": "1.18.2+build.4",
15
15
  "javaVersion": 17,
16
16
  "gradleModule": "version-1.18.2"
@@ -22,7 +22,7 @@
22
22
  "support": "ready",
23
23
  "validation": "verified",
24
24
  "modVersion": "0.1.1",
25
- "fabricLoaderVersion": "0.16.10",
25
+ "fabricLoaderVersion": "0.16.14",
26
26
  "yarnMappings": "1.20.1+build.10",
27
27
  "javaVersion": 17,
28
28
  "gradleModule": "version-1.20.1"
@@ -44,7 +44,7 @@
44
44
  "support": "ready",
45
45
  "validation": "verified",
46
46
  "modVersion": "0.1.1",
47
- "fabricLoaderVersion": "0.16.10",
47
+ "fabricLoaderVersion": "0.16.14",
48
48
  "yarnMappings": "1.20.2+build.4",
49
49
  "javaVersion": 17,
50
50
  "gradleModule": "version-1.20.2"
@@ -66,7 +66,7 @@
66
66
  "support": "ready",
67
67
  "validation": "verified",
68
68
  "modVersion": "0.1.1",
69
- "fabricLoaderVersion": "0.16.10",
69
+ "fabricLoaderVersion": "0.16.14",
70
70
  "yarnMappings": "1.20.4+build.3",
71
71
  "javaVersion": 17,
72
72
  "gradleModule": "version-1.20.4"
@@ -98,7 +98,7 @@
98
98
  "support": "ready",
99
99
  "validation": "verified",
100
100
  "modVersion": "0.1.1",
101
- "fabricLoaderVersion": "0.16.10",
101
+ "fabricLoaderVersion": "0.16.14",
102
102
  "yarnMappings": "1.21.1+build.3",
103
103
  "javaVersion": 21,
104
104
  "gradleModule": "version-1.21.1"
@@ -120,7 +120,7 @@
120
120
  "support": "ready",
121
121
  "validation": "verified",
122
122
  "modVersion": "0.1.1",
123
- "fabricLoaderVersion": "0.16.10",
123
+ "fabricLoaderVersion": "0.16.14",
124
124
  "yarnMappings": "1.21.4+build.1",
125
125
  "javaVersion": 21,
126
126
  "gradleModule": "version-1.21.4"
@@ -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
  }
@@ -10,7 +10,7 @@ const VERSION_MATRIX = [
10
10
  spigot: { supported: true, requiresBuildTools: true }
11
11
  },
12
12
  clients: {
13
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1", validation: "verified" },
13
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
14
14
  forge: { supported: false, notes: "不支持此版本" },
15
15
  neoforge: { supported: true, loaderVersion: "21.4.x", modVersion: "0.1.1" }
16
16
  }
@@ -25,7 +25,7 @@ const VERSION_MATRIX = [
25
25
  spigot: { supported: true, requiresBuildTools: true }
26
26
  },
27
27
  clients: {
28
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1", validation: "verified" },
28
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
29
29
  forge: { supported: false, notes: "不支持此版本" },
30
30
  neoforge: { supported: true, loaderVersion: "21.1.x", modVersion: "0.1.1" }
31
31
  }
@@ -40,7 +40,7 @@ const VERSION_MATRIX = [
40
40
  spigot: { supported: true, requiresBuildTools: true }
41
41
  },
42
42
  clients: {
43
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1" },
43
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1" },
44
44
  forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.1.1" },
45
45
  neoforge: { supported: false, notes: "不支持此版本" }
46
46
  }
@@ -55,7 +55,7 @@ const VERSION_MATRIX = [
55
55
  spigot: { supported: true, requiresBuildTools: true }
56
56
  },
57
57
  clients: {
58
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1", validation: "verified" },
58
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
59
59
  forge: { supported: false, notes: "当前未接入此 loader" },
60
60
  neoforge: { supported: false, notes: "不支持此版本" }
61
61
  }
@@ -70,7 +70,7 @@ const VERSION_MATRIX = [
70
70
  spigot: { supported: true, requiresBuildTools: true }
71
71
  },
72
72
  clients: {
73
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1" },
73
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1" },
74
74
  forge: { supported: false, notes: "当前未接入此 loader" },
75
75
  neoforge: { supported: false, notes: "不支持此版本" }
76
76
  }
@@ -85,7 +85,7 @@ const VERSION_MATRIX = [
85
85
  spigot: { supported: true, requiresBuildTools: true }
86
86
  },
87
87
  clients: {
88
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1" },
88
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1" },
89
89
  forge: { supported: true, loaderVersion: "47.x", modVersion: "0.1.1" },
90
90
  neoforge: { supported: false, notes: "不支持此版本" }
91
91
  }
@@ -100,7 +100,7 @@ const VERSION_MATRIX = [
100
100
  spigot: { supported: true, requiresBuildTools: true }
101
101
  },
102
102
  clients: {
103
- fabric: { supported: true, loaderVersion: "0.16.10", modVersion: "0.1.1", validation: "verified" },
103
+ fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.1.1", validation: "verified" },
104
104
  forge: { supported: true, loaderVersion: "40.x", modVersion: "0.1.1" },
105
105
  neoforge: { supported: false, notes: "不支持此版本" }
106
106
  }
@@ -127,7 +127,7 @@ function buildLaunchArgs(runtimePaths, variant) {
127
127
  "--minecraft-version",
128
128
  variant.minecraftVersion,
129
129
  "--fabric-loader-version",
130
- variant.fabricLoaderVersion ?? "0.16.10"
130
+ variant.fabricLoaderVersion ?? "0.16.14"
131
131
  ];
132
132
  }
133
133
  function buildManagedLaunchArgs(runtimeRootDir, versionId, gameDir) {
@@ -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,9 +49,16 @@ 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
  }>;
44
63
  getClient(name?: string): Promise<ClientRuntimeEntry>;
45
64
  loadMeta(clientName: string): 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,49 @@ 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;
159
193
  while (Date.now() < deadline) {
160
194
  try {
161
195
  const ws = new WebSocketClient(wsUrl);
162
- return await ws.ping(1);
196
+ await ws.ping(1);
197
+ connected = true;
198
+ break;
163
199
  }
164
200
  catch {
165
201
  await new Promise((resolve) => setTimeout(resolve, 500));
166
202
  }
167
203
  }
168
- throw new MctError({ code: "TIMEOUT", message: `Timed out waiting for client ${clientName} on ${wsUrl}` }, 2);
204
+ if (!connected) {
205
+ throw new MctError({ code: "TIMEOUT", message: `Timed out connecting to client ${clientName} on ${wsUrl}` }, 2);
206
+ }
207
+ if (!requireWorld) {
208
+ return { connected: true, url: wsUrl };
209
+ }
210
+ // 阶段 B:轮询 position.get 直到不再 NOT_IN_WORLD
211
+ let lastErrorCode = "NOT_IN_WORLD";
212
+ while (Date.now() < deadline) {
213
+ try {
214
+ const ws = new WebSocketClient(wsUrl);
215
+ const response = (await ws.send("position.get", {}, 1));
216
+ if (!response.error) {
217
+ return { connected: true, url: wsUrl, inWorld: true, position: response.data };
218
+ }
219
+ lastErrorCode = response.error.code || lastErrorCode;
220
+ if (lastErrorCode !== "NOT_IN_WORLD") {
221
+ break;
222
+ }
223
+ }
224
+ catch {
225
+ // 连接瞬断等偶发错误,继续轮询
226
+ }
227
+ await new Promise((resolve) => setTimeout(resolve, 500));
228
+ }
229
+ throw new MctError({
230
+ code: "TIMEOUT",
231
+ message: `Client ${clientName} connected (${wsUrl}) but did not join a world (last status: ${lastErrorCode}). Tip: try \`mct client reconnect --address <server>\` or \`mct client launch --force\`.`
232
+ }, 2);
169
233
  }
170
234
  async getClient(name) {
171
235
  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.3",
3
+ "version": "0.4.0",
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": {
@@ -198,7 +198,7 @@ async function buildLaunchSpec(options) {
198
198
  const minecraftVersion = process.env.MCT_CLIENT_VERSION || options["minecraft-version"] || defaultVariant.minecraftVersion;
199
199
  const modVariantId = process.env.MCT_CLIENT_MOD_VARIANT || options["mod-variant"] || `${minecraftVersion}-fabric`;
200
200
  const selectedVariant = getVariantById(modVariantId) ?? defaultVariant;
201
- const fabricLoaderVersion = options["fabric-loader-version"] || selectedVariant.fabricLoaderVersion || "0.16.10";
201
+ const fabricLoaderVersion = options["fabric-loader-version"] || selectedVariant.fabricLoaderVersion || "0.16.14";
202
202
  const instanceRoot = options["instance-dir"];
203
203
  const metaRoot = options["meta-dir"];
204
204
  const librariesRoot = options["libraries-dir"];