@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.
- package/data/variants.json +24 -21
- package/dist/commands/client.js +22 -73
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +211 -0
- package/dist/download/VersionMatrix.d.ts +1 -15
- package/dist/download/VersionMatrix.js +23 -19
- package/dist/download/client/ClientDownloader.d.ts +2 -2
- package/dist/download/client/ClientDownloader.js +36 -8
- package/dist/download/client/FabricRuntimeDownloader.d.ts +2 -0
- package/dist/download/client/FabricRuntimeDownloader.js +47 -17
- package/dist/index.js +2 -0
- package/dist/instance/ClientInstanceManager.d.ts +3 -0
- package/dist/instance/ClientInstanceManager.js +108 -98
- package/dist/instance/ServerInstanceManager.d.ts +5 -0
- package/dist/instance/ServerInstanceManager.js +85 -3
- package/dist/util/command-log.d.ts +12 -0
- package/dist/util/command-log.js +13 -0
- package/dist/util/global-state.d.ts +4 -0
- package/dist/util/global-state.js +22 -0
- package/dist/util/instance-types.d.ts +1 -0
- package/dist/util/net.d.ts +1 -0
- package/dist/util/net.js +14 -12
- package/dist/util/state.d.ts +5 -0
- package/dist/util/state.js +81 -2
- package/package.json +3 -1
- package/scripts/launch-fabric-client.mjs +50 -8
|
@@ -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
|
}
|
package/dist/util/net.d.ts
CHANGED
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
|
-
|
|
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,
|
package/dist/util/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/util/state.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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.
|
|
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
|
-
|
|
130
|
-
const artifactName = `mct-client-mod-${
|
|
131
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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,
|