@kzheart_/mc-pilot 0.9.1 → 0.9.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.
@@ -31,11 +31,12 @@
31
31
  "id": "1.20.1-forge",
32
32
  "minecraftVersion": "1.20.1",
33
33
  "loader": "forge",
34
- "support": "planned",
35
- "validation": "planned",
34
+ "support": "configured",
35
+ "validation": "limited",
36
36
  "modVersion": "0.9.1",
37
37
  "forgeVersion": "47.3.0",
38
- "javaVersion": 17
38
+ "javaVersion": 17,
39
+ "gradleModule": "version-1.20.1-forge"
39
40
  },
40
41
  {
41
42
  "id": "1.20.2-fabric",
@@ -53,11 +54,12 @@
53
54
  "id": "1.20.2-forge",
54
55
  "minecraftVersion": "1.20.2",
55
56
  "loader": "forge",
56
- "support": "planned",
57
- "validation": "planned",
57
+ "support": "configured",
58
+ "validation": "limited",
58
59
  "modVersion": "0.9.1",
59
60
  "forgeVersion": "48.1.0",
60
- "javaVersion": 17
61
+ "javaVersion": 17,
62
+ "gradleModule": "version-1.20.2-forge"
61
63
  },
62
64
  {
63
65
  "id": "1.20.4-fabric",
@@ -75,11 +77,12 @@
75
77
  "id": "1.20.4-forge",
76
78
  "minecraftVersion": "1.20.4",
77
79
  "loader": "forge",
78
- "support": "planned",
79
- "validation": "planned",
80
+ "support": "configured",
81
+ "validation": "limited",
80
82
  "modVersion": "0.9.1",
81
83
  "forgeVersion": "49.0.49",
82
- "javaVersion": 17
84
+ "javaVersion": 17,
85
+ "gradleModule": "version-1.20.4-forge"
83
86
  },
