@bkm016/calcite 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +314 -2
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/dist/accounts.d.ts +27 -0
- package/dist/accounts.js +114 -0
- package/dist/accounts.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +242 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +251 -0
- package/dist/client.js +671 -0
- package/dist/client.js.map +1 -0
- package/dist/display.d.ts +23 -0
- package/dist/display.js +135 -0
- package/dist/display.js.map +1 -0
- package/dist/hmc.d.ts +33 -0
- package/dist/hmc.js +130 -0
- package/dist/hmc.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/java.d.ts +31 -0
- package/dist/java.js +218 -0
- package/dist/java.js.map +1 -0
- package/dist/lock.d.ts +13 -0
- package/dist/lock.js +79 -0
- package/dist/lock.js.map +1 -0
- package/dist/log.d.ts +13 -0
- package/dist/log.js +20 -0
- package/dist/log.js.map +1 -0
- package/dist/manager.d.ts +15 -0
- package/dist/manager.js +65 -0
- package/dist/manager.js.map +1 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +252 -0
- package/dist/mcp.js.map +1 -0
- package/dist/mojang.d.ts +70 -0
- package/dist/mojang.js +109 -0
- package/dist/mojang.js.map +1 -0
- package/dist/net.d.ts +21 -0
- package/dist/net.js +246 -0
- package/dist/net.js.map +1 -0
- package/dist/options.d.ts +14 -0
- package/dist/options.js +50 -0
- package/dist/options.js.map +1 -0
- package/dist/paths.d.ts +27 -0
- package/dist/paths.js +48 -0
- package/dist/paths.js.map +1 -0
- package/dist/probe-server.d.ts +28 -0
- package/dist/probe-server.js +132 -0
- package/dist/probe-server.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +11 -0
- package/dist/version.js.map +1 -0
- package/package.json +57 -4
- package/vendor/calcite-probe.jar +0 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,671 @@
|
|
|
1
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { copyFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { Socket } from 'node:net';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
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
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* One Minecraft client controlled by Calcite.
|
|
108
|
+
*
|
|
109
|
+
* Events: 'phase' (Phase), 'log' (LogLine), 'chat' (ChatLine), 'state' (GameState), 'exit' (code).
|
|
110
|
+
*/
|
|
111
|
+
export class Client extends EventEmitter {
|
|
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
|
+
}
|
|
577
|
+
async state() {
|
|
578
|
+
const state = await this.requireProbe().request('state');
|
|
579
|
+
this.lastState = state;
|
|
580
|
+
return state;
|
|
581
|
+
}
|
|
582
|
+
async entities(query = {}) {
|
|
583
|
+
const list = await this.requireProbe().request('entities', { radius: query.radius ?? 0, limit: query.type || query.uuid || query.name ? 0 : query.limit ?? 0, includeSelf: !!query.includeSelf }, 20_000);
|
|
584
|
+
const type = query.type ? (query.type.includes(':') ? query.type : `minecraft:${query.type}`).toLowerCase() : undefined;
|
|
585
|
+
const name = query.name?.toLowerCase();
|
|
586
|
+
let result = list.filter((e) => (!type || e.type?.toLowerCase() === type) &&
|
|
587
|
+
(!query.uuid || e.uuid === query.uuid) &&
|
|
588
|
+
(!name || (e.name ?? '').toLowerCase().includes(name) || (e.customName ?? '').toLowerCase().includes(name)));
|
|
589
|
+
if (query.limit && query.limit > 0)
|
|
590
|
+
result = result.slice(0, query.limit);
|
|
591
|
+
return result;
|
|
592
|
+
}
|
|
593
|
+
async chat(message) {
|
|
594
|
+
if (!message || message.length > 256)
|
|
595
|
+
throw new CalciteError('bad_message', 'Chat message must be 1-256 characters');
|
|
596
|
+
await this.requireProbe().request('chat', { message });
|
|
597
|
+
}
|
|
598
|
+
async command(command) {
|
|
599
|
+
if (!command.trim())
|
|
600
|
+
throw new CalciteError('bad_command', 'Command is empty');
|
|
601
|
+
await this.requireProbe().request('command', { command: command.trim() });
|
|
602
|
+
}
|
|
603
|
+
async respawn() {
|
|
604
|
+
await this.requireProbe().request('respawn');
|
|
605
|
+
}
|
|
606
|
+
async setRender(enabled) {
|
|
607
|
+
if (this.headlessValue)
|
|
608
|
+
throw new CalciteError('headless', 'This client runs without a renderer');
|
|
609
|
+
await this.requireProbe().request('render', { enabled });
|
|
610
|
+
}
|
|
611
|
+
/** Takes a screenshot and returns the PNG. With {@code keep} the file stays in the game's screenshots folder. */
|
|
612
|
+
async screenshot({ keep = false } = {}) {
|
|
613
|
+
if (this.headlessValue) {
|
|
614
|
+
throw new CalciteError('headless', this.render === 'off'
|
|
615
|
+
? 'This client was started with render "off"; start it with render "on-demand" to take screenshots'
|
|
616
|
+
: 'Screenshots need a renderer: offline accounts can only render on Linux (Xvfb); use a Microsoft account on Windows/macOS');
|
|
617
|
+
}
|
|
618
|
+
const name = `calcite-${Date.now()}.png`;
|
|
619
|
+
const path = await this.requireProbe().request('screenshot', { name, settleFrames: 3, timeoutMs: 30_000 }, 45_000);
|
|
620
|
+
const png = await readFile(path);
|
|
621
|
+
if (keep)
|
|
622
|
+
return { path, png };
|
|
623
|
+
await rm(path, { force: true });
|
|
624
|
+
return { png };
|
|
625
|
+
}
|
|
626
|
+
logsSince(opts = {}) {
|
|
627
|
+
const contains = opts.contains?.toLowerCase();
|
|
628
|
+
const lines = this.logs.filter((l) => l.seq > (opts.since ?? 0) && (!opts.source || l.source === opts.source) && (!contains || l.line.toLowerCase().includes(contains)));
|
|
629
|
+
return opts.limit ? lines.slice(-opts.limit) : lines;
|
|
630
|
+
}
|
|
631
|
+
chatSince(since = 0, limit) {
|
|
632
|
+
const lines = this.chats.filter((c) => c.seq > since);
|
|
633
|
+
return limit ? lines.slice(-limit) : lines;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Waits for a condition: a chat line matching {@code chat} (regex), an entity appearing/disappearing, or a
|
|
637
|
+
* phase. Resolves with a description of what matched.
|
|
638
|
+
*/
|
|
639
|
+
async waitFor(cond, timeoutMs = 30_000) {
|
|
640
|
+
const deadline = Date.now() + timeoutMs;
|
|
641
|
+
const chatRe = cond.chat ? new RegExp(cond.chat, 'i') : undefined;
|
|
642
|
+
const startSeq = this.seq;
|
|
643
|
+
for (;;) {
|
|
644
|
+
if (chatRe) {
|
|
645
|
+
const hit = this.chats.find((c) => c.seq > startSeq && chatRe.test(c.message));
|
|
646
|
+
if (hit)
|
|
647
|
+
return { matched: 'chat', chat: hit };
|
|
648
|
+
}
|
|
649
|
+
if (cond.phase && this.phaseValue === cond.phase)
|
|
650
|
+
return { matched: 'phase' };
|
|
651
|
+
if (cond.entity && this.probe?.connected) {
|
|
652
|
+
const { present = true, ...query } = cond.entity;
|
|
653
|
+
try {
|
|
654
|
+
const found = await this.entities(query);
|
|
655
|
+
if (present ? found.length > 0 : found.length === 0)
|
|
656
|
+
return { matched: present ? 'entity_present' : 'entity_absent', entities: found };
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
if (!(err instanceof ProbeError) || !['not_ready', 'not_in_game'].includes(err.code))
|
|
660
|
+
throw err;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (Date.now() > deadline)
|
|
664
|
+
throw new CalciteError('timeout', `Condition not met within ${timeoutMs}ms`);
|
|
665
|
+
if (['stopped', 'crashed'].includes(this.phaseValue))
|
|
666
|
+
throw new CalciteError('not_running', `Client stopped (phase ${this.phaseValue})`);
|
|
667
|
+
await sleep(300);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
//# sourceMappingURL=client.js.map
|