@kzheart_/mc-pilot 0.9.0 → 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.
@@ -6,6 +6,12 @@ export class GlobalStateStore extends StateStore {
6
6
  constructor() {
7
7
  super(resolveGlobalStateDir());
8
8
  }
9
+ async withClientLock(task) {
10
+ return this.withLock("clients", task);
11
+ }
12
+ async withServerLock(task) {
13
+ return this.withLock("servers", task);
14
+ }
9
15
  async readServerState() {
10
16
  return this.readJson(SERVERS_STATE_FILE, { servers: {} });
11
17
  }
@@ -18,4 +24,20 @@ export class GlobalStateStore extends StateStore {
18
24
  async writeClientState(state) {
19
25
  await this.writeJson(CLIENTS_STATE_FILE, state);
20
26
  }
27
+ async updateClientState(mutate) {
28
+ return this.withClientLock(async () => {
29
+ const state = await this.readClientState();
30
+ const result = await mutate(state);
31
+ await this.writeClientState(state);
32
+ return result;
33
+ });
34
+ }
35
+ async updateServerState(mutate) {
36
+ return this.withServerLock(async () => {
37
+ const state = await this.readServerState();
38
+ const result = await mutate(state);
39
+ await this.writeServerState(state);
40
+ return result;
41
+ });
42
+ }
21
43
  }
@@ -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;
@@ -1,3 +1,4 @@
1
+ export declare function isTcpPortReachable(host: string, port: number): Promise<boolean>;
1
2
  export declare function waitForTcpPort(host: string, port: number, timeoutSeconds: number): Promise<{
2
3
  reachable: boolean;
3
4
  host: string;
package/dist/util/net.js CHANGED
@@ -5,21 +5,23 @@ function wait(ms) {
5
5
  setTimeout(resolve, ms);
6
6
  });
7
7
  }
8
+ export async function isTcpPortReachable(host, port) {
9
+ return await new Promise((resolve) => {
10
+ const socket = net.createConnection({ host, port });
11
+ socket.once("connect", () => {
12
+ socket.destroy();
13
+ resolve(true);
14
+ });
15
+ socket.once("error", () => {
16
+ socket.destroy();
17
+ resolve(false);
18
+ });
19
+ });
20
+ }
8
21
  export async function waitForTcpPort(host, port, timeoutSeconds) {
9
22
  const deadline = Date.now() + timeoutSeconds * 1000;
10
23
  while (Date.now() < deadline) {
11
- const reachable = await new Promise((resolve) => {
12
- const socket = net.createConnection({ host, port });
13
- socket.once("connect", () => {
14
- socket.destroy();
15
- resolve(true);
16
- });
17
- socket.once("error", () => {
18
- socket.destroy();
19
- resolve(false);
20
- });
21
- });
22
- if (reachable) {
24
+ if (await isTcpPortReachable(host, port)) {
23
25
  return {
24
26
  reachable: true,
25
27
  host,
@@ -6,5 +6,10 @@ export declare class StateStore {
6
6
  readJson<T>(name: string, fallback: T): Promise<T>;
7
7
  writeJson(name: string, value: unknown): Promise<void>;
8
8
  remove(name: string): Promise<void>;
9
+ withLock<T>(name: string, task: () => Promise<T>, options?: {
10
+ timeoutMs?: number;
11
+ staleMs?: number;
12
+ }): Promise<T>;
13
+ private cleanupStaleLock;
9
14
  }
10
15
  export declare function resolveStateDir(stateDir: string | undefined, cwd: string): string;
@@ -1,5 +1,22 @@
1
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
4
+ import process from "node:process";
5
+ const DEFAULT_LOCK_TIMEOUT_MS = 15_000;
6
+ const DEFAULT_LOCK_STALE_MS = 60_000;
7
+ const LOCK_POLL_INTERVAL_MS = 50;
8
+ function sleep(ms) {
9
+ return new Promise((resolve) => setTimeout(resolve, ms));
10
+ }
11
+ function isPidRunning(pid) {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
3
20
  export class StateStore {
4
21
  rootDir;
5
22
  constructor(rootDir) {
@@ -25,12 +42,74 @@ export class StateStore {
25
42
  async writeJson(name, value) {
26
43
  await this.ensure();
27
44
  const target = path.join(this.rootDir, name);
45
+ const tempTarget = `${target}.${process.pid}.${randomUUID()}.tmp`;
28
46
  const content = JSON.stringify(value, null, 2);
29
- await writeFile(target, `${content}\n`, "utf8");
47
+ await writeFile(tempTarget, `${content}\n`, "utf8");
48
+ await rename(tempTarget, target);
30
49
  }
31
50
  async remove(name) {
32
51
  await rm(path.join(this.rootDir, name), { force: true });
33
52
  }
53
+ async withLock(name, task, options = {}) {
54
+ await this.ensure();
55
+ const deadline = Date.now() + (options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS);
56
+ const staleMs = options.staleMs ?? DEFAULT_LOCK_STALE_MS;
57
+ const safeName = name.replace(/[\\/]/g, "-");
58
+ const lockDir = path.join(this.rootDir, `${safeName}.lock`);
59
+ const ownerPath = path.join(lockDir, "owner.json");
60
+ while (true) {
61
+ try {
62
+ await mkdir(lockDir);
63
+ const owner = {
64
+ pid: process.pid,
65
+ acquiredAt: new Date().toISOString(),
66
+ acquiredAtMs: Date.now()
67
+ };
68
+ await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, "utf8");
69
+ break;
70
+ }
71
+ catch (error) {
72
+ const lockExists = typeof error === "object"
73
+ && error !== null
74
+ && "code" in error
75
+ && error.code === "EEXIST";
76
+ if (!lockExists) {
77
+ throw error;
78
+ }
79
+ if (await this.cleanupStaleLock(lockDir, ownerPath, staleMs)) {
80
+ continue;
81
+ }
82
+ if (Date.now() >= deadline) {
83
+ throw new Error(`Timed out waiting for lock ${safeName}`);
84
+ }
85
+ await sleep(LOCK_POLL_INTERVAL_MS);
86
+ }
87
+ }
88
+ try {
89
+ return await task();
90
+ }
91
+ finally {
92
+ await rm(lockDir, { recursive: true, force: true });
93
+ }
94
+ }
95
+ async cleanupStaleLock(lockDir, ownerPath, staleMs) {
96
+ try {
97
+ const raw = await readFile(ownerPath, "utf8");
98
+ const owner = JSON.parse(raw);
99
+ const pid = Number(owner.pid);
100
+ const acquiredAtMs = typeof owner.acquiredAtMs === "number" ? owner.acquiredAtMs : NaN;
101
+ const ownerAlive = Number.isInteger(pid) && pid > 0 ? isPidRunning(pid) : false;
102
+ const isStale = Number.isFinite(acquiredAtMs) && Date.now() - acquiredAtMs > staleMs;
103
+ if (!ownerAlive || isStale) {
104
+ await rm(lockDir, { recursive: true, force: true });
105
+ return true;
106
+ }
107
+ }
108
+ catch {
109
+ // The owner file may not exist yet while another process is finalizing lock acquisition.
110
+ }
111
+ return false;
112
+ }
34
113
  }
35
114
  export function resolveStateDir(stateDir, cwd) {
36
115
  if (!stateDir) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kzheart_/mc-pilot",
3
- "version": "0.9.0",
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);
@@ -126,9 +141,17 @@ async function readJson(filePath) {
126
141
  return JSON.parse(await readFile(filePath, "utf8"));
127
142
  }
128
143
 
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);
144
+ function getLocalBuildArtifactPath(repoRoot, variant) {
145
+ const artifactName = `mct-client-mod-${variant.loader ?? "fabric"}-${variant.minecraftVersion}.jar`;
146
+ const gradleModule = variant.gradleModule || `version-${variant.minecraftVersion}`;
147
+ return {
148
+ artifactName,
149
+ sourceJar: path.join(repoRoot, "client-mod", gradleModule, "build", "libs", artifactName)
150
+ };
151
+ }
152
+
153
+ async function syncBuiltMod(instanceRoot, repoRoot, variant) {
154
+ const { artifactName, sourceJar } = getLocalBuildArtifactPath(repoRoot, variant);
132
155
  const targetDir = path.join(instanceRoot, "minecraft", "mods");
133
156
  const targetJar = path.join(targetDir, artifactName);
134
157
 
@@ -162,7 +185,7 @@ async function syncConfiguredMod(gameDir) {
162
185
  await copyFile(sourceJar, targetJar);
163
186
  }
164
187
 
165
- async function ensureAutomationOptions(gameDir, server) {
188
+ async function ensureAutomationOptions(gameDir, server, mute) {
166
189
  const optionsPath = path.join(gameDir, "options.txt");
167
190
  const values = new Map();
168
191
 
@@ -183,6 +206,23 @@ async function ensureAutomationOptions(gameDir, server) {
183
206
  values.set("joinedFirstServer", "true");
184
207
  values.set("tutorialStep", "none");
185
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
+ }
186
226
  if (server) {
187
227
  values.set("lastServer", server);
188
228
  }
@@ -193,7 +233,7 @@ async function ensureAutomationOptions(gameDir, server) {
193
233
  }
194
234
 
195
235
  async function buildLaunchSpec(options) {
196
- const repoRoot = process.cwd();
236
+ const repoRoot = path.resolve(__dirname, "..", "..");
197
237
  const defaultVariant = getDefaultVariant();
198
238
  const minecraftVersion = process.env.MCT_CLIENT_VERSION || options["minecraft-version"] || defaultVariant.minecraftVersion;
199
239
  const modVariantId = process.env.MCT_CLIENT_MOD_VARIANT || options["mod-variant"] || `${minecraftVersion}-fabric`;
@@ -206,7 +246,8 @@ async function buildLaunchSpec(options) {
206
246
  const nativesDir = options["natives-dir"] || path.join(instanceRoot, "natives");
207
247
  const gameDir = path.join(instanceRoot, "minecraft");
208
248
  const packMeta = await readJson(path.join(instanceRoot, "mmc-pack.json"));
209
- await syncBuiltMod(instanceRoot, repoRoot, modVariantId);
249
+ const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
250
+ await syncBuiltMod(instanceRoot, repoRoot, selectedVariant);
210
251
  const componentMetas = new Map();
211
252
  for (const component of packMeta.components) {
212
253
  const componentMetaPath = path.join(metaRoot, component.uid, `${component.version}.json`);
@@ -255,7 +296,7 @@ async function buildLaunchSpec(options) {
255
296
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
256
297
  const accountUuid = offlineUuid(accountName);
257
298
  const server = process.env.MCT_CLIENT_SERVER || "";
258
- await ensureAutomationOptions(gameDir, server);
299
+ await ensureAutomationOptions(gameDir, server, mute);
259
300
  const [serverHost, serverPort = "25565"] = server.split(":");
260
301
  const classpath = [
261
302
  path.join(librariesRoot, mainJarPath),
@@ -308,12 +349,13 @@ async function buildManifestLaunchSpec(options) {
308
349
 
309
350
  const manifest = await readJson(manifestPath);
310
351
  const gameDir = manifest.gameDir;
352
+ const mute = parseOptionalBoolean(process.env.MCT_CLIENT_MUTE);
311
353
  await syncConfiguredMod(gameDir);
312
354
 
313
355
  const accountName = process.env.MCT_CLIENT_ACCOUNT || options.account || "TEST1";
314
356
  const accountUuid = offlineUuid(accountName);
315
357
  const server = process.env.MCT_CLIENT_SERVER || "";
316
- await ensureAutomationOptions(gameDir, server);
358
+ await ensureAutomationOptions(gameDir, server, mute);
317
359
  const [serverHost, serverPort = "25565"] = server.split(":");
318
360
  const substitutions = {
319
361
  auth_player_name: accountName,