84
87
  {
85
88
  "id": "1.20.4-neoforge",
@@ -5,14 +5,8 @@ import { ServerInstanceManager } from "../instance/ServerInstanceManager.js";
5
5
  import { MctError } from "../util/errors.js";
6
6
  import { createRequestAction } from "./request-helpers.js";
7
7
  import { wrapCommand } from "../util/command.js";
8
- import { CacheManager } from "../download/CacheManager.js";
9
- import { findVariantByVersionAndLoader, getModArtifactFileName, loadModVariantCatalog } from "../download/ModVariantCatalog.js";
10
- import { detectJava } from "../download/JavaDetector.js";
11
- import { prepareManagedFabricRuntime } from "../download/client/FabricRuntimeDownloader.js";
12
- import { copyFileIfMissing, downloadFile } from "../download/DownloadUtils.js";
8
+ import { downloadClientModToDir } from "../download/client/ClientDownloader.js";
13
9
  import { resolveClientInstanceDir } from "../util/paths.js";
14
- import { access, mkdir } from "node:fs/promises";
15
- import path from "node:path";
16
10
  export async function resolveProfileServerAddress(context, explicitServer, loadPort) {
17
11
  if (explicitServer) {
18
12
  return explicitServer;
@@ -50,93 +44,46 @@ export function createClientCommand() {
50
44
  .description("Create a new client instance")
51
45
  .argument("<name>", "Client instance name (e.g. fabric-1.20.4)")
52
46
  .option("--version <version>", "Minecraft version (default: 1.21.4)")
53
- .option("--loader <loader>", "Client loader: fabric (default: fabric)")
47
+ .option("--loader <loader>", "Client loader: fabric|forge (default: fabric)")
54
48
  .option("--ws-port <port>", "WebSocket port (auto-assigned if omitted)", Number)
55
49
  .option("--account <account>", "Offline username or account identifier")
56
50
  .option("--headless", "Launch in headless mode")
51
+ .option("--mute", "Mute all in-game audio for this client")
52
+ .option("--no-mute", "Keep in-game audio enabled for this client")
57
53
  .option("--java <command>", "Java command to use")
58
54
  .action(wrapCommand(async (_context, { args, options }) => {
59
55
  const clientName = args[0];
60
56
  const loader = options.loader ?? "fabric";
61
57
  const version = options.version ?? "1.21.4";
62
- const cacheManager = new CacheManager();
63
- const catalog = await loadModVariantCatalog();
64
- const variant = findVariantByVersionAndLoader(catalog, version, loader);
65
- if (!variant) {
66
- throw new MctError({ code: "VARIANT_NOT_FOUND", message: `No mod variant found for ${version} / ${loader}` }, 4);
67
- }
68
- if (variant.loader !== "fabric") {
69
- throw new MctError({ code: "UNSUPPORTED_LOADER", message: `Loader ${variant.loader} is not implemented yet` }, 4);
70
- }
71
- // Check Java
72
- const java = await detectJava(options.java ?? "java");
73
- const requiredJava = variant.javaVersion ?? 17;
74
- if (!java.available || (java.majorVersion ?? 0) < requiredJava) {
75
- throw new MctError({ code: "JAVA_NOT_FOUND", message: `Java ${requiredJava}+ is required for ${variant.id}` }, 4);
76
- }
77
- // Resolve mod artifact
78
- const artifactFileName = getModArtifactFileName(variant);
79
- const cacheArtifactPath = cacheManager.getModFile(artifactFileName);
80
- const gradleModule = variant.gradleModule ?? `version-${variant.minecraftVersion}`;
81
- const localBuildPath = path.join(process.cwd(), "client-mod", gradleModule, "build", "libs", artifactFileName);
82
- let sourcePath;
83
- try {
84
- await access(localBuildPath);
85
- sourcePath = localBuildPath;
86
- await copyFileIfMissing(localBuildPath, cacheArtifactPath);
87
- }
88
- catch {
89
- try {
90
- await access(cacheArtifactPath);
91
- sourcePath = cacheArtifactPath;
92
- }
93
- catch {
94
- const modVersion = variant.modVersion ?? "0.9.1";
95
- const baseUrl = process.env.MCT_MOD_DOWNLOAD_BASE_URL || "https://github.com/kzheart/mc-pilot/releases/download";
96
- const downloadUrl = `${baseUrl}/v${modVersion}/${artifactFileName}`;
97
- await downloadFile(downloadUrl, cacheArtifactPath, fetch);
98
- sourcePath = cacheArtifactPath;
99
- }
100
- }
101
- // Set up client instance directory
102
58
  const instanceDir = resolveClientInstanceDir(clientName);
103
- const minecraftDir = path.join(instanceDir, "minecraft");
104
- const modsDir = path.join(minecraftDir, "mods");
105
- await mkdir(modsDir, { recursive: true });
106
- await copyFileIfMissing(sourcePath, path.join(modsDir, artifactFileName));
107
- // Prepare Fabric runtime
108
- const runtimeRootDir = path.join(cacheManager.getRootDir(), "client", "runtime", variant.minecraftVersion);
109
- const managedRuntime = await prepareManagedFabricRuntime(variant, {
110
- runtimeRootDir,
111
- gameDir: minecraftDir
112
- }, { fetchImpl: fetch });
113
- const launchArgs = [
114
- "--runtime-root", managedRuntime.runtimeRootDir,
115
- "--version-id", managedRuntime.versionId,
116
- "--game-dir", managedRuntime.gameDir
117
- ];
59
+ const downloaded = await downloadClientModToDir(process.cwd(), instanceDir, {
60
+ version,
61
+ loader,
62
+ java: options.java
63
+ });
118
64
  const manager = new ClientInstanceManager(_context.globalState);
119
65
  const meta = await manager.create({
120
66
  name: clientName,
121
- loader,
122
- version: variant.minecraftVersion,
67
+ loader: downloaded.loader,
68
+ version: downloaded.minecraftVersion,
123
69
  wsPort: options.wsPort,
124
70
  account: options.account,
125
71
  headless: options.headless,
126
- launchArgs,
72
+ mute: options.mute,
73
+ launchArgs: downloaded.launchArgs,
127
74
  env: {
128
- MCT_CLIENT_MOD_VARIANT: variant.id,
129
- MCT_CLIENT_MOD_JAR: path.join(modsDir, artifactFileName)
75
+ MCT_CLIENT_MOD_VARIANT: downloaded.variantId,
76
+ MCT_CLIENT_MOD_JAR: downloaded.jar
130
77
  }
131
78
  });
132
79
  return {
133
80
  created: true,
134
81
  ...meta,
135
- javaCommand: java.command,
136
- javaVersion: java.majorVersion,
137
- modsDir,
138
- runtimeRootDir: managedRuntime.runtimeRootDir,
139
- runtimeVersionId: managedRuntime.versionId
82
+ javaCommand: downloaded.javaCommand,
83
+ javaVersion: downloaded.javaVersion,
84
+ modsDir: downloaded.modsDir,
85
+ runtimeRootDir: downloaded.runtimeRootDir,
86
+ runtimeVersionId: downloaded.runtimeVersionId
140
87
  };
141
88
  }));
142
89
  command
@@ -147,6 +94,8 @@ export function createClientCommand() {
147
94
  .option("--account <account>", "Offline username or account identifier")
148
95
  .option("--ws-port <port>", "WebSocket port override", Number)
149
96
  .option("--headless", "Launch in headless mode")
97
+ .option("--mute", "Mute all in-game audio for this launch")
98
+ .option("--no-mute", "Keep in-game audio enabled for this launch")
150
99
  .option("--force", "Kill any existing client with the same name before launching")
151
100
  .action(wrapCommand(async (context, { args, options }) => {
152
101
  const clientName = args[0] ?? context.activeProfile?.clients[0];
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare function createImageCommand(): Command;
@@ -0,0 +1,211 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PNG } from "pngjs";
5
+ import { wrapCommand } from "../util/command.js";
6
+ import { MctError } from "../util/errors.js";
7
+ import { resolveProjectRelativePath } from "./request-helpers.js";
8
+ function parseRect(value, field) {
9
+ if (!value)
10
+ return undefined;
11
+ const parts = value.split(",").map((part) => Number(part.trim()));
12
+ if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
13
+ throw new MctError({ code: "INVALID_PARAMS", message: `${field} must use x,y,w,h format.` }, 4);
14
+ }
15
+ return { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
16
+ }
17
+ function parseRgb(value) {
18
+ if (!value)
19
+ return undefined;
20
+ const parts = value.split(",").map((part) => Number(part.trim()));
21
+ if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part) || part < 0 || part > 255)) {
22
+ throw new MctError({ code: "INVALID_PARAMS", message: "--background must use r,g,b format." }, 4);
23
+ }
24
+ return [parts[0], parts[1], parts[2]];
25
+ }
26
+ function loadPng(file) {
27
+ return PNG.sync.read(readFileSync(file));
28
+ }
29
+ function pixelOffset(image, x, y) {
30
+ return (image.width * y + x) << 2;
31
+ }
32
+ function colorDistance(a, b) {
33
+ return Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
34
+ }
35
+ function clampRect(rect, image) {
36
+ const raw = rect ?? { x: 0, y: 0, width: image.width, height: image.height };
37
+ const x = Math.max(0, Math.min(image.width, Math.floor(raw.x)));
38
+ const y = Math.max(0, Math.min(image.height, Math.floor(raw.y)));
39
+ const right = Math.max(x, Math.min(image.width, Math.floor(raw.x + raw.width)));
40
+ const bottom = Math.max(y, Math.min(image.height, Math.floor(raw.y + raw.height)));
41
+ return { x, y, width: right - x, height: bottom - y };
42
+ }
43
+ function findForegroundBounds(image, options) {
44
+ const region = clampRect(options.region, image);
45
+ const background = options.background ?? [
46
+ image.data[pixelOffset(image, 0, 0)],
47
+ image.data[pixelOffset(image, 0, 0) + 1],
48
+ image.data[pixelOffset(image, 0, 0) + 2]
49
+ ];
50
+ let minX = Number.POSITIVE_INFINITY;
51
+ let minY = Number.POSITIVE_INFINITY;
52
+ let maxX = -1;
53
+ let maxY = -1;
54
+ let pixels = 0;
55
+ for (let y = region.y; y < region.y + region.height; y++) {
56
+ for (let x = region.x; x < region.x + region.width; x++) {
57
+ const offset = pixelOffset(image, x, y);
58
+ const a = image.data[offset + 3];
59
+ const rgb = [image.data[offset], image.data[offset + 1], image.data[offset + 2]];
60
+ if (a > options.alphaThreshold && colorDistance(rgb, background) > options.threshold) {
61
+ minX = Math.min(minX, x);
62
+ minY = Math.min(minY, y);
63
+ maxX = Math.max(maxX, x);
64
+ maxY = Math.max(maxY, y);
65
+ pixels++;
66
+ }
67
+ }
68
+ }
69
+ if (pixels === 0)
70
+ return null;
71
+ return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1, pixels };
72
+ }
73
+ function collectTemplatePixels(template, alphaThreshold, maxSamples) {
74
+ const pixels = [];
75
+ for (let y = 0; y < template.height; y++) {
76
+ for (let x = 0; x < template.width; x++) {
77
+ const offset = pixelOffset(template, x, y);
78
+ if (template.data[offset + 3] > alphaThreshold) {
79
+ pixels.push({ x, y, r: template.data[offset], g: template.data[offset + 1], b: template.data[offset + 2] });
80
+ }
81
+ }
82
+ }
83
+ if (pixels.length <= maxSamples)
84
+ return pixels;
85
+ const stride = pixels.length / maxSamples;
86
+ const sampled = [];
87
+ for (let i = 0; i < maxSamples; i++) {
88
+ sampled.push(pixels[Math.floor(i * stride)]);
89
+ }
90
+ return sampled;
91
+ }
92
+ function scoreTemplateAt(screenshot, samples, x, y, scale) {
93
+ let score = 0;
94
+ let compared = 0;
95
+ for (const sample of samples) {
96
+ const sx = Math.round(x + sample.x * scale);
97
+ const sy = Math.round(y + sample.y * scale);
98
+ if (sx < 0 || sy < 0 || sx >= screenshot.width || sy >= screenshot.height)
99
+ continue;
100
+ const offset = pixelOffset(screenshot, sx, sy);
101
+ score += Math.abs(screenshot.data[offset] - sample.r) +
102
+ Math.abs(screenshot.data[offset + 1] - sample.g) +
103
+ Math.abs(screenshot.data[offset + 2] - sample.b);
104
+ compared++;
105
+ }
106
+ return compared === 0 ? Number.POSITIVE_INFINITY : score / compared;
107
+ }
108
+ export function createImageCommand() {
109
+ const command = new Command("image").description("Image analysis helpers for screenshots and UI offset tuning");
110
+ command
111
+ .command("bbox")
112
+ .description("Find the foreground bounding box in a PNG image")
113
+ .argument("<image>", "PNG image path")
114
+ .option("--background <rgb>", "Background color to ignore, format r,g,b. Defaults to top-left pixel.")
115
+ .option("--threshold <value>", "RGB distance threshold", (value) => Number(value), 24)
116
+ .option("--alpha-threshold <value>", "Alpha threshold", (value) => Number(value), 8)
117
+ .option("--region <rect>", "Optional scan region x,y,w,h")
118
+ .action(wrapCommand(async (context, { args, options }) => {
119
+ const imagePath = resolveProjectRelativePath(context, args[0] ?? "");
120
+ const image = loadPng(imagePath);
121
+ const bbox = findForegroundBounds(image, {
122
+ background: parseRgb(options.background),
123
+ threshold: Number(options.threshold),
124
+ alphaThreshold: Number(options.alphaThreshold),
125
+ region: parseRect(options.region, "--region")
126
+ });
127
+ return {
128
+ image: path.resolve(imagePath),
129
+ size: { width: image.width, height: image.height },
130
+ bbox,
131
+ center: bbox ? { x: bbox.x + bbox.width / 2, y: bbox.y + bbox.height / 2 } : null
132
+ };
133
+ }));
134
+ command
135
+ .command("locate-template")
136
+ .description("Locate a transparent PNG template inside a screenshot, useful for tuning GUI title offsets")
137
+ .argument("<screenshot>", "Screenshot PNG path")
138
+ .argument("<template>", "Template PNG path")
139
+ .option("--region <rect>", "Screenshot search region x,y,w,h")
140
+ .option("--expected <rect>", "Expected rectangle x,y,w,h; output includes delta from it")
141
+ .option("--scale <value>", "Single scale to test", (value) => Number(value))
142
+ .option("--min-scale <value>", "Minimum scale", (value) => Number(value), 0.5)
143
+ .option("--max-scale <value>", "Maximum scale", (value) => Number(value), 3)
144
+ .option("--scale-step <value>", "Scale step", (value) => Number(value), 0.1)
145
+ .option("--stride <pixels>", "Search stride in screenshot pixels", (value) => Number(value), 2)
146
+ .option("--alpha-threshold <value>", "Template alpha threshold", (value) => Number(value), 16)
147
+ .option("--max-samples <count>", "Maximum template pixels to sample", (value) => Number(value), 4000)
148
+ .action(wrapCommand(async (context, { args, options }) => {
149
+ const screenshotPath = resolveProjectRelativePath(context, args[0] ?? "");
150
+ const templatePath = resolveProjectRelativePath(context, args[1] ?? "");
151
+ const screenshot = loadPng(screenshotPath);
152
+ const template = loadPng(templatePath);
153
+ const region = clampRect(parseRect(options.region, "--region"), screenshot);
154
+ const expected = parseRect(options.expected, "--expected");
155
+ const samples = collectTemplatePixels(template, Number(options.alphaThreshold), Number(options.maxSamples));
156
+ if (samples.length === 0) {
157
+ throw new MctError({ code: "INVALID_PARAMS", message: "Template has no visible pixels after alpha filtering." }, 4);
158
+ }
159
+ const scales = options.scale
160
+ ? [Number(options.scale)]
161
+ : Array.from({ length: Math.max(1, Math.floor((Number(options.maxScale) - Number(options.minScale)) / Number(options.scaleStep)) + 1) }, (_, index) => Number((Number(options.minScale) + index * Number(options.scaleStep)).toFixed(4)));
162
+ let best;
163
+ for (const scale of scales) {
164
+ const width = Math.round(template.width * scale);
165
+ const height = Math.round(template.height * scale);
166
+ for (let y = region.y - height; y <= region.y + region.height; y += Number(options.stride)) {
167
+ for (let x = region.x - width; x <= region.x + region.width; x += Number(options.stride)) {
168
+ const score = scoreTemplateAt(screenshot, samples, x, y, scale);
169
+ if (!best || score < best.score) {
170
+ best = { x, y, scale, score, compared: samples.length };
171
+ }
172
+ }
173
+ }
174
+ }
175
+ if (!best) {
176
+ throw new MctError({ code: "INVALID_STATE", message: "No template match candidate found." }, 3);
177
+ }
178
+ const bbox = {
179
+ x: best.x,
180
+ y: best.y,
181
+ width: Math.round(template.width * best.scale),
182
+ height: Math.round(template.height * best.scale)
183
+ };
184
+ const center = { x: bbox.x + bbox.width / 2, y: bbox.y + bbox.height / 2 };
185
+ const expectedCenter = expected ? { x: expected.x + expected.width / 2, y: expected.y + expected.height / 2 } : undefined;
186
+ return {
187
+ screenshot: path.resolve(screenshotPath),
188
+ template: path.resolve(templatePath),
189
+ screenshotSize: { width: screenshot.width, height: screenshot.height },
190
+ templateSize: { width: template.width, height: template.height },
191
+ samples: samples.length,
192
+ match: {
193
+ bbox,
194
+ center,
195
+ scale: best.scale,
196
+ score: Number(best.score.toFixed(3))
197
+ },
198
+ expected: expected
199
+ ? {
200
+ bbox: expected,
201
+ center: expectedCenter,
202
+ delta: {
203
+ x: Number((center.x - expectedCenter.x).toFixed(3)),
204
+ y: Number((center.y - expectedCenter.y).toFixed(3))
205
+ }
206
+ }
207
+ : undefined
208
+ };
209
+ }));
210
+ return command;
211
+ }
@@ -40,21 +40,7 @@ export interface ClientSearchResult {
40
40
  notes?: string;
41
41
  javaVersion: string;
42
42
  }
43
- export declare function getVersionMatrix(): {
44
- minecraftVersion: string;
45
- javaVersion: string;
46
- servers: {
47
- paper: ServerSupportInfo;
48
- purpur: ServerSupportInfo;
49
- vanilla: ServerSupportInfo;
50
- spigot: ServerSupportInfo;
51
- };
52
- clients: {
53
- fabric: ClientLoaderSupportInfo;
54
- forge: ClientLoaderSupportInfo;
55
- neoforge: ClientLoaderSupportInfo;
56
- };
57
- }[];
43
+ export declare function getVersionMatrix(): MinecraftSupportEntry[];
58
44
  export declare function getSupportedMinecraftVersions(): string[];
59
45
  export declare function getMinecraftSupport(version: string): MinecraftSupportEntry | undefined;
60
46
  export declare function searchServerVersions(filter?: {
@@ -41,7 +41,7 @@ const VERSION_MATRIX = [
41
41
  },
42
42
  clients: {
43
43
  fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
44
- forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.9.1" },
44
+ forge: { supported: true, loaderVersion: "49.0.49", modVersion: "0.9.1", validation: "limited" },
45
45
  neoforge: { supported: false, notes: "不支持此版本" }
46
46
  }
47
47
  },
@@ -56,7 +56,7 @@ const VERSION_MATRIX = [
56
56
  },
57
57
  clients: {
58
58
  fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1", validation: "verified" },
59
- forge: { supported: false, notes: "当前未接入此 loader" },
59
+ forge: { supported: true, loaderVersion: "48.1.0", modVersion: "0.9.1", validation: "limited" },
60
60
  neoforge: { supported: false, notes: "不支持此版本" }
61
61
  }
62
62
  },
@@ -86,7 +86,7 @@ const VERSION_MATRIX = [
86
86
  },
87
87
  clients: {
88
88
  fabric: { supported: true, loaderVersion: "0.16.14", modVersion: "0.9.1" },
89
- forge: { supported: true, loaderVersion: "47.x", modVersion: "0.9.1" },
89
+ forge: { supported: true, loaderVersion: "47.3.0", modVersion: "0.9.1", validation: "limited" },
90
90
  neoforge: { supported: false, notes: "不支持此版本" }
91
91
  }
92
92
  },
@@ -150,8 +150,8 @@ function overlayClientSupport(entry, loader) {
150
150
  notes: variant.notes
151
151
  };
152
152
  }
153
- export function getVersionMatrix() {
154
- return VERSION_MATRIX.map((entry) => ({
153
+ function overlayMinecraftSupport(entry) {
154
+ return {
155
155
  minecraftVersion: entry.minecraftVersion,
156
156
  javaVersion: entry.javaVersion,
157
157
  servers: { ...entry.servers },
@@ -160,13 +160,17 @@ export function getVersionMatrix() {
160
160
  forge: overlayClientSupport(entry, "forge"),
161
161
  neoforge: overlayClientSupport(entry, "neoforge")
162
162
  }
163
- }));
163
+ };
164
+ }
165
+ export function getVersionMatrix() {
166
+ return VERSION_MATRIX.map((entry) => overlayMinecraftSupport(entry));
164
167
  }
165
168
  export function getSupportedMinecraftVersions() {
166
169
  return VERSION_MATRIX.map((entry) => entry.minecraftVersion);
167
170
  }
168
171
  export function getMinecraftSupport(version) {
169
- return VERSION_MATRIX.find((entry) => entry.minecraftVersion === version);
172
+ const entry = VERSION_MATRIX.find((candidate) => candidate.minecraftVersion === version);
173
+ return entry ? overlayMinecraftSupport(entry) : undefined;
170
174
  }
171
175
  export function searchServerVersions(filter) {
172
176
  const types = filter?.type ? [filter.type] : getServerTypes();
@@ -1,7 +1,7 @@
1
1
  import { CacheManager } from "../CacheManager.js";
2
2
  import { detectJava } from "../JavaDetector.js";
3
3
  import type { LoaderType, ModVariant } from "../types.js";
4
- import { prepareManagedFabricRuntime } from "./FabricRuntimeDownloader.js";
4
+ import { prepareManagedClientRuntime } from "./FabricRuntimeDownloader.js";
5
5
  export interface DownloadClientOptions {
6
6
  loader?: LoaderType;
7
7
  version?: string;
@@ -20,7 +20,7 @@ export interface DownloadClientDependencies {
20
20
  cacheManager?: CacheManager;
21
21
  detectJavaImpl?: typeof detectJava;
22
22
  fetchImpl?: typeof fetch;
23
- prepareManagedRuntimeImpl?: typeof prepareManagedFabricRuntime;
23
+ prepareManagedRuntimeImpl?: typeof prepareManagedClientRuntime;
24
24
  }
25
25
  export declare function resolveArtifact(cwd: string, variant: ModVariant, cacheManager: CacheManager, fetchImpl?: typeof fetch): Promise<{
26
26
  sourcePath: string;
@@ -7,7 +7,7 @@ import { CacheManager } from "../CacheManager.js";
7
7
  import { copyFileIfMissing, downloadFile } from "../DownloadUtils.js";
8
8
  import { detectJava } from "../JavaDetector.js";
9
9
  import { findVariantByVersionAndLoader, getDefaultVariant, getModArtifactFileName, loadModVariantCatalog } from "../ModVariantCatalog.js";
10
- import { prepareManagedFabricRuntime } from "./FabricRuntimeDownloader.js";
10
+ import { prepareManagedClientRuntime } from "./FabricRuntimeDownloader.js";
11
11
  function getLaunchScriptPath() {
12
12
  const thisFile = fileURLToPath(import.meta.url);
13
13
  // dist/download/client/ClientDownloader.js -> scripts/launch-fabric-client.mjs
@@ -15,13 +15,27 @@ function getLaunchScriptPath() {
15
15
  }
16
16
  const GITHUB_RELEASE_BASE_URL = process.env.MCT_MOD_DOWNLOAD_BASE_URL || "https://github.com/kzheart/mc-pilot/releases/download";
17
17
  function ensureSupportedVariant(variant) {
18
- if (variant.loader !== "fabric") {
18
+ if (variant.support !== "ready" && variant.support !== "configured") {
19
19
  throw new MctError({
20
- code: "UNSUPPORTED_LOADER",
21
- message: `Loader ${variant.loader} is not implemented yet`
20
+ code: "VARIANT_NOT_BUILDABLE",
21
+ message: `Variant ${variant.id} is not buildable yet`,
22
+ details: {
23
+ support: variant.support,
24
+ validation: variant.validation
25
+ }
26
+ }, 4);
27
+ }
28
+ if (variant.loader === "fabric" && (!variant.fabricLoaderVersion || !variant.yarnMappings)) {
29
+ throw new MctError({
30
+ code: "VARIANT_NOT_BUILDABLE",
31
+ message: `Variant ${variant.id} is not buildable yet`,
32
+ details: {
33
+ support: variant.support,
34
+ validation: variant.validation
35
+ }
22
36
  }, 4);
23
37
  }
24
- if (!variant.fabricLoaderVersion || !variant.yarnMappings) {
38
+ if (variant.loader === "forge" && !variant.forgeVersion) {
25
39
  throw new MctError({
26
40
  code: "VARIANT_NOT_BUILDABLE",
27
41
  message: `Variant ${variant.id} is not buildable yet`,
@@ -31,6 +45,12 @@ function ensureSupportedVariant(variant) {
31
45
  }
32
46
  }, 4);
33
47
  }
48
+ if (variant.loader !== "fabric" && variant.loader !== "forge") {
49
+ throw new MctError({
50
+ code: "UNSUPPORTED_LOADER",
51
+ message: `Loader ${variant.loader} is not implemented yet`
52
+ }, 4);
53
+ }
34
54
  }
35
55
  export async function resolveArtifact(cwd, variant, cacheManager, fetchImpl = fetch) {
36
56
  const artifactFileName = getModArtifactFileName(variant);
@@ -114,6 +134,12 @@ function resolveLaunchRuntimePaths(cwd, options) {
114
134
  };
115
135
  }
116
136
  function buildLaunchArgs(runtimePaths, variant) {
137
+ if (variant.loader !== "fabric") {
138
+ throw new MctError({
139
+ code: "INVALID_PARAMS",
140
+ message: `Custom runtime directories are not supported for ${variant.loader} clients yet`
141
+ }, 4);
142
+ }
117
143
  return [
118
144
  "--instance-dir",
119
145
  runtimePaths.instanceDir,
@@ -169,11 +195,13 @@ export async function downloadClientModToDir(cwd, targetDir, options, dependenci
169
195
  const cacheManager = dependencies.cacheManager ?? new CacheManager();
170
196
  const detectJavaImpl = dependencies.detectJavaImpl ?? detectJava;
171
197
  const fetchImpl = dependencies.fetchImpl ?? fetch;
172
- const prepareManagedRuntimeImpl = dependencies.prepareManagedRuntimeImpl ?? prepareManagedFabricRuntime;
198
+ const prepareManagedRuntimeImpl = dependencies.prepareManagedRuntimeImpl ?? prepareManagedClientRuntime;
173
199
  const catalog = await loadModVariantCatalog();
174
200
  const variant = options.version
175
201
  ? findVariantByVersionAndLoader(catalog, options.version, loader)
176
- : getDefaultVariant(catalog);
202
+ : loader === "fabric"
203
+ ? getDefaultVariant(catalog)
204
+ : undefined;
177
205
  if (!variant) {
178
206
  throw new MctError({
179
207
  code: "VARIANT_NOT_FOUND",
@@ -12,3 +12,5 @@ export interface PrepareFabricRuntimeOptions {
12
12
  gameDir: string;
13
13
  }
14
14
  export declare function prepareManagedFabricRuntime(variant: ModVariant, runtimeOptions: PrepareFabricRuntimeOptions, dependencies?: PrepareFabricRuntimeDependencies): Promise<PreparedFabricRuntime>;
15
+ export declare function prepareManagedForgeRuntime(variant: ModVariant, runtimeOptions: PrepareFabricRuntimeOptions, dependencies?: PrepareFabricRuntimeDependencies): Promise<PreparedFabricRuntime>;
16
+ export declare function prepareManagedClientRuntime(variant: ModVariant, runtimeOptions: PrepareFabricRuntimeOptions, dependencies?: PrepareFabricRuntimeDependencies): Promise<PreparedFabricRuntime>;
@@ -2,7 +2,7 @@ import { access, mkdir, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
4
  import { MinecraftFolder, Version } from "@xmcl/core";
5
- import { getVersionList, installDependencies, installFabric, installVersion } from "@xmcl/installer";
5
+ import { getVersionList, installDependencies, installFabric, installForge, installVersion } from "@xmcl/installer";
6
6
  import { Agent, interceptors } from "undici";
7
7
  import { MctError } from "../../util/errors.js";
8
8
  import { applyArm64LwjglPatch } from "./Arm64LwjglPatcher.js";
@@ -42,19 +42,11 @@ async function fetchWithRetry(input, init, attempts = 4) {
42
42
  }
43
43
  throw lastError;
44
44
  }
45
- export async function prepareManagedFabricRuntime(variant, runtimeOptions, dependencies = {}) {
45
+ async function prepareManagedRuntime(variant, runtimeOptions, dependencies, expectedVersionId, installLoader) {
46
46
  const fetchImpl = dependencies.fetchImpl ?? fetchWithRetry;
47
- const loaderVersion = variant.fabricLoaderVersion;
48
- if (!loaderVersion) {
49
- throw new MctError({
50
- code: "VARIANT_NOT_BUILDABLE",
51
- message: `Variant ${variant.id} does not define a Fabric loader version`
52
- }, 4);
53
- }
54
47
  const { runtimeRootDir, gameDir } = runtimeOptions;
55
48
  await mkdir(runtimeRootDir, { recursive: true });
56
49
  await mkdir(gameDir, { recursive: true });
57
- const expectedVersionId = `${variant.minecraftVersion}-fabric${loaderVersion}`;
58
50
  const readyMarker = path.join(runtimeRootDir, `.ready-${expectedVersionId}`);
59
51
  try {
60
52
  await access(readyMarker);
@@ -77,13 +69,7 @@ export async function prepareManagedFabricRuntime(variant, runtimeOptions, depen
77
69
  side: "client",
78
70
  dispatcher: DOWNLOAD_DISPATCHER
79
71
  });
80
- const installedVersionId = await installFabric({
81
- minecraftVersion: variant.minecraftVersion,
82
- version: loaderVersion,
83
- minecraft,
84
- side: "client",
85
- fetch: fetchImpl
86
- });
72
+ const installedVersionId = await installLoader(minecraft, fetchImpl);
87
73
  const resolvedVersion = await Version.parse(minecraft, installedVersionId);
88
74
  await installDependencies(resolvedVersion, {
89
75
  side: "client",
@@ -99,3 +85,47 @@ export async function prepareManagedFabricRuntime(variant, runtimeOptions, depen
99
85
  versionId: installedVersionId
100
86
  };
101
87
  }
88
+ export async function prepareManagedFabricRuntime(variant, runtimeOptions, dependencies = {}) {
89
+ const loaderVersion = variant.fabricLoaderVersion;
90
+ if (!loaderVersion) {
91
+ throw new MctError({
92
+ code: "VARIANT_NOT_BUILDABLE",
93
+ message: `Variant ${variant.id} does not define a Fabric loader version`
94
+ }, 4);
95
+ }
96
+ return prepareManagedRuntime(variant, runtimeOptions, dependencies, `${variant.minecraftVersion}-fabric${loaderVersion}`, (minecraft, fetchImpl) => installFabric({
97
+ minecraftVersion: variant.minecraftVersion,
98
+ version: loaderVersion,
99
+ minecraft,
100
+ side: "client",
101
+ fetch: fetchImpl
102
+ }));
103
+ }
104
+ export async function prepareManagedForgeRuntime(variant, runtimeOptions, dependencies = {}) {
105
+ const forgeVersion = variant.forgeVersion;
106
+ if (!forgeVersion) {
107
+ throw new MctError({
108
+ code: "VARIANT_NOT_BUILDABLE",
109
+ message: `Variant ${variant.id} does not define a Forge version`
110
+ }, 4);
111
+ }
112
+ return prepareManagedRuntime(variant, runtimeOptions, dependencies, `${variant.minecraftVersion}-forge-${forgeVersion}`, (minecraft) => installForge({
113
+ mcversion: variant.minecraftVersion,
114
+ version: forgeVersion
115
+ }, minecraft, {
116
+ side: "client",
117
+ dispatcher: DOWNLOAD_DISPATCHER
118
+ }));
119
+ }
120
+ export async function prepareManagedClientRuntime(variant, runtimeOptions, dependencies = {}) {
121
+ if (variant.loader === "fabric") {
122
+ return prepareManagedFabricRuntime(variant, runtimeOptions, dependencies);
123
+ }
124
+ if (variant.loader === "forge") {
125
+ return prepareManagedForgeRuntime(variant, runtimeOptions, dependencies);
126
+ }
127
+ throw new MctError({
128
+ code: "UNSUPPORTED_LOADER",
129
+ message: `Loader ${variant.loader} is not implemented yet`
130
+ }, 4);
131
+ }
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { createEntityCommand } from "./commands/entity.js";
12
12
  import { createEventsCommand } from "./commands/events.js";
13
13
  import { createGuiCommand } from "./commands/gui.js";
14
14
  import { createHudCommand } from "./commands/hud.js";
15
+ import { createImageCommand } from "./commands/image.js";
15
16
  import { createInputCommand } from "./commands/input.js";
16
17
  import { createInventoryCommand } from "./commands/inventory.js";
17
18
  import { createLookCommand } from "./commands/look.js";
@@ -83,6 +84,7 @@ export function buildProgram() {
83
84
  program.addCommand(createEntityCommand());
84
85
  program.addCommand(createInventoryCommand());
85
86
  program.addCommand(createGuiCommand());
87
+ program.addCommand(createImageCommand());
86
88
  program.addCommand(createScreenshotCommand());
87
89
  program.addCommand(createScreenCommand());
88
90
  program.addCommand(createHudCommand());
@@ -7,6 +7,7 @@ export interface CreateClientOptions {
7
7
  wsPort?: number;
8
8
  account?: string;
9
9
  headless?: boolean;
10
+ mute?: boolean;
10
11
  launchArgs?: string[];
11
12
  env?: Record<string, string>;
12
13
  }
@@ -15,6 +16,7 @@ export interface LaunchClientOptions {
15
16
  account?: string;
16
17
  wsPort?: number;
17
18
  headless?: boolean;
19
+ mute?: boolean;
18
20
  force?: boolean;
19
21
  }
20
22
  export interface WaitReadyOptions {
@@ -30,6 +30,7 @@ export class ClientInstanceManager {
30
30
  wsPort,
31
31
  account: options.account,
32
32
  headless: options.headless,
33
+ mute: options.mute,
33
34
  launchArgs: options.launchArgs,
34
35
  env: options.env,
35
36
  createdAt: new Date().toISOString()
@@ -57,6 +58,7 @@ export class ClientInstanceManager {
57
58
  const meta = await this.loadMeta(clientName);
58
59
  const instanceDir = resolveClientInstanceDir(clientName);
59
60
  const wsPort = options.wsPort ?? meta.wsPort;
61
+ const mute = options.mute ?? meta.mute;
60
62
  if (!meta.launchArgs || meta.launchArgs.length === 0) {
61
63
  throw new MctError({ code: "INVALID_PARAMS", message: `Client ${clientName} has no launchArgs configured` }, 4);
62
64
  }
@@ -91,7 +93,8 @@ export class ClientInstanceManager {
91
93
  MCT_CLIENT_ACCOUNT: options.account ?? meta.account ?? "",
92
94
  MCT_CLIENT_SERVER: options.server ?? "",
93
95
  MCT_CLIENT_WS_PORT: String(wsPort),
94
- MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false)
96
+ MCT_CLIENT_HEADLESS: String(options.headless ?? meta.headless ?? false),
97
+ ...(mute === undefined ? {} : { MCT_CLIENT_MUTE: String(mute) })
95
98
  }
96
99
  });
97
100
  child.unref();
@@ -16,6 +16,7 @@ export interface ClientInstanceMeta {
16
16
  wsPort: number;
17
17
  account?: string;
18
18
  headless?: boolean;
19
+ mute?: boolean;
19
20
  launchArgs?: string[];
20
21
  env?: Record<string, string>;
21
22
  createdAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.9.1",
3
+ "version": "0.9.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": {
@@ -44,10 +44,12 @@
44
44
  "@xmcl/file-transfer": "^2.0.3",
45
45
  "@xmcl/installer": "^6.1.2",
46
46
  "commander": "^14.0.1",
47
+ "pngjs": "^7.0.0",
47
48
  "ws": "^8.19.0"
48
49
  },
49
50
  "devDependencies": {
50
51
  "@types/node": "^24.5.2",
52
+ "@types/pngjs": "^6.0.5",
51
53
  "@types/ws": "^8.18.1",
52
54
  "tsx": "^4.20.5",
53
55
  "typescript": "^5.9.2"
@@ -106,6 +106,21 @@ function substitute(template, variables) {
106
106
  return template.replace(/\$\{([^}]+)\}/g, (_, key) => variables[key] ?? "");
107
107
  }
108
108
 
109
+ function parseOptionalBoolean(value) {
110
+ if (value === undefined || value === null || value === "") {
111
+ return undefined;
112
+ }
113
+
114
+ const normalized = String(value).trim().toLowerCase();
115
+ if (["true", "1", "yes", "on"].includes(normalized)) {
116
+ return true;
117
+ }
118
+ if (["false", "0", "no", "off"].includes(normalized)) {
119
+ return false;
120
+ }
121
+ return undefined;
122
+ }
123
+
109
124
  async function ensureFile(filePath, downloadUrl) {
110
125
  try {
111
126
  await access(filePath);
@@ -170,7 +185,7 @@ async function syncConfiguredMod(gameDir) {
170
185
  await copyFile(sourceJar, targetJar);
171
186
  }
172
187
 
173
- async function ensureAutomationOptions(gameDir, server) {
188
+ async function ensureAutomationOptions(gameDir, server, mute) {
174
189
  const optionsPath = path.join(gameDir, "options.txt");
175
190
  const values = new Map();
176
191
 
@@ -191,6 +206,23 @@ async function ensureAutomationOptions(gameDir, server) {
191
206
  values.set("joinedFirstServer", "true");
192
207
  values.set("tutorialStep", "none");
193
208
  values.set("pauseOnLostFocus", "false");
209
+ if (mute !== undefined) {
210
+ const volume = mute ? "0.0" : "1.0";
211
+ for (const category of [
212
+ "master",
213
+ "music",
214
+ "record",
215
+ "weather",
216
+ "block",
217
+ "hostile",
218
+ "neutral",
219
+ "player",
220
+ "ambient",
221
+ "voice"
222
+ ]) {
223
+ values.set(`soundCategory_${category}`, volume);
224
+ }
225
+ }
194
226
  if (server) {
195
227
  values.set("lastServer", server);
196
228
  }
@@ -214,6 +246,7 @@ async function buildLaunchSpec(options) {
214
246
  const nativesDir = options["natives-dir"] || path.join(instanceRoot, "natives");
215
247
  const gameDir = path.join(instanceRoot, "minecraft");
216
248
  const packMeta = await readJson(path.join(instanceRoot, "mmc-pack.json"));
249
+ const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
217
250
  await syncBuiltMod(instanceRoot, repoRoot, selectedVariant);
218
251
  const componentMetas = new Map();
219
252
  for (const component of packMeta.components) {
@@ -263,7 +296,7 @@ async function buildLaunchSpec(options) {
263
296
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
264
297
  const accountUuid = offlineUuid(accountName);
265
298
  const server = process.env.MCT_CLIENT_SERVER || "";
266
- await ensureAutomationOptions(gameDir, server);
299
+ await ensureAutomationOptions(gameDir, server, mute);
267
300
  const [serverHost, serverPort = "25565"] = server.split(":");
268
301
  const classpath = [
269
302
  path.join(librariesRoot, mainJarPath),
@@ -316,12 +349,13 @@ async function buildManifestLaunchSpec(options) {
316
349
 
317
350
  const manifest = await readJson(manifestPath);
318
351
  const gameDir = manifest.gameDir;
352
+ const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
319
353
  await syncConfiguredMod(gameDir);
320
354
 
321
355
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
322
356
  const accountUuid = offlineUuid(accountName);
323
357
  const server = process.env.MCT_CLIENT_SERVER || "";
324
- await ensureAutomationOptions(gameDir, server);
358
+ await ensureAutomationOptions(gameDir, server, mute);
325
359
  const [serverHost, serverPort = "25565"] = server.split(":");
326
360
  const substitutions = {
327
361
  auth_player_name: accountName,