@kzheart_/mc-pilot 0.4.0 → 0.5.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.
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createEventsCommand(): Command;
@@ -0,0 +1,129 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { Command } from "commander";
4
+ import { MctError } from "../util/errors.js";
5
+ import { resolveMctHome } from "../util/paths.js";
6
+ import { wrapCommand } from "../util/command.js";
7
+ function resolveEventsFile(clientName) {
8
+ if (!clientName) {
9
+ throw new MctError({
10
+ code: "INVALID_PARAMS",
11
+ message: "Client name is required. Use --client <name> or set an active profile."
12
+ }, 4);
13
+ }
14
+ return path.join(resolveMctHome(), "logs", clientName, "events.jsonl");
15
+ }
16
+ function parseSince(raw, nowMs) {
17
+ if (!raw)
18
+ return undefined;
19
+ const trimmed = raw.trim();
20
+ // 支持 "30s" / "5m" / "1h" / "200ms" / epoch 毫秒
21
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(trimmed);
22
+ if (!m) {
23
+ throw new MctError({ code: "INVALID_PARAMS", message: `Invalid --since value: ${raw}` }, 4);
24
+ }
25
+ const value = Number(m[1]);
26
+ const unit = m[2];
27
+ if (!unit) {
28
+ // epoch millis
29
+ return value;
30
+ }
31
+ const multipliers = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 };
32
+ return nowMs - value * multipliers[unit];
33
+ }
34
+ function readAllEvents(filePath) {
35
+ if (!existsSync(filePath))
36
+ return [];
37
+ const raw = readFileSync(filePath, "utf8");
38
+ const lines = raw.split("\n").filter((line) => line.length > 0);
39
+ const out = [];
40
+ for (const line of lines) {
41
+ try {
42
+ out.push(JSON.parse(line));
43
+ }
44
+ catch {
45
+ // 跳过损坏行
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ export function createEventsCommand() {
51
+ const command = new Command("events").description("Inspect the client event log (written by the mod to ~/.mct/logs/<client>/events.jsonl)");
52
+ command
53
+ .command("list")
54
+ .description("Print events. Defaults to the last 20 for the active client.")
55
+ .option("--tail <n>", "Show the last N events (default 20)", (v) => Number(v))
56
+ .option("--since <duration>", "Only show events since duration ago (e.g. 30s, 5m, 1h) or epoch ms")
57
+ .option("--type <types>", "Comma-separated list of event types to include")
58
+ .option("--all", "Show all events (ignore --tail)")
59
+ .option("--file <path>", "Override the log file path")
60
+ .action(wrapCommand(async (context, { options, globalOptions }) => {
61
+ const clientName = globalOptions.client ?? context.activeProfile?.clients[0];
62
+ const filePath = options.file ?? resolveEventsFile(clientName);
63
+ const events = readAllEvents(filePath);
64
+ let filtered = events;
65
+ const sinceMs = parseSince(options.since, Date.now());
66
+ if (sinceMs !== undefined) {
67
+ filtered = filtered.filter((e) => e.t >= sinceMs);
68
+ }
69
+ if (options.type) {
70
+ const wanted = new Set(options.type.split(",").map((s) => s.trim()).filter(Boolean));
71
+ filtered = filtered.filter((e) => wanted.has(e.type));
72
+ }
73
+ if (!options.all) {
74
+ const tail = options.tail ?? 20;
75
+ if (filtered.length > tail) {
76
+ filtered = filtered.slice(filtered.length - tail);
77
+ }
78
+ }
79
+ return {
80
+ file: filePath,
81
+ total: events.length,
82
+ returned: filtered.length,
83
+ events: filtered
84
+ };
85
+ }));
86
+ command
87
+ .command("tail")
88
+ .description("Shortcut for `events list --tail N`")
89
+ .argument("[n]", "Number of events (default 20)")
90
+ .option("--type <types>", "Comma-separated event types to include")
91
+ .option("--file <path>", "Override the log file path")
92
+ .action(wrapCommand(async (context, { args, options, globalOptions }) => {
93
+ const tail = args[0] ? Number(args[0]) : 20;
94
+ const clientName = globalOptions.client ?? context.activeProfile?.clients[0];
95
+ const filePath = options.file ?? resolveEventsFile(clientName);
96
+ let events = readAllEvents(filePath);
97
+ if (options.type) {
98
+ const wanted = new Set(options.type.split(",").map((s) => s.trim()).filter(Boolean));
99
+ events = events.filter((e) => wanted.has(e.type));
100
+ }
101
+ if (events.length > tail)
102
+ events = events.slice(events.length - tail);
103
+ return { file: filePath, returned: events.length, events };
104
+ }));
105
+ command
106
+ .command("clear")
107
+ .description("Truncate the event log for the active client")
108
+ .option("--file <path>", "Override the log file path")
109
+ .action(wrapCommand(async (context, { options, globalOptions }) => {
110
+ const { truncateSync, existsSync: exists, mkdirSync } = await import("node:fs");
111
+ const clientName = globalOptions.client ?? context.activeProfile?.clients[0];
112
+ const filePath = options.file ?? resolveEventsFile(clientName);
113
+ if (exists(filePath)) {
114
+ truncateSync(filePath, 0);
115
+ return { cleared: true, file: filePath };
116
+ }
117
+ // ensure directory exists for future writes
118
+ mkdirSync(path.dirname(filePath), { recursive: true });
119
+ return { cleared: false, reason: "file_not_found", file: filePath };
120
+ }));
121
+ command
122
+ .command("path")
123
+ .description("Print the expected path to the event log for the active client")
124
+ .action(wrapCommand(async (context, { globalOptions }) => {
125
+ const clientName = globalOptions.client ?? context.activeProfile?.clients[0];
126
+ return { file: resolveEventsFile(clientName), exists: existsSync(resolveEventsFile(clientName)) };
127
+ }));
128
+ return command;
129
+ }
package/dist/index.js CHANGED
@@ -9,6 +9,7 @@ import { createChatCommand } from "./commands/chat.js";
9
9
  import { createCombatCommand } from "./commands/combat.js";
10
10
  import { createAnvilCommand, createCraftCommand, createEnchantCommand, createTradeCommand } from "./commands/craft.js";
11
11
  import { createEntityCommand } from "./commands/entity.js";
12
+ import { createEventsCommand } from "./commands/events.js";
12
13
  import { createGuiCommand } from "./commands/gui.js";
13
14
  import { createHudCommand } from "./commands/hud.js";
14
15
  import { createInputCommand } from "./commands/input.js";
@@ -90,6 +91,7 @@ export function buildProgram() {
90
91
  program.addCommand(createEnchantCommand());
91
92
  program.addCommand(createTradeCommand());
92
93
  program.addCommand(createWaitCommand());
94
+ program.addCommand(createEventsCommand());
93
95
  return program;
94
96
  }
95
97
  const program = buildProgram();
@@ -60,6 +60,8 @@ export declare class ClientInstanceManager {
60
60
  inWorld: boolean;
61
61
  position: unknown;
62
62
  }>;
63
+ private buildDiagnostics;
64
+ private formatDiagnostics;
63
65
  getClient(name?: string): Promise<ClientRuntimeEntry>;
64
66
  loadMeta(clientName: string): Promise<ClientInstanceMeta>;
65
67
  updateMeta(clientName: string, updates: Partial<ClientInstanceMeta>): Promise<ClientInstanceMeta>;
@@ -190,6 +190,7 @@ export class ClientInstanceManager {
190
190
  const requireWorld = options.requireWorld ?? true;
191
191
  // 阶段 A:等 WS 连通
192
192
  let connected = false;
193
+ let lastConnectError;
193
194
  while (Date.now() < deadline) {
194
195
  try {
195
196
  const ws = new WebSocketClient(wsUrl);
@@ -197,12 +198,22 @@ export class ClientInstanceManager {
197
198
  connected = true;
198
199
  break;
199
200
  }
200
- catch {
201
+ catch (err) {
202
+ lastConnectError = err instanceof Error ? err.message : String(err);
201
203
  await new Promise((resolve) => setTimeout(resolve, 500));
202
204
  }
203
205
  }
204
206
  if (!connected) {
205
- throw new MctError({ code: "TIMEOUT", message: `Timed out connecting to client ${clientName} on ${wsUrl}` }, 2);
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);
206
217
  }
207
218
  if (!requireWorld) {
208
219
  return { connected: true, url: wsUrl };
@@ -221,16 +232,47 @@ export class ClientInstanceManager {
221
232
  break;
222
233
  }
223
234
  }
224
- catch {
225
- // 连接瞬断等偶发错误,继续轮询
235
+ catch (err) {
236
+ lastErrorCode = err instanceof Error ? `WS_ERROR(${err.message})` : "WS_ERROR";
226
237
  }
227
238
  await new Promise((resolve) => setTimeout(resolve, 500));
228
239
  }
240
+ const diag = this.buildDiagnostics(entry.pid, entry.wsPort, {
241
+ wsConnected: true,
242
+ inWorld: false,
243
+ lastError: lastErrorCode
244
+ });
229
245
  throw new MctError({
230
246
  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\`.`
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
232
249
  }, 2);
233
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(", ")}.`;
275
+ }
234
276
  async getClient(name) {
235
277
  const state = await this.globalState.readClientState();
236
278
  const resolvedName = name ?? state.defaultClient;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {