@bkm016/calcite 0.1.0 → 0.3.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.
- package/README.md +222 -15
- package/dist/accounts.d.ts +23 -4
- package/dist/accounts.js +120 -40
- package/dist/accounts.js.map +1 -1
- package/dist/cli.js +114 -6
- package/dist/cli.js.map +1 -1
- package/dist/client-core.d.ts +68 -0
- package/dist/client-core.js +556 -0
- package/dist/client-core.js.map +1 -0
- package/dist/client.d.ts +107 -213
- package/dist/client.js +114 -579
- package/dist/client.js.map +1 -1
- package/dist/display.js +42 -13
- package/dist/display.js.map +1 -1
- package/dist/hmc.d.ts +31 -11
- package/dist/hmc.js +68 -30
- package/dist/hmc.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +48 -0
- package/dist/install.js +119 -0
- package/dist/install.js.map +1 -0
- package/dist/java.d.ts +8 -2
- package/dist/java.js +14 -10
- package/dist/java.js.map +1 -1
- package/dist/loaders.d.ts +41 -0
- package/dist/loaders.js +86 -0
- package/dist/loaders.js.map +1 -0
- package/dist/mcp.js +163 -6
- package/dist/mcp.js.map +1 -1
- package/dist/mods.d.ts +23 -0
- package/dist/mods.js +164 -0
- package/dist/mods.js.map +1 -0
- package/dist/mojang.d.ts +0 -16
- package/dist/mojang.js +1 -37
- package/dist/mojang.js.map +1 -1
- package/dist/names.d.ts +53 -0
- package/dist/names.js +267 -0
- package/dist/names.js.map +1 -0
- package/dist/net.d.ts +5 -0
- package/dist/net.js +41 -1
- package/dist/net.js.map +1 -1
- package/dist/paths.d.ts +5 -3
- package/dist/paths.js +1 -0
- package/dist/paths.js.map +1 -1
- package/dist/probe-server.d.ts +1 -1
- package/dist/probe-server.js +5 -1
- package/dist/probe-server.js.map +1 -1
- package/dist/types.d.ts +265 -0
- package/dist/types.js +25 -0
- package/dist/types.js.map +1 -0
- package/dist/zip.d.ts +5 -0
- package/dist/zip.js +65 -0
- package/dist/zip.js.map +1 -0
- package/package.json +1 -1
- package/vendor/calcite-probe.jar +0 -0
package/dist/client.js
CHANGED
|
@@ -1,579 +1,16 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { accountsLock, listAccounts } from './accounts.js';
|
|
8
|
-
import { acquireDisplay, which } from './display.js';
|
|
9
|
-
import { ensureHmc, ensureHmcHome, killTree, runHmc } from './hmc.js';
|
|
10
|
-
import { ensureJava } from './java.js';
|
|
11
|
-
import { acquireLock, withLock } from './lock.js';
|
|
12
|
-
import { logger } from './log.js';
|
|
13
|
-
import { ensureClientJar, getVersionJson, requiredJavaMajor, resolveNames, resolveVersion, supportsQuickPlay } from './mojang.js';
|
|
14
|
-
import { defaultOptions, writeOptions } from './options.js';
|
|
15
|
-
import { resolvePaths } from './paths.js';
|
|
16
|
-
import { ProbeError, ProbeServer } from './probe-server.js';
|
|
17
|
-
export class CalciteError extends Error {
|
|
18
|
-
code;
|
|
19
|
-
constructor(code, message) {
|
|
20
|
-
super(message);
|
|
21
|
-
this.code = code;
|
|
22
|
-
this.name = 'CalciteError';
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
const NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
26
|
-
const MAX_LOG = 5000;
|
|
27
|
-
const MAX_CHAT = 1000;
|
|
28
|
-
export function parseServer(server) {
|
|
29
|
-
if (typeof server !== 'string')
|
|
30
|
-
return { host: server.host, port: server.port || 25565 };
|
|
31
|
-
const s = server.trim();
|
|
32
|
-
const v6 = /^\[([^\]]+)\](?::(\d+))?$/.exec(s);
|
|
33
|
-
if (v6)
|
|
34
|
-
return { host: v6[1], port: v6[2] ? Number(v6[2]) : 25565 };
|
|
35
|
-
const idx = s.lastIndexOf(':');
|
|
36
|
-
if (idx > 0 && s.indexOf(':') === idx)
|
|
37
|
-
return { host: s.slice(0, idx), port: Number(s.slice(idx + 1)) };
|
|
38
|
-
return { host: s, port: 25565 };
|
|
39
|
-
}
|
|
40
|
-
function probeJarSource() {
|
|
41
|
-
return process.env.CALCITE_PROBE_JAR || fileURLToPath(new URL('../vendor/calcite-probe.jar', import.meta.url));
|
|
42
|
-
}
|
|
43
|
-
/** Copies the probe jar to the space-free probe directory (content-addressed). */
|
|
44
|
-
async function installProbe(paths) {
|
|
45
|
-
const source = await readFile(probeJarSource());
|
|
46
|
-
const hash = createHash('sha256').update(source).digest('hex').slice(0, 12);
|
|
47
|
-
const target = join(paths.probe, `calcite-probe-${hash}.jar`);
|
|
48
|
-
try {
|
|
49
|
-
await stat(target);
|
|
50
|
-
}
|
|
51
|
-
catch {
|
|
52
|
-
await mkdir(paths.probe, { recursive: true });
|
|
53
|
-
const tmp = `${target}.${process.pid}.tmp`;
|
|
54
|
-
await writeFile(tmp, source);
|
|
55
|
-
await copyFile(tmp, target);
|
|
56
|
-
await rm(tmp, { force: true });
|
|
57
|
-
}
|
|
58
|
-
return target;
|
|
59
|
-
}
|
|
60
|
-
async function tcpReachable(host, port, timeoutMs = 3000) {
|
|
61
|
-
return new Promise((resolve) => {
|
|
62
|
-
const socket = new Socket();
|
|
63
|
-
const done = (ok) => {
|
|
64
|
-
socket.destroy();
|
|
65
|
-
resolve(ok);
|
|
66
|
-
};
|
|
67
|
-
socket.setTimeout(timeoutMs, () => done(false));
|
|
68
|
-
socket.once('error', () => done(false));
|
|
69
|
-
socket.connect(port, host, () => done(true));
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
/** Screens shown while joining or changing dimension; the player is not playable yet. */
|
|
73
|
-
const LOADING_SCREEN = /LevelLoading|ReceivingLevel|ProgressScreen|ConnectScreen|GenericMessage|DownloadingTerrain/;
|
|
74
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
75
|
-
/** Offline username derived from a client name (3-16 chars of [A-Za-z0-9_]). */
|
|
76
|
-
export function defaultUsername(name) {
|
|
77
|
-
return name.replace(/[^A-Za-z0-9_]/g, '_').slice(0, 16).padEnd(3, '_');
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Downloads everything a version needs (client, libraries, natives, assets, mappings, Java, HeadlessMC) without
|
|
81
|
-
* starting the game, so later launches start fast and work offline.
|
|
82
|
-
*/
|
|
83
|
-
export async function installVersion(spec, opts = {}) {
|
|
84
|
-
const paths = opts.paths ?? resolvePaths();
|
|
85
|
-
const version = await resolveVersion(paths, spec);
|
|
86
|
-
const json = await getVersionJson(paths, version);
|
|
87
|
-
const clientJar = await ensureClientJar(paths, json);
|
|
88
|
-
const names = await resolveNames(paths, json, clientJar);
|
|
89
|
-
const java = await ensureJava(paths, requiredJavaMajor(json), { javaPath: opts.javaPath, allowDownload: opts.allowJavaDownload });
|
|
90
|
-
const hmcJar = await ensureHmc(paths);
|
|
91
|
-
await ensureHmcHome(paths);
|
|
92
|
-
await installProbe(paths);
|
|
93
|
-
const run = runHmc({
|
|
94
|
-
javaPath: java.path,
|
|
95
|
-
hmcJar,
|
|
96
|
-
paths,
|
|
97
|
-
props: { 'hmc.mcdir': paths.minecraft, 'hmc.java.versions': java.path, 'hmc.offline': 'true' },
|
|
98
|
-
command: ['launch', json.id, '-prepare', '-lwjgl'],
|
|
99
|
-
onLine: opts.onLine,
|
|
100
|
-
});
|
|
101
|
-
const code = await run.exited;
|
|
102
|
-
if (code !== 0)
|
|
103
|
-
throw new CalciteError('install_failed', `HeadlessMC exited with code ${code} while downloading ${json.id}`);
|
|
104
|
-
return { id: json.id, java: java.version, probe: names.kind === 'unsupported' ? names.reason : 'supported' };
|
|
105
|
-
}
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { ClientCore, sleep } from './client-core.js';
|
|
3
|
+
import { ProbeError } from './probe-server.js';
|
|
4
|
+
import { CalciteError, } from './types.js';
|
|
5
|
+
export * from './types.js';
|
|
6
|
+
export { installVersion } from './install.js';
|
|
106
7
|
/**
|
|
107
8
|
* One Minecraft client controlled by Calcite.
|
|
108
9
|
*
|
|
109
|
-
* Events: 'phase' (Phase), 'log' (LogLine), 'chat' (ChatLine), 'state' (GameState), '
|
|
10
|
+
* Events: 'phase' (Phase), 'log' (LogLine), 'chat' (ChatLine), 'state' (GameState), 'extension' (ExtensionEvent),
|
|
11
|
+
* 'exit' (code).
|
|
110
12
|
*/
|
|
111
|
-
export class Client extends
|
|
112
|
-
options;
|
|
113
|
-
paths;
|
|
114
|
-
gameDir;
|
|
115
|
-
render;
|
|
116
|
-
account;
|
|
117
|
-
server;
|
|
118
|
-
phaseValue = 'idle';
|
|
119
|
-
logs = [];
|
|
120
|
-
chats = [];
|
|
121
|
-
seq = 0;
|
|
122
|
-
probe = null;
|
|
123
|
-
run = null;
|
|
124
|
-
display = null;
|
|
125
|
-
releaseInstance = null;
|
|
126
|
-
probeConfig = null;
|
|
127
|
-
java = null;
|
|
128
|
-
versionJson = null;
|
|
129
|
-
names = null;
|
|
130
|
-
headlessValue = false;
|
|
131
|
-
lastState;
|
|
132
|
-
lastError;
|
|
133
|
-
/** Error code of a failure that relaunching cannot fix (reported by start() instead of "crashed"). */
|
|
134
|
-
fatalCode;
|
|
135
|
-
startedAt;
|
|
136
|
-
reconnects = 0;
|
|
137
|
-
stopping = false;
|
|
138
|
-
poller = null;
|
|
139
|
-
inGameSince = 0;
|
|
140
|
-
relaunching = false;
|
|
141
|
-
everInGame = false;
|
|
142
|
-
joinRequested = false;
|
|
143
|
-
log;
|
|
144
|
-
constructor(options) {
|
|
145
|
-
super();
|
|
146
|
-
if (!NAME_RE.test(options.name)) {
|
|
147
|
-
throw new CalciteError('bad_name', `Invalid client name "${options.name}" (allowed: letters, digits, - _ .)`);
|
|
148
|
-
}
|
|
149
|
-
this.options = options;
|
|
150
|
-
this.paths = options.paths ?? resolvePaths();
|
|
151
|
-
this.gameDir = join(this.paths.instances, options.name);
|
|
152
|
-
this.render = options.render ?? 'on-demand';
|
|
153
|
-
this.account = options.account ?? { type: 'offline', username: defaultUsername(options.name) };
|
|
154
|
-
if (this.account.type === 'offline' && !/^[A-Za-z0-9_]{3,16}$/.test(this.account.username)) {
|
|
155
|
-
throw new CalciteError('bad_username', `Offline username must be 3-16 letters, digits or underscores: "${this.account.username}"`);
|
|
156
|
-
}
|
|
157
|
-
this.server = options.server ? parseServer(options.server) : undefined;
|
|
158
|
-
this.log = logger(`client:${options.name}`);
|
|
159
|
-
this.on('error', () => undefined); // never crash the host process on an unhandled 'error' event
|
|
160
|
-
}
|
|
161
|
-
get phase() {
|
|
162
|
-
return this.phaseValue;
|
|
163
|
-
}
|
|
164
|
-
get headless() {
|
|
165
|
-
return this.headlessValue;
|
|
166
|
-
}
|
|
167
|
-
setPhase(phase) {
|
|
168
|
-
if (phase === this.phaseValue)
|
|
169
|
-
return;
|
|
170
|
-
this.phaseValue = phase;
|
|
171
|
-
this.note(`phase → ${phase}`);
|
|
172
|
-
this.emit('phase', phase);
|
|
173
|
-
}
|
|
174
|
-
note(line) {
|
|
175
|
-
this.pushLog('calcite', line);
|
|
176
|
-
this.log.debug(line);
|
|
177
|
-
}
|
|
178
|
-
pushLog(source, line) {
|
|
179
|
-
const entry = { seq: ++this.seq, time: Date.now(), source, line };
|
|
180
|
-
this.logs.push(entry);
|
|
181
|
-
if (this.logs.length > MAX_LOG)
|
|
182
|
-
this.logs.splice(0, this.logs.length - MAX_LOG);
|
|
183
|
-
this.emit('log', entry);
|
|
184
|
-
if (source === 'game') {
|
|
185
|
-
const chat = /\[CHAT\] (.*)$/.exec(line);
|
|
186
|
-
if (chat) {
|
|
187
|
-
const c = { seq: entry.seq, time: entry.time, message: chat[1] };
|
|
188
|
-
this.chats.push(c);
|
|
189
|
-
if (this.chats.length > MAX_CHAT)
|
|
190
|
-
this.chats.splice(0, this.chats.length - MAX_CHAT);
|
|
191
|
-
this.emit('chat', c);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
// ------------------------------------------------------------------ lifecycle
|
|
196
|
-
/**
|
|
197
|
-
* Prepares everything (Java, game files, mappings), launches the client and resolves once it is in a world
|
|
198
|
-
* (or on the title screen when no server is configured).
|
|
199
|
-
*/
|
|
200
|
-
async start() {
|
|
201
|
-
if (!['idle', 'stopped', 'crashed'].includes(this.phaseValue)) {
|
|
202
|
-
throw new CalciteError('already_running', `Client "${this.options.name}" is already ${this.phaseValue}`);
|
|
203
|
-
}
|
|
204
|
-
this.stopping = false;
|
|
205
|
-
this.everInGame = false;
|
|
206
|
-
this.reconnects = 0;
|
|
207
|
-
this.lastError = undefined;
|
|
208
|
-
this.fatalCode = undefined;
|
|
209
|
-
const timeout = this.options.startTimeoutMs ?? 15 * 60_000;
|
|
210
|
-
try {
|
|
211
|
-
this.releaseInstance = await acquireLock(join(this.gameDir, '.calcite.lock'), { timeoutMs: 0 });
|
|
212
|
-
}
|
|
213
|
-
catch {
|
|
214
|
-
throw new CalciteError('instance_busy', `Client "${this.options.name}" is already running in another process`);
|
|
215
|
-
}
|
|
216
|
-
try {
|
|
217
|
-
await this.prepare();
|
|
218
|
-
await this.launch();
|
|
219
|
-
await this.waitUntilStarted(timeout);
|
|
220
|
-
return this.status();
|
|
221
|
-
}
|
|
222
|
-
catch (err) {
|
|
223
|
-
this.lastError = err.message;
|
|
224
|
-
await this.shutdown('crashed');
|
|
225
|
-
throw err;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
/** Downloads and verifies everything needed to launch (idempotent). */
|
|
229
|
-
async prepare() {
|
|
230
|
-
this.setPhase('preparing');
|
|
231
|
-
const version = await resolveVersion(this.paths, this.options.version);
|
|
232
|
-
this.versionJson = await getVersionJson(this.paths, version);
|
|
233
|
-
const clientJar = await ensureClientJar(this.paths, this.versionJson);
|
|
234
|
-
this.names = await resolveNames(this.paths, this.versionJson, clientJar);
|
|
235
|
-
if (this.names.kind === 'unsupported')
|
|
236
|
-
this.note(`probe disabled: ${this.names.reason}`);
|
|
237
|
-
this.java = await ensureJava(this.paths, requiredJavaMajor(this.versionJson), {
|
|
238
|
-
javaPath: this.options.javaPath,
|
|
239
|
-
allowDownload: this.options.allowJavaDownload,
|
|
240
|
-
});
|
|
241
|
-
this.note(`java ${this.java.version} (${this.java.path}) for Minecraft ${this.versionJson.id}`);
|
|
242
|
-
}
|
|
243
|
-
resolveHeadless() {
|
|
244
|
-
if (this.render === 'off')
|
|
245
|
-
return true;
|
|
246
|
-
if (this.account.type === 'offline' && process.platform !== 'linux') {
|
|
247
|
-
this.note('offline accounts can only render on Linux (HeadlessMC policy); running headless — use a Microsoft account for screenshots');
|
|
248
|
-
return true;
|
|
249
|
-
}
|
|
250
|
-
return false;
|
|
251
|
-
}
|
|
252
|
-
async launch() {
|
|
253
|
-
const json = this.versionJson;
|
|
254
|
-
const java = this.java;
|
|
255
|
-
const hmcJar = await ensureHmc(this.paths);
|
|
256
|
-
await ensureHmcHome(this.paths);
|
|
257
|
-
const probeJar = await installProbe(this.paths);
|
|
258
|
-
this.headlessValue = this.resolveHeadless();
|
|
259
|
-
if (this.server) {
|
|
260
|
-
this.setPhase('waiting_for_server');
|
|
261
|
-
const deadline = Date.now() + (this.options.waitForServerMs ?? 120_000);
|
|
262
|
-
while (!(await tcpReachable(this.server.host, this.server.port))) {
|
|
263
|
-
if (this.stopping)
|
|
264
|
-
throw new CalciteError('stopped', 'Stopped while waiting for the server');
|
|
265
|
-
if (Date.now() > deadline)
|
|
266
|
-
throw new CalciteError('server_unreachable', `Server ${this.server.host}:${this.server.port} is not reachable`);
|
|
267
|
-
await sleep(2000);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
// probe endpoint + config (paths inside may contain spaces; the config file itself lives in a safe dir)
|
|
271
|
-
this.probe = new ProbeServer();
|
|
272
|
-
await this.probe.listen();
|
|
273
|
-
this.probe.on('connected', () => this.onProbeConnected());
|
|
274
|
-
this.probe.on('disconnected', () => this.note('probe disconnected'));
|
|
275
|
-
this.probeConfig = join(this.paths.probe, `${this.options.name}-${randomBytes(6).toString('hex')}.properties`);
|
|
276
|
-
const cfg = [
|
|
277
|
-
`port=${this.probe.port}`,
|
|
278
|
-
`token=${this.probe.token}`,
|
|
279
|
-
`mappings=${this.names?.kind === 'mappings' ? this.names.file.replace(/\\/g, '\\\\').replace(/:/g, '\\:') : ''}`,
|
|
280
|
-
`headless=${this.headlessValue}`,
|
|
281
|
-
`render=${this.render === 'always' ? 'on' : 'off'}`,
|
|
282
|
-
'exitOnDisconnect=true',
|
|
283
|
-
'',
|
|
284
|
-
].join('\n');
|
|
285
|
-
await mkdir(this.paths.probe, { recursive: true });
|
|
286
|
-
await writeFile(this.probeConfig, cfg, { mode: 0o600 });
|
|
287
|
-
await writeOptions(this.gameDir, defaultOptions({ renderDistance: this.options.renderDistance, maxFps: this.options.maxFps }));
|
|
288
|
-
let displayEnv = {};
|
|
289
|
-
let virtualDisplay = false;
|
|
290
|
-
if (!this.headlessValue) {
|
|
291
|
-
// offline accounts may only render on a virtual display (HeadlessMC); on-demand clients stay invisible when Xvfb exists
|
|
292
|
-
this.display = await acquireDisplay({ forceVirtual: this.account.type === 'offline' || (this.render === 'on-demand' && which('Xvfb') !== null) });
|
|
293
|
-
displayEnv = this.display.env;
|
|
294
|
-
virtualDisplay = this.display.virtual;
|
|
295
|
-
}
|
|
296
|
-
const jvmArgs = [`-Xmx${this.options.memory ?? '2G'}`, ...(this.options.jvmArgs ?? [])];
|
|
297
|
-
if (this.names?.kind !== 'unsupported')
|
|
298
|
-
jvmArgs.unshift(`-javaagent:${probeJar}=${this.probeConfig}`);
|
|
299
|
-
const gameArgs = [...(this.options.gameArgs ?? [])];
|
|
300
|
-
if (this.server) {
|
|
301
|
-
if (supportsQuickPlay(json))
|
|
302
|
-
gameArgs.unshift('--quickPlayMultiplayer', `${this.server.host}:${this.server.port}`);
|
|
303
|
-
else
|
|
304
|
-
gameArgs.unshift('--server', this.server.host, '--port', String(this.server.port));
|
|
305
|
-
}
|
|
306
|
-
for (const arg of [...jvmArgs, ...gameArgs]) {
|
|
307
|
-
if (/\s/.test(arg))
|
|
308
|
-
throw new CalciteError('bad_argument', `JVM/game arguments must not contain spaces: "${arg}"`);
|
|
309
|
-
}
|
|
310
|
-
const offline = this.account.type === 'offline';
|
|
311
|
-
const props = {
|
|
312
|
-
'hmc.gamedir': this.gameDir,
|
|
313
|
-
'hmc.mcdir': this.paths.minecraft,
|
|
314
|
-
'hmc.java.versions': java.path,
|
|
315
|
-
'hmc.offline': String(offline),
|
|
316
|
-
'hmc.always.lwjgl.flag': String(this.headlessValue),
|
|
317
|
-
'hmc.check.xvfb': String(virtualDisplay),
|
|
318
|
-
'hmc.jvmargs': jvmArgs.join(' '),
|
|
319
|
-
'hmc.gameargs': gameArgs.join(' '),
|
|
320
|
-
'hmc.exit.on.failed.command': 'true',
|
|
321
|
-
};
|
|
322
|
-
if (this.account.type === 'offline')
|
|
323
|
-
props['hmc.offline.username'] = this.account.username;
|
|
324
|
-
const command = ['launch', json.id, '-jndi', '-lookup', ...(this.headlessValue ? ['-lwjgl', '-paulscode'] : [])];
|
|
325
|
-
const env = { ...process.env, ...displayEnv };
|
|
326
|
-
this.setPhase('starting');
|
|
327
|
-
this.joinRequested = false;
|
|
328
|
-
this.startedAt = Date.now();
|
|
329
|
-
const spawnGame = async () => {
|
|
330
|
-
let launched;
|
|
331
|
-
const launchedPromise = new Promise((r) => (launched = r));
|
|
332
|
-
// 26.x+: when no graphics backend can be created the game opens a modal error box and blocks forever
|
|
333
|
-
const backendErrors = [];
|
|
334
|
-
let backendTimer;
|
|
335
|
-
const noBackend = () => {
|
|
336
|
-
if (run !== this.run || this.stopping)
|
|
337
|
-
return;
|
|
338
|
-
const hint = process.platform === 'linux'
|
|
339
|
-
? ' On Linux without a GPU install Mesa EGL (apt install libegl1 libegl-mesa0) or Vulkan (apt install mesa-vulkan-drivers), or use render "off".'
|
|
340
|
-
: ' Update the graphics drivers, or use render "off".';
|
|
341
|
-
this.fatalCode = 'renderer_unavailable';
|
|
342
|
-
this.lastError = `No graphics backend could be created: ${backendErrors.join('; ')}.${hint}`;
|
|
343
|
-
this.note(this.lastError);
|
|
344
|
-
this.setPhase('crashed');
|
|
345
|
-
void killTree(run.child);
|
|
346
|
-
};
|
|
347
|
-
this.run = runHmc({
|
|
348
|
-
javaPath: java.path,
|
|
349
|
-
hmcJar,
|
|
350
|
-
paths: this.paths,
|
|
351
|
-
props,
|
|
352
|
-
command,
|
|
353
|
-
env,
|
|
354
|
-
onLine: (line) => {
|
|
355
|
-
this.pushLog('game', line);
|
|
356
|
-
if (/Launching version|Minecraft exited|LWJGL Version|Backend library/i.test(line))
|
|
357
|
-
launched();
|
|
358
|
-
const failure = /BackendCreationException: (.*)/.exec(line);
|
|
359
|
-
if (failure) {
|
|
360
|
-
backendErrors.push(failure[1].trim());
|
|
361
|
-
clearTimeout(backendTimer);
|
|
362
|
-
backendTimer = setTimeout(noBackend, 20_000);
|
|
363
|
-
}
|
|
364
|
-
else if (/Using graphics backend/.test(line))
|
|
365
|
-
clearTimeout(backendTimer);
|
|
366
|
-
},
|
|
367
|
-
});
|
|
368
|
-
const run = this.run;
|
|
369
|
-
void run.exited.then(() => clearTimeout(backendTimer));
|
|
370
|
-
void run.exited.then((code) => this.onGameExit(run, code));
|
|
371
|
-
// give HeadlessMC time to read the primary account before another launch may switch it
|
|
372
|
-
await Promise.race([launchedPromise, run.exited, sleep(60_000)]);
|
|
373
|
-
};
|
|
374
|
-
if (this.account.type === 'microsoft') {
|
|
375
|
-
const wanted = this.account.name;
|
|
376
|
-
await withLock(accountsLock(this.paths), async () => {
|
|
377
|
-
const accounts = await listAccounts(this.paths);
|
|
378
|
-
if (!accounts.length)
|
|
379
|
-
throw new CalciteError('not_logged_in', 'No Microsoft account is stored; run `calcite login` first');
|
|
380
|
-
const match = wanted ? accounts.find((a) => a.name.toLowerCase() === wanted.toLowerCase()) : accounts[0];
|
|
381
|
-
if (!match)
|
|
382
|
-
throw new CalciteError('unknown_account', `No stored Microsoft account named "${wanted}" (have: ${accounts.map((a) => a.name).join(', ')})`);
|
|
383
|
-
if (!match.primary) {
|
|
384
|
-
const select = runHmc({ javaPath: java.path, hmcJar, paths: this.paths, props: {}, command: ['account', match.name] });
|
|
385
|
-
await select.exited;
|
|
386
|
-
}
|
|
387
|
-
await spawnGame();
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
else {
|
|
391
|
-
await spawnGame();
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
async onProbeConnected() {
|
|
395
|
-
this.note('probe connected');
|
|
396
|
-
if (this.phaseValue === 'starting' || this.phaseValue === 'reconnecting')
|
|
397
|
-
this.setPhase('connecting');
|
|
398
|
-
if (!this.poller) {
|
|
399
|
-
this.poller = setInterval(() => void this.poll(), 1000);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
async poll() {
|
|
403
|
-
if (!this.probe?.connected)
|
|
404
|
-
return;
|
|
405
|
-
let state;
|
|
406
|
-
try {
|
|
407
|
-
state = await this.probe.request('state', {}, 10_000);
|
|
408
|
-
}
|
|
409
|
-
catch (err) {
|
|
410
|
-
if (err instanceof ProbeError && err.code === 'not_ready')
|
|
411
|
-
return;
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
414
|
-
const prev = this.lastState;
|
|
415
|
-
this.lastState = state;
|
|
416
|
-
if (JSON.stringify(prev) !== JSON.stringify(state))
|
|
417
|
-
this.emit('state', state);
|
|
418
|
-
if (state.inGame && !state.loading && !LOADING_SCREEN.test(state.screen ?? '')) {
|
|
419
|
-
if (this.phaseValue !== 'in_game') {
|
|
420
|
-
this.inGameSince = Date.now();
|
|
421
|
-
this.everInGame = true;
|
|
422
|
-
this.setPhase('in_game');
|
|
423
|
-
}
|
|
424
|
-
else if (this.reconnects > 0 && Date.now() - this.inGameSince > 60_000) {
|
|
425
|
-
this.reconnects = 0; // stable again
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
else if (this.server &&
|
|
429
|
-
!this.joinRequested &&
|
|
430
|
-
this.phaseValue === 'connecting' &&
|
|
431
|
-
state.screen === 'TitleScreen' &&
|
|
432
|
-
!state.loading) {
|
|
433
|
-
// Pre-1.20 versions may ignore --server (e.g. 1.16.4+ when multiplayer privileges cannot be checked)
|
|
434
|
-
this.joinRequested = true;
|
|
435
|
-
this.note(`joining ${this.server.host}:${this.server.port} from the title screen`);
|
|
436
|
-
this.probe.request('connect', { host: this.server.host, port: this.server.port }).catch((err) => this.note(`join failed: ${err.message}`));
|
|
437
|
-
}
|
|
438
|
-
else if (state.screen === 'DisconnectedScreen' && this.phaseValue !== 'disconnected' && this.phaseValue !== 'reconnecting') {
|
|
439
|
-
this.lastError = `Disconnected: ${state.disconnectReason ?? 'unknown reason'}`;
|
|
440
|
-
this.setPhase('disconnected');
|
|
441
|
-
void this.maybeReconnect();
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
reconnectLimit() {
|
|
445
|
-
const r = this.options.reconnect;
|
|
446
|
-
if (r === false)
|
|
447
|
-
return 0;
|
|
448
|
-
if (typeof r === 'object')
|
|
449
|
-
return r.maxAttempts ?? 10;
|
|
450
|
-
return 10;
|
|
451
|
-
}
|
|
452
|
-
async maybeReconnect() {
|
|
453
|
-
if (this.stopping || this.relaunching || !this.server)
|
|
454
|
-
return;
|
|
455
|
-
if (this.reconnects >= this.reconnectLimit()) {
|
|
456
|
-
this.note(`not reconnecting: ${this.reconnects} attempts used`);
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
this.relaunching = true;
|
|
460
|
-
try {
|
|
461
|
-
this.reconnects++;
|
|
462
|
-
const delay = Math.min(5000 * 2 ** (this.reconnects - 1), 60_000);
|
|
463
|
-
this.setPhase('reconnecting');
|
|
464
|
-
this.note(`reconnecting in ${delay}ms (attempt ${this.reconnects})`);
|
|
465
|
-
if (this.run)
|
|
466
|
-
await killTree(this.run.child);
|
|
467
|
-
await sleep(delay);
|
|
468
|
-
if (this.stopping)
|
|
469
|
-
return;
|
|
470
|
-
await this.cleanupRun();
|
|
471
|
-
await this.launch();
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
this.lastError = err.message;
|
|
475
|
-
this.setPhase('crashed');
|
|
476
|
-
}
|
|
477
|
-
finally {
|
|
478
|
-
this.relaunching = false;
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
onGameExit(run, code) {
|
|
482
|
-
if (run !== this.run)
|
|
483
|
-
return; // an older run
|
|
484
|
-
this.emit('exit', code);
|
|
485
|
-
this.note(`game process exited with code ${code}`);
|
|
486
|
-
if (this.stopping || this.relaunching || this.fatalCode)
|
|
487
|
-
return;
|
|
488
|
-
const tail = this.logs.filter((l) => l.source === 'game').slice(-15).map((l) => l.line).join('\n');
|
|
489
|
-
this.lastError = `Game exited with code ${code}${tail ? `:\n${tail}` : ''}`;
|
|
490
|
-
this.setPhase('crashed');
|
|
491
|
-
// a crash during the very first start is reported to start(); later crashes are retried
|
|
492
|
-
if (this.everInGame)
|
|
493
|
-
void this.maybeReconnect();
|
|
494
|
-
}
|
|
495
|
-
async waitUntilStarted(timeoutMs) {
|
|
496
|
-
const deadline = Date.now() + timeoutMs;
|
|
497
|
-
for (;;) {
|
|
498
|
-
const phase = this.phaseValue;
|
|
499
|
-
if (phase === 'in_game')
|
|
500
|
-
return;
|
|
501
|
-
if (!this.server && this.lastState?.ready && this.lastState.loading === false && phase === 'connecting')
|
|
502
|
-
return;
|
|
503
|
-
if (phase === 'crashed')
|
|
504
|
-
throw new CalciteError(this.fatalCode ?? 'crashed', this.lastError ?? 'The game crashed during startup');
|
|
505
|
-
if (phase === 'disconnected' && this.reconnectLimit() === 0)
|
|
506
|
-
throw new CalciteError('disconnected', this.lastError ?? 'Disconnected');
|
|
507
|
-
if (Date.now() > deadline)
|
|
508
|
-
throw new CalciteError('start_timeout', `Client did not start within ${timeoutMs}ms (phase ${phase})`);
|
|
509
|
-
await sleep(250);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
async cleanupRun() {
|
|
513
|
-
if (this.poller) {
|
|
514
|
-
clearInterval(this.poller);
|
|
515
|
-
this.poller = null;
|
|
516
|
-
}
|
|
517
|
-
if (this.run) {
|
|
518
|
-
await killTree(this.run.child);
|
|
519
|
-
this.run = null;
|
|
520
|
-
}
|
|
521
|
-
if (this.probe) {
|
|
522
|
-
await this.probe.close();
|
|
523
|
-
this.probe = null;
|
|
524
|
-
}
|
|
525
|
-
if (this.probeConfig) {
|
|
526
|
-
await rm(this.probeConfig, { force: true });
|
|
527
|
-
this.probeConfig = null;
|
|
528
|
-
}
|
|
529
|
-
this.display?.release();
|
|
530
|
-
this.display = null;
|
|
531
|
-
this.lastState = undefined;
|
|
532
|
-
}
|
|
533
|
-
async shutdown(final) {
|
|
534
|
-
this.stopping = true;
|
|
535
|
-
await this.cleanupRun();
|
|
536
|
-
if (this.releaseInstance) {
|
|
537
|
-
await this.releaseInstance();
|
|
538
|
-
this.releaseInstance = null;
|
|
539
|
-
}
|
|
540
|
-
this.setPhase(final);
|
|
541
|
-
}
|
|
542
|
-
/** Stops the game and releases all resources. */
|
|
543
|
-
async stop() {
|
|
544
|
-
if (this.phaseValue === 'stopped' || this.phaseValue === 'idle')
|
|
545
|
-
return;
|
|
546
|
-
this.setPhase('stopping');
|
|
547
|
-
await this.shutdown('stopped');
|
|
548
|
-
}
|
|
549
|
-
status() {
|
|
550
|
-
return {
|
|
551
|
-
name: this.options.name,
|
|
552
|
-
version: this.versionJson?.id ?? this.options.version,
|
|
553
|
-
phase: this.phaseValue,
|
|
554
|
-
render: this.render,
|
|
555
|
-
headless: this.headlessValue,
|
|
556
|
-
account: this.account,
|
|
557
|
-
server: this.server,
|
|
558
|
-
gameDir: this.gameDir,
|
|
559
|
-
pid: this.run?.child.pid,
|
|
560
|
-
java: this.java?.version,
|
|
561
|
-
startedAt: this.startedAt,
|
|
562
|
-
reconnects: this.reconnects,
|
|
563
|
-
lastError: this.lastError,
|
|
564
|
-
probeConnected: !!this.probe?.connected,
|
|
565
|
-
game: this.lastState,
|
|
566
|
-
};
|
|
567
|
-
}
|
|
568
|
-
// ------------------------------------------------------------------ operations
|
|
569
|
-
requireProbe() {
|
|
570
|
-
if (this.names?.kind === 'unsupported') {
|
|
571
|
-
throw new CalciteError('unsupported_version', this.names.reason);
|
|
572
|
-
}
|
|
573
|
-
if (!this.probe?.connected)
|
|
574
|
-
throw new CalciteError('not_connected', `Client "${this.options.name}" is not running (phase ${this.phaseValue})`);
|
|
575
|
-
return this.probe;
|
|
576
|
-
}
|
|
13
|
+
export class Client extends ClientCore {
|
|
577
14
|
async state() {
|
|
578
15
|
const state = await this.requireProbe().request('state');
|
|
579
16
|
this.lastState = state;
|
|
@@ -603,14 +40,92 @@ export class Client extends EventEmitter {
|
|
|
603
40
|
async respawn() {
|
|
604
41
|
await this.requireProbe().request('respawn');
|
|
605
42
|
}
|
|
43
|
+
// ------------------------------------------------------------------ actions
|
|
44
|
+
/** Turns the player to an absolute rotation (degrees; yaw 0 = south, pitch -90 = up) or towards a point. */
|
|
45
|
+
async look(to) {
|
|
46
|
+
return this.requireProbe().request('look', to);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Holds movement controls until changed (or for {@code ticks} game ticks, 20 per second). Pass false to release
|
|
50
|
+
* a control; {@link stop} releases all of them.
|
|
51
|
+
*/
|
|
52
|
+
async move(controls, opts = {}) {
|
|
53
|
+
return this.requireProbe().request('move', { ...controls, ticks: opts.ticks ?? 0 });
|
|
54
|
+
}
|
|
55
|
+
/** Releases every control and cancels a running walkTo/dig/held use. */
|
|
56
|
+
async stopActions() {
|
|
57
|
+
await this.requireProbe().request('stop');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Walks in a straight line to (x, z), jumping over single blocks and swimming. Resolves when within
|
|
61
|
+
* {@code range} blocks, or with {@code arrived: false} when stuck (no progress for 3 s) or timed out.
|
|
62
|
+
* There is no path finding: route around obstacles with intermediate points.
|
|
63
|
+
*/
|
|
64
|
+
async walkTo(x, z, opts = {}) {
|
|
65
|
+
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
66
|
+
return this.requireProbe().request('walk_to', { x, z, range: opts.range ?? 0.5, sprint: opts.sprint ?? true, timeoutMs }, timeoutMs + 15_000);
|
|
67
|
+
}
|
|
68
|
+
/** Left click: attacks the entity (facing it first) or whatever the crosshair points at. */
|
|
69
|
+
async attack(entityId) {
|
|
70
|
+
return this.requireProbe().request('attack', entityId === undefined ? {} : { entityId });
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Right click: interacts with an entity, a block (opening chests, pressing buttons, placing the held block
|
|
74
|
+
* against that face) or, with no target, whatever the crosshair points at / the held item in the air.
|
|
75
|
+
* {@code holdTicks} keeps the button pressed afterwards (eating, drinking, drawing a bow, shields).
|
|
76
|
+
*/
|
|
77
|
+
async use(target = {}) {
|
|
78
|
+
const { block, ...rest } = target;
|
|
79
|
+
return this.requireProbe().request('use', { ...rest, ...(block ?? {}) });
|
|
80
|
+
}
|
|
81
|
+
/** Mines a block like a player (holding left click, taking the real break time). */
|
|
82
|
+
async dig(block, opts = {}) {
|
|
83
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
84
|
+
return this.requireProbe().request('dig', { ...block, timeoutMs }, timeoutMs + 15_000);
|
|
85
|
+
}
|
|
86
|
+
/** The block at a position as the client sees it. */
|
|
87
|
+
async block(pos) {
|
|
88
|
+
return this.requireProbe().request('block', { ...pos });
|
|
89
|
+
}
|
|
90
|
+
/** What the crosshair points at. */
|
|
91
|
+
async target() {
|
|
92
|
+
return this.requireProbe().request('target');
|
|
93
|
+
}
|
|
94
|
+
async inventory() {
|
|
95
|
+
return this.requireProbe().request('inventory');
|
|
96
|
+
}
|
|
97
|
+
/** Selects hotbar slot 0-8. */
|
|
98
|
+
async selectSlot(slot) {
|
|
99
|
+
return this.requireProbe().request('select_slot', { slot });
|
|
100
|
+
}
|
|
101
|
+
/** The open container (chest, furnace, villager trade, ...) or the inventory; {@code waitMs} waits for one to open. */
|
|
102
|
+
async container(opts = {}) {
|
|
103
|
+
const waitMs = opts.waitMs ?? 0;
|
|
104
|
+
return this.requireProbe().request('container', { waitMs }, waitMs + 15_000);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Clicks a slot of the open container (or the inventory). Modes: pickup (default; button 0 left, 1 right),
|
|
108
|
+
* quick_move (shift click), swap (button = hotbar slot 0-8 or 40 for the offhand), clone, throw (button 1 =
|
|
109
|
+
* whole stack), quick_craft, pickup_all. Slot -999 is outside the window.
|
|
110
|
+
*/
|
|
111
|
+
async click(slot, opts = {}) {
|
|
112
|
+
return this.requireProbe().request('click', { slot, button: opts.button ?? 0, mode: opts.mode ?? 'pickup' });
|
|
113
|
+
}
|
|
114
|
+
async closeContainer() {
|
|
115
|
+
await this.requireProbe().request('close_container');
|
|
116
|
+
}
|
|
117
|
+
/** Drops one item (or the whole stack) from the selected hotbar slot. */
|
|
118
|
+
async drop(opts = {}) {
|
|
119
|
+
return this.requireProbe().request('drop', { all: !!opts.all });
|
|
120
|
+
}
|
|
606
121
|
async setRender(enabled) {
|
|
607
|
-
if (this.
|
|
122
|
+
if (this.headless)
|
|
608
123
|
throw new CalciteError('headless', 'This client runs without a renderer');
|
|
609
124
|
await this.requireProbe().request('render', { enabled });
|
|
610
125
|
}
|
|
611
126
|
/** Takes a screenshot and returns the PNG. With {@code keep} the file stays in the game's screenshots folder. */
|
|
612
127
|
async screenshot({ keep = false } = {}) {
|
|
613
|
-
if (this.
|
|
128
|
+
if (this.headless) {
|
|
614
129
|
throw new CalciteError('headless', this.render === 'off'
|
|
615
130
|
? 'This client was started with render "off"; start it with render "on-demand" to take screenshots'
|
|
616
131
|
: 'Screenshots need a renderer: offline accounts can only render on Linux (Xvfb); use a Microsoft account on Windows/macOS');
|
|
@@ -632,13 +147,28 @@ export class Client extends EventEmitter {
|
|
|
632
147
|
const lines = this.chats.filter((c) => c.seq > since);
|
|
633
148
|
return limit ? lines.slice(-limit) : lines;
|
|
634
149
|
}
|
|
150
|
+
/** Extension events received after {@code since}, optionally only those whose name matches {@code name} (regex). */
|
|
151
|
+
eventsSince(opts = {}) {
|
|
152
|
+
const re = opts.name ? new RegExp(opts.name) : undefined;
|
|
153
|
+
const list = this.events.filter((e) => e.seq > (opts.since ?? 0) && (!re || re.test(e.name)));
|
|
154
|
+
return opts.limit ? list.slice(-opts.limit) : list;
|
|
155
|
+
}
|
|
156
|
+
/** Commands registered by extensions and mods, and the extension jars the probe loaded. */
|
|
157
|
+
async extensions() {
|
|
158
|
+
return this.requireProbe().request('ext.list');
|
|
159
|
+
}
|
|
160
|
+
/** Calls an extension command, e.g. {@code call('hud.bossbars')}. */
|
|
161
|
+
async call(name, args = {}, opts = {}) {
|
|
162
|
+
return this.requireProbe().request('ext.call', { name, args }, opts.timeoutMs ?? 30_000);
|
|
163
|
+
}
|
|
635
164
|
/**
|
|
636
|
-
* Waits for a condition: a chat line matching {@code chat} (regex), an
|
|
637
|
-
* phase. Resolves with a description of what matched.
|
|
165
|
+
* Waits for a condition: a chat line matching {@code chat} (regex), an extension event whose name matches
|
|
166
|
+
* {@code event} (regex), an entity appearing/disappearing, or a phase. Resolves with a description of what matched.
|
|
638
167
|
*/
|
|
639
168
|
async waitFor(cond, timeoutMs = 30_000) {
|
|
640
169
|
const deadline = Date.now() + timeoutMs;
|
|
641
170
|
const chatRe = cond.chat ? new RegExp(cond.chat, 'i') : undefined;
|
|
171
|
+
const eventRe = cond.event ? new RegExp(cond.event) : undefined;
|
|
642
172
|
const startSeq = this.seq;
|
|
643
173
|
for (;;) {
|
|
644
174
|
if (chatRe) {
|
|
@@ -646,7 +176,12 @@ export class Client extends EventEmitter {
|
|
|
646
176
|
if (hit)
|
|
647
177
|
return { matched: 'chat', chat: hit };
|
|
648
178
|
}
|
|
649
|
-
if (
|
|
179
|
+
if (eventRe) {
|
|
180
|
+
const hit = this.events.find((e) => e.seq > startSeq && eventRe.test(e.name));
|
|
181
|
+
if (hit)
|
|
182
|
+
return { matched: 'event', event: hit };
|
|
183
|
+
}
|
|
184
|
+
if (cond.phase && this.phase === cond.phase)
|
|
650
185
|
return { matched: 'phase' };
|
|
651
186
|
if (cond.entity && this.probe?.connected) {
|
|
652
187
|
const { present = true, ...query } = cond.entity;
|
|
@@ -662,8 +197,8 @@ export class Client extends EventEmitter {
|
|
|
662
197
|
}
|
|
663
198
|
if (Date.now() > deadline)
|
|
664
199
|
throw new CalciteError('timeout', `Condition not met within ${timeoutMs}ms`);
|
|
665
|
-
if (['stopped', 'crashed'].includes(this.
|
|
666
|
-
throw new CalciteError('not_running', `Client stopped (phase ${this.
|
|
200
|
+
if (['stopped', 'crashed'].includes(this.phase))
|
|
201
|
+
throw new CalciteError('not_running', `Client stopped (phase ${this.phase})`);
|
|
667
202
|
await sleep(300);
|
|
668
203
|
}
|
|
669
204
|
}
|