@kzheart_/mc-pilot 0.1.1 → 0.1.2

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,6 +1,7 @@
1
1
  import { access, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { DEFAULT_WS_PORT_BASE, loadConfig, writeConfig } from "../../util/config.js";
5
6
  import { MctError } from "../../util/errors.js";
6
7
  import { CacheManager } from "../CacheManager.js";
@@ -8,6 +9,11 @@ import { copyFileIfMissing, downloadFile } from "../DownloadUtils.js";
8
9
  import { detectJava } from "../JavaDetector.js";
9
10
  import { findVariantByVersionAndLoader, getDefaultVariant, getModArtifactFileName, loadModVariantCatalog } from "../ModVariantCatalog.js";
10
11
  import { prepareManagedFabricRuntime } from "./FabricRuntimeDownloader.js";
12
+ function getLaunchScriptPath() {
13
+ const thisFile = fileURLToPath(import.meta.url);
14
+ // dist/download/client/ClientDownloader.js -> scripts/launch-fabric-client.mjs
15
+ return path.resolve(path.dirname(thisFile), "..", "..", "..", "scripts", "launch-fabric-client.mjs");
16
+ }
11
17
  const GITHUB_RELEASE_BASE_URL = process.env.MCT_MOD_DOWNLOAD_BASE_URL || "https://github.com/kzheart/mc-pilot/releases/download";
12
18
  function ensureSupportedVariant(variant) {
13
19
  if (variant.loader !== "fabric") {
@@ -111,7 +117,7 @@ function resolveLaunchRuntimePaths(context, options) {
111
117
  function buildLaunchCommand(context, runtimePaths, variant) {
112
118
  return [
113
119
  process.execPath,
114
- path.join(context.cwd, "scripts", "launch-fabric-client.mjs"),
120
+ getLaunchScriptPath(),
115
121
  "--instance-dir",
116
122
  runtimePaths.instanceDir,
117
123
  "--meta-dir",
@@ -130,7 +136,7 @@ function buildLaunchCommand(context, runtimePaths, variant) {
130
136
  function buildManagedLaunchCommand(context, runtimeRootDir, versionId, gameDir) {
131
137
  return [
132
138
  process.execPath,
133
- path.join(context.cwd, "scripts", "launch-fabric-client.mjs"),
139
+ getLaunchScriptPath(),
134
140
  "--runtime-root",
135
141
  runtimeRootDir,
136
142
  "--version-id",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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": {
@@ -10,6 +10,7 @@
10
10
  "bin/",
11
11
  "dist/",
12
12
  "data/",
13
+ "scripts/",
13
14
  "!dist/**/*.test.js",
14
15
  "!dist/**/*.test.d.ts",
15
16
  "!dist/**/*.e2e.test.js",
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from "node:crypto";
4
+ import { access, copyFile, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
5
+ import { readFileSync } from "node:fs";
6
+ import { execFileSync } from "node:child_process";
7
+ import path from "node:path";
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import { createQuickPlayMultiplayer, launch as launchMinecraft } from "@xmcl/core";
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+ const VARIANTS_PATH = path.join(__dirname, "..", "data", "variants.json");
15
+
16
+ function readCatalog() {
17
+ return JSON.parse(readFileSync(VARIANTS_PATH, "utf8"));
18
+ }
19
+
20
+ function getDefaultVariant() {
21
+ const catalog = readCatalog();
22
+ return catalog.variants.find((variant) => variant.id === catalog.defaultVariant);
23
+ }
24
+
25
+ function getVariantById(variantId) {
26
+ const catalog = readCatalog();
27
+ return catalog.variants.find((variant) => variant.id === variantId);
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ const parsed = {};
32
+
33
+ for (let index = 0; index < argv.length; index += 1) {
34
+ const entry = argv[index];
35
+ if (!entry.startsWith("--")) {
36
+ continue;
37
+ }
38
+
39
+ const key = entry.slice(2);
40
+ const value = argv[index + 1];
41
+ if (!value || value.startsWith("--")) {
42
+ parsed[key] = "true";
43
+ continue;
44
+ }
45
+
46
+ parsed[key] = value;
47
+ index += 1;
48
+ }
49
+
50
+ return parsed;
51
+ }
52
+
53
+ function offlineUuid(username) {
54
+ const source = Buffer.from(`OfflinePlayer:${username}`, "utf8");
55
+ const digest = createHash("md5").update(source).digest();
56
+ digest[6] = (digest[6] & 0x0f) | 0x30;
57
+ digest[8] = (digest[8] & 0x3f) | 0x80;
58
+ const hex = digest.toString("hex");
59
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
60
+ }
61
+
62
+ function mavenPath(coordinate) {
63
+ const parts = coordinate.split(":");
64
+ if (parts.length < 3 || parts.length > 4) {
65
+ throw new Error(`Unsupported Maven coordinate: ${coordinate}`);
66
+ }
67
+
68
+ const [group, artifact, version, classifier] = parts;
69
+ const baseName = `${artifact}-${version}${classifier ? `-${classifier}` : ""}.jar`;
70
+ return path.join(...group.split("."), artifact, version, baseName);
71
+ }
72
+
73
+ function isRuleMatch(ruleOs = {}) {
74
+ const current = {
75
+ name: "osx-arm64",
76
+ arch: "arm64"
77
+ };
78
+
79
+ if (ruleOs.name && ruleOs.name !== "osx" && ruleOs.name !== current.name) {
80
+ return false;
81
+ }
82
+
83
+ if (ruleOs.arch && ruleOs.arch !== current.arch) {
84
+ return false;
85
+ }
86
+
87
+ return true;
88
+ }
89
+
90
+ function isAllowedByRules(rules) {
91
+ if (!rules || rules.length === 0) {
92
+ return true;
93
+ }
94
+
95
+ let allowed = false;
96
+ for (const rule of rules) {
97
+ if (!rule.os || isRuleMatch(rule.os)) {
98
+ allowed = rule.action === "allow";
99
+ }
100
+ }
101
+
102
+ return allowed;
103
+ }
104
+
105
+ function substitute(template, variables) {
106
+ return template.replace(/\$\{([^}]+)\}/g, (_, key) => variables[key] ?? "");
107
+ }
108
+
109
+ async function ensureFile(filePath, downloadUrl) {
110
+ try {
111
+ await access(filePath);
112
+ return;
113
+ } catch {
114
+ await mkdir(path.dirname(filePath), { recursive: true });
115
+ const response = await fetch(downloadUrl);
116
+ if (!response.ok) {
117
+ throw new Error(`Failed to download ${downloadUrl}: ${response.status}`);
118
+ }
119
+
120
+ const buffer = Buffer.from(await response.arrayBuffer());
121
+ await writeFile(filePath, buffer);
122
+ }
123
+ }
124
+
125
+ async function readJson(filePath) {
126
+ return JSON.parse(await readFile(filePath, "utf8"));
127
+ }
128
+
129
+ async function syncBuiltMod(instanceRoot, repoRoot, variantId) {
130
+ const artifactName = `mct-client-mod-${variantId.endsWith("-fabric") ? "fabric" : variantId.split("-").pop()}-${variantId.replace(/-fabric$|-forge$|-neoforge$/, "")}.jar`;
131
+ const sourceJar = path.join(repoRoot, "client-mod", "versions", variantId, "build", "libs", artifactName);
132
+ const targetDir = path.join(instanceRoot, "minecraft", "mods");
133
+ const targetJar = path.join(targetDir, artifactName);
134
+
135
+ try {
136
+ await access(sourceJar);
137
+ } catch {
138
+ return;
139
+ }
140
+
141
+ await mkdir(targetDir, { recursive: true });
142
+ await copyFile(sourceJar, targetJar);
143
+ }
144
+
145
+ async function syncConfiguredMod(gameDir) {
146
+ const configuredJar = process.env.MCT_CLIENT_MOD_JAR;
147
+ if (!configuredJar) {
148
+ return;
149
+ }
150
+
151
+ const sourceJar = path.isAbsolute(configuredJar) ? configuredJar : path.resolve(process.cwd(), configuredJar);
152
+ const targetDir = path.join(gameDir, "mods");
153
+ const targetJar = path.join(targetDir, path.basename(sourceJar));
154
+
155
+ try {
156
+ await access(sourceJar);
157
+ } catch {
158
+ return;
159
+ }
160
+
161
+ await mkdir(targetDir, { recursive: true });
162
+ await copyFile(sourceJar, targetJar);
163
+ }
164
+
165
+ async function ensureAutomationOptions(gameDir, server) {
166
+ const optionsPath = path.join(gameDir, "options.txt");
167
+ const values = new Map();
168
+
169
+ try {
170
+ const content = await readFile(optionsPath, "utf8");
171
+ for (const line of content.split(/\r?\n/)) {
172
+ const separator = line.indexOf(":");
173
+ if (separator < 0) {
174
+ continue;
175
+ }
176
+ values.set(line.slice(0, separator), line.slice(separator + 1));
177
+ }
178
+ } catch {}
179
+
180
+ values.set("onboardAccessibility", "false");
181
+ values.set("skipMultiplayerWarning", "true");
182
+ values.set("skipRealms32bitWarning", "true");
183
+ values.set("joinedFirstServer", "true");
184
+ values.set("tutorialStep", "none");
185
+ values.set("pauseOnLostFocus", "false");
186
+ if (server) {
187
+ values.set("lastServer", server);
188
+ }
189
+
190
+ const lines = [...values.entries()].map(([key, value]) => `${key}:${value}`);
191
+ await mkdir(gameDir, { recursive: true });
192
+ await writeFile(optionsPath, `${lines.join("\n")}\n`, "utf8");
193
+ }
194
+
195
+ async function buildLaunchSpec(options) {
196
+ const repoRoot = process.cwd();
197
+ const defaultVariant = getDefaultVariant();
198
+ const minecraftVersion = process.env.MCT_CLIENT_VERSION || options["minecraft-version"] || defaultVariant.minecraftVersion;
199
+ const modVariantId = process.env.MCT_CLIENT_MOD_VARIANT || options["mod-variant"] || `${minecraftVersion}-fabric`;
200
+ const selectedVariant = getVariantById(modVariantId) ?? defaultVariant;
201
+ const fabricLoaderVersion = options["fabric-loader-version"] || selectedVariant.fabricLoaderVersion || "0.16.10";
202
+ const instanceRoot = options["instance-dir"];
203
+ const metaRoot = options["meta-dir"];
204
+ const librariesRoot = options["libraries-dir"];
205
+ const assetsRoot = options["assets-dir"];
206
+ const nativesDir = options["natives-dir"] || path.join(instanceRoot, "natives");
207
+ const gameDir = path.join(instanceRoot, "minecraft");
208
+ const packMeta = await readJson(path.join(instanceRoot, "mmc-pack.json"));
209
+ await syncBuiltMod(instanceRoot, repoRoot, modVariantId);
210
+ const componentMetas = new Map();
211
+ for (const component of packMeta.components) {
212
+ const componentMetaPath = path.join(metaRoot, component.uid, `${component.version}.json`);
213
+ componentMetas.set(component.uid, await readJson(componentMetaPath));
214
+ }
215
+
216
+ const vanillaMeta = componentMetas.get("net.minecraft");
217
+ const fabricMeta = componentMetas.get("net.fabricmc.fabric-loader");
218
+ const intermediaryMeta = componentMetas.get("net.fabricmc.intermediary");
219
+
220
+ const libraries = [];
221
+ for (const component of packMeta.components) {
222
+ const meta = componentMetas.get(component.uid);
223
+ for (const library of meta.libraries ?? []) {
224
+ if (!isAllowedByRules(library.rules)) {
225
+ continue;
226
+ }
227
+
228
+ libraries.push({
229
+ coordinate: library.name,
230
+ path: library.downloads?.artifact?.path ?? mavenPath(library.name),
231
+ url:
232
+ library.downloads?.artifact?.url ??
233
+ `${library.url.replace(/\/$/, "")}/${mavenPath(library.name)}`
234
+ });
235
+ }
236
+ }
237
+
238
+ const mainJarPath =
239
+ vanillaMeta.mainJar?.downloads?.artifact?.path ??
240
+ mavenPath(vanillaMeta.mainJar?.name ?? `com.mojang:minecraft:${minecraftVersion}:client`);
241
+ const mainJarUrl = vanillaMeta.mainJar?.downloads?.artifact?.url;
242
+
243
+ for (const entry of libraries) {
244
+ if (entry.url) {
245
+ await ensureFile(path.join(librariesRoot, entry.path), entry.url);
246
+ }
247
+ }
248
+
249
+ if (mainJarUrl) {
250
+ await ensureFile(path.join(librariesRoot, mainJarPath), mainJarUrl);
251
+ }
252
+
253
+ await mkdir(nativesDir, { recursive: true });
254
+
255
+ const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
256
+ const accountUuid = offlineUuid(accountName);
257
+ const server = process.env.MCT_CLIENT_SERVER || "";
258
+ await ensureAutomationOptions(gameDir, server);
259
+ const [serverHost, serverPort = "25565"] = server.split(":");
260
+ const classpath = [
261
+ path.join(librariesRoot, mainJarPath),
262
+ ...libraries.map((entry) => path.join(librariesRoot, entry.path))
263
+ ].join(path.delimiter);
264
+ const substitutions = {
265
+ auth_player_name: accountName,
266
+ version_name: `fabric-loader-${fabricLoaderVersion}-${minecraftVersion}`,
267
+ game_directory: gameDir,
268
+ assets_root: assetsRoot,
269
+ assets_index_name: vanillaMeta.assetIndex.id,
270
+ auth_uuid: accountUuid,
271
+ auth_access_token: "0",
272
+ user_type: "legacy",
273
+ version_type: "release"
274
+ };
275
+ const gameArgs = vanillaMeta.minecraftArguments
276
+ .split(" ")
277
+ .filter(Boolean)
278
+ .map((entry) => substitute(entry, substitutions));
279
+
280
+ if (serverHost) {
281
+ gameArgs.push("--quickPlayMultiplayer", `${serverHost}:${serverPort}`);
282
+ }
283
+
284
+ return {
285
+ cwd: gameDir,
286
+ classpathEntries: [path.join(librariesRoot, mainJarPath), ...libraries.map((entry) => path.join(librariesRoot, entry.path))],
287
+ classpath,
288
+ gameArgs,
289
+ mainClass: fabricMeta.mainClass,
290
+ nativesDir,
291
+ javaBin: options.java || process.env.MCT_CLIENT_JAVA || "java",
292
+ javaArgs: [
293
+ "-XstartOnFirstThread",
294
+ "-Xms512m",
295
+ `-Xmx${options["max-mem"] || "1024m"}`,
296
+ "-Duser.language=en",
297
+ `-Djava.library.path=${nativesDir}`,
298
+ "-DFabricMcEmu=net.minecraft.client.main.Main"
299
+ ]
300
+ };
301
+ }
302
+
303
+ async function buildManifestLaunchSpec(options) {
304
+ const manifestPath = options.manifest;
305
+ if (!manifestPath) {
306
+ throw new Error("Missing required argument: --manifest");
307
+ }
308
+
309
+ const manifest = await readJson(manifestPath);
310
+ const gameDir = manifest.gameDir;
311
+ await syncConfiguredMod(gameDir);
312
+
313
+ const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
314
+ const accountUuid = offlineUuid(accountName);
315
+ const server = process.env.MCT_CLIENT_SERVER || "";
316
+ await ensureAutomationOptions(gameDir, server);
317
+ const [serverHost, serverPort = "25565"] = server.split(":");
318
+ const substitutions = {
319
+ auth_player_name: accountName,
320
+ version_name: `fabric-loader-${manifest.fabricLoaderVersion}-${manifest.minecraftVersion}`,
321
+ game_directory: gameDir,
322
+ assets_root: manifest.assetsDir,
323
+ assets_index_name: manifest.assetsIndexId,
324
+ auth_uuid: accountUuid,
325
+ auth_access_token: "0",
326
+ user_type: "legacy",
327
+ version_type: "release",
328
+ natives_directory: path.join(manifest.runtimeRootDir ?? path.dirname(manifestPath), "natives")
329
+ };
330
+ const gameArgs = manifest.gameArgs.map((entry) => substitute(entry, substitutions));
331
+ if (serverHost && !gameArgs.includes("--quickPlayMultiplayer")) {
332
+ gameArgs.push("--quickPlayMultiplayer", `${serverHost}:${serverPort}`);
333
+ }
334
+
335
+ const javaArgs = manifest.javaArgs
336
+ .map((entry) => substitute(entry, substitutions))
337
+ .filter((entry) => !entry.includes("${"));
338
+
339
+ return {
340
+ cwd: gameDir,
341
+ classpathEntries: manifest.classpathEntries,
342
+ classpath: manifest.classpathEntries.join(path.delimiter),
343
+ gameArgs,
344
+ mainClass: manifest.mainClass,
345
+ javaBin: options.java || process.env.MCT_CLIENT_JAVA || "java",
346
+ javaArgs
347
+ };
348
+ }
349
+
350
+ function parseMaxMemory(value) {
351
+ if (!value) {
352
+ return 1024;
353
+ }
354
+
355
+ const normalized = String(value).trim().toLowerCase();
356
+ if (normalized.endsWith("g")) {
357
+ return Number.parseInt(normalized.slice(0, -1), 10) * 1024;
358
+ }
359
+ if (normalized.endsWith("m")) {
360
+ return Number.parseInt(normalized.slice(0, -1), 10);
361
+ }
362
+ return Number.parseInt(normalized, 10);
363
+ }
364
+
365
+ async function launchXmclManagedClient(options) {
366
+ const runtimeRoot = options["runtime-root"];
367
+ const versionId = options["version-id"];
368
+ const gameDir = options["game-dir"];
369
+
370
+ if (!runtimeRoot || !versionId || !gameDir) {
371
+ throw new Error("Missing required arguments: --runtime-root, --version-id and --game-dir");
372
+ }
373
+
374
+ await syncConfiguredMod(gameDir);
375
+
376
+ const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
377
+ const accountUuid = offlineUuid(accountName).replaceAll("-", "");
378
+ const server = process.env.MCT_CLIENT_SERVER || "";
379
+ await ensureAutomationOptions(gameDir, server);
380
+ const [serverHost, serverPort = "25565"] = server.split(":");
381
+
382
+ return launchMinecraft({
383
+ gamePath: gameDir,
384
+ resourcePath: runtimeRoot,
385
+ javaPath: options.java || process.env.MCT_CLIENT_JAVA || "java",
386
+ minMemory: 512,
387
+ maxMemory: parseMaxMemory(options["max-mem"]),
388
+ version: versionId,
389
+ gameProfile: {
390
+ name: accountName,
391
+ id: accountUuid
392
+ },
393
+ accessToken: "0",
394
+ userType: "legacy",
395
+ launcherName: "mct",
396
+ launcherBrand: "mct",
397
+ ...(serverHost
398
+ ? {
399
+ quickPlayMultiplayer: createQuickPlayMultiplayer(serverHost, Number.parseInt(serverPort, 10)),
400
+ // Legacy fallback for MC < 1.19.1 that ignores --quickPlayMultiplayer.
401
+ // Don't pass both to 1.19.1+ to avoid "Attempt to connect while already connecting".
402
+ extraMCArgs: versionId.startsWith("1.18.") || versionId.startsWith("1.12.")
403
+ ? ["--server", serverHost, "--port", serverPort]
404
+ : []
405
+ }
406
+ : {}),
407
+ extraExecOption: {
408
+ cwd: gameDir,
409
+ env: {
410
+ ...process.env
411
+ },
412
+ stdio: "inherit"
413
+ }
414
+ });
415
+ }
416
+
417
+ async function main() {
418
+ const options = parseArgs(process.argv.slice(2));
419
+ if (options["runtime-root"]) {
420
+ const child = await launchXmclManagedClient(options);
421
+
422
+ const forwardSignal = (signal) => {
423
+ if (!child.killed) {
424
+ child.kill(signal);
425
+ }
426
+ };
427
+
428
+ process.on("SIGINT", forwardSignal);
429
+ process.on("SIGTERM", forwardSignal);
430
+
431
+ child.on("exit", (code, signal) => {
432
+ if (signal) {
433
+ process.kill(process.pid, signal);
434
+ return;
435
+ }
436
+ process.exit(code ?? 0);
437
+ });
438
+ return;
439
+ }
440
+
441
+ const launch = options.manifest
442
+ ? await buildManifestLaunchSpec(options)
443
+ : await (async () => {
444
+ const instanceDir = options["instance-dir"];
445
+ const metaDir = options["meta-dir"];
446
+ const librariesDir = options["libraries-dir"];
447
+ const assetsDir = options["assets-dir"];
448
+ if (!instanceDir || !metaDir || !librariesDir || !assetsDir) {
449
+ throw new Error("Missing required arguments: --manifest or --instance-dir, --meta-dir, --libraries-dir and --assets-dir");
450
+ }
451
+ return buildLaunchSpec(options);
452
+ })();
453
+ console.log(`[mct-launch] mainClass=${launch.mainClass}`);
454
+ console.log(`[mct-launch] classpathEntries=${launch.classpathEntries.length}`);
455
+ console.log(
456
+ `[mct-launch] lwjglCorePresent=${launch.classpathEntries.some((entry) => entry.endsWith(`${path.sep}org${path.sep}lwjgl${path.sep}lwjgl${path.sep}3.3.2${path.sep}lwjgl-3.3.2.jar`))}`
457
+ );
458
+ console.log(`[mct-launch] gameArgs=${launch.gameArgs.join(" ")}`);
459
+ const child = spawn(
460
+ launch.javaBin,
461
+ [...launch.javaArgs, "-cp", launch.classpath, launch.mainClass, ...launch.gameArgs],
462
+ {
463
+ cwd: launch.cwd,
464
+ env: {
465
+ ...process.env
466
+ },
467
+ stdio: "inherit"
468
+ }
469
+ );
470
+
471
+ const forwardSignal = (signal) => {
472
+ if (!child.killed) {
473
+ child.kill(signal);
474
+ }
475
+ };
476
+
477
+ process.on("SIGINT", forwardSignal);
478
+ process.on("SIGTERM", forwardSignal);
479
+
480
+ child.on("exit", (code, signal) => {
481
+ if (signal) {
482
+ process.kill(process.pid, signal);
483
+ return;
484
+ }
485
+ process.exit(code ?? 0);
486
+ });
487
+ }
488
+
489
+ main().catch((error) => {
490
+ console.error(error instanceof Error ? error.stack : String(error));
491
+ process.exit(1);
492
+ });