@bkm016/calcite 0.3.0 → 0.5.1
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/CHANGELOG.md +42 -0
- package/README.md +103 -20
- package/dist/accounts.js +4 -2
- package/dist/accounts.js.map +1 -1
- package/dist/cli.js +12 -141
- package/dist/cli.js.map +1 -1
- package/dist/client-core.d.ts +22 -13
- package/dist/client-core.js +217 -260
- package/dist/client-core.js.map +1 -1
- package/dist/client.d.ts +44 -15
- package/dist/client.js +58 -21
- package/dist/client.js.map +1 -1
- package/dist/display.d.ts +0 -6
- package/dist/display.js +31 -44
- package/dist/display.js.map +1 -1
- package/dist/feed.d.ts +17 -0
- package/dist/feed.js +47 -0
- package/dist/feed.js.map +1 -0
- package/dist/game-run.d.ts +30 -0
- package/dist/game-run.js +51 -0
- package/dist/game-run.js.map +1 -0
- package/dist/hmc.d.ts +7 -0
- package/dist/hmc.js +42 -31
- package/dist/hmc.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/install.d.ts +6 -0
- package/dist/install.js +26 -3
- package/dist/install.js.map +1 -1
- package/dist/java.js +11 -2
- package/dist/java.js.map +1 -1
- package/dist/launch.d.ts +79 -0
- package/dist/launch.js +142 -0
- package/dist/launch.js.map +1 -0
- package/dist/lock.js +18 -18
- package/dist/lock.js.map +1 -1
- package/dist/mcp/accounts.d.ts +3 -0
- package/dist/mcp/accounts.js +45 -0
- package/dist/mcp/accounts.js.map +1 -0
- package/dist/mcp/act.d.ts +3 -0
- package/dist/mcp/act.js +129 -0
- package/dist/mcp/act.js.map +1 -0
- package/dist/mcp/extensions.d.ts +3 -0
- package/dist/mcp/extensions.js +21 -0
- package/dist/mcp/extensions.js.map +1 -0
- package/dist/mcp/index.d.ts +13 -0
- package/dist/mcp/index.js +58 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/items.d.ts +3 -0
- package/dist/mcp/items.js +71 -0
- package/dist/mcp/items.js.map +1 -0
- package/dist/mcp/lifecycle.d.ts +3 -0
- package/dist/mcp/lifecycle.js +120 -0
- package/dist/mcp/lifecycle.js.map +1 -0
- package/dist/mcp/observe.d.ts +3 -0
- package/dist/mcp/observe.js +110 -0
- package/dist/mcp/observe.js.map +1 -0
- package/dist/mcp/shared.d.ts +49 -0
- package/dist/mcp/shared.js +56 -0
- package/dist/mcp/shared.js.map +1 -0
- package/dist/mcp/tasks.d.ts +15 -0
- package/dist/mcp/tasks.js +46 -0
- package/dist/mcp/tasks.js.map +1 -0
- package/dist/mods.js +6 -4
- package/dist/mods.js.map +1 -1
- package/dist/mojang.d.ts +5 -1
- package/dist/mojang.js.map +1 -1
- package/dist/names.js +30 -8
- package/dist/names.js.map +1 -1
- package/dist/net.js +25 -19
- package/dist/net.js.map +1 -1
- package/dist/paper.d.ts +48 -0
- package/dist/paper.js +215 -0
- package/dist/paper.js.map +1 -0
- package/dist/probe-server.d.ts +14 -4
- package/dist/probe-server.js +11 -10
- package/dist/probe-server.js.map +1 -1
- package/dist/repl.d.ts +6 -0
- package/dist/repl.js +207 -0
- package/dist/repl.js.map +1 -0
- package/dist/stage.d.ts +6 -0
- package/dist/stage.js +19 -0
- package/dist/stage.js.map +1 -0
- package/dist/types.d.ts +139 -5
- package/dist/types.js +12 -1
- package/dist/types.js.map +1 -1
- package/dist/zip.js.map +1 -1
- package/package.json +11 -3
- package/vendor/calcite-probe.jar +0 -0
package/dist/client-core.js
CHANGED
|
@@ -1,44 +1,25 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
1
|
import { EventEmitter } from 'node:events';
|
|
3
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { Socket } from 'node:net';
|
|
5
2
|
import { join } from 'node:path';
|
|
6
3
|
import { prepareAccount, syncAccount } from './accounts.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import { which } from './display.js';
|
|
5
|
+
import { Feed } from './feed.js';
|
|
6
|
+
import { GameRun } from './game-run.js';
|
|
7
|
+
import { killTree, runHmc, writeHmcProfile } from './hmc.js';
|
|
8
|
+
import { assetsVerified, installProbe, markAssetsVerified, prepareGame } from './install.js';
|
|
9
|
+
import { BackendWatch, gameJvmArgs, hmcLaunchCommand, hmcProperties, joinArgs, noBackendHint, reconnectDelay, reconnectLimit, waitForServer, } from './launch.js';
|
|
10
10
|
import { parseLoader } from './loaders.js';
|
|
11
11
|
import { acquireLock } from './lock.js';
|
|
12
12
|
import { logger } from './log.js';
|
|
13
13
|
import { resolveExtensions, resolveMods, syncMods } from './mods.js';
|
|
14
|
-
import { javaProxyProps } from './net.js';
|
|
15
14
|
import { supportsQuickPlay } from './mojang.js';
|
|
16
15
|
import { defaultOptions, writeOptions } from './options.js';
|
|
17
16
|
import { resolvePaths } from './paths.js';
|
|
18
|
-
import {
|
|
19
|
-
import { CalciteError, defaultUsername, parseServer, } from './types.js';
|
|
17
|
+
import { MOD_ERROR_LINE, gameStage } from './stage.js';
|
|
18
|
+
import { CalciteError, defaultUsername, parseServer, parseWorld, } from './types.js';
|
|
20
19
|
const NAME_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
21
20
|
const MAX_LOG = 5000;
|
|
22
21
|
const MAX_CHAT = 1000;
|
|
23
22
|
const MAX_EVENTS = 1000;
|
|
24
|
-
/** Escapes a value for a java.util.Properties file. */
|
|
25
|
-
function propValue(value) {
|
|
26
|
-
return value.replace(/\\/g, '\\\\').replace(/[:=]/g, (c) => `\\${c}`);
|
|
27
|
-
}
|
|
28
|
-
async function tcpReachable(host, port, timeoutMs = 3000) {
|
|
29
|
-
return new Promise((resolve) => {
|
|
30
|
-
const socket = new Socket();
|
|
31
|
-
const done = (ok) => {
|
|
32
|
-
socket.destroy();
|
|
33
|
-
resolve(ok);
|
|
34
|
-
};
|
|
35
|
-
socket.setTimeout(timeoutMs, () => done(false));
|
|
36
|
-
socket.once('error', () => done(false));
|
|
37
|
-
socket.connect(port, host, () => done(true));
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
/** Screens shown while joining or changing dimension; the player is not playable yet. */
|
|
41
|
-
const LOADING_SCREEN = /LevelLoading|ReceivingLevel|ProgressScreen|ConnectScreen|GenericMessage|DownloadingTerrain/;
|
|
42
23
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
43
24
|
/** Process lifecycle of a client: preparing, launching, the probe connection, reconnects and shutdown. */
|
|
44
25
|
export class ClientCore extends EventEmitter {
|
|
@@ -48,17 +29,15 @@ export class ClientCore extends EventEmitter {
|
|
|
48
29
|
render;
|
|
49
30
|
account;
|
|
50
31
|
server;
|
|
32
|
+
world;
|
|
51
33
|
phaseValue = 'idle';
|
|
52
|
-
logs =
|
|
53
|
-
chats =
|
|
54
|
-
events =
|
|
34
|
+
logs = new Feed(MAX_LOG);
|
|
35
|
+
chats = new Feed(MAX_CHAT);
|
|
36
|
+
events = new Feed(MAX_EVENTS);
|
|
55
37
|
extensionJars = [];
|
|
56
38
|
seq = 0;
|
|
57
|
-
|
|
58
|
-
run = null;
|
|
59
|
-
display = null;
|
|
39
|
+
current = null;
|
|
60
40
|
releaseInstance = null;
|
|
61
|
-
probeConfig = null;
|
|
62
41
|
game = null;
|
|
63
42
|
mods = [];
|
|
64
43
|
headlessValue = false;
|
|
@@ -69,7 +48,6 @@ export class ClientCore extends EventEmitter {
|
|
|
69
48
|
startedAt;
|
|
70
49
|
reconnects = 0;
|
|
71
50
|
stopping = false;
|
|
72
|
-
poller = null;
|
|
73
51
|
inGameSince = 0;
|
|
74
52
|
relaunching = false;
|
|
75
53
|
everInGame = false;
|
|
@@ -88,7 +66,10 @@ export class ClientCore extends EventEmitter {
|
|
|
88
66
|
if (this.account.type === 'offline' && !/^[A-Za-z0-9_]{3,16}$/.test(this.account.username)) {
|
|
89
67
|
throw new CalciteError('bad_username', `Offline username must be 3-16 letters, digits or underscores: "${this.account.username}"`);
|
|
90
68
|
}
|
|
69
|
+
if (options.server && options.world)
|
|
70
|
+
throw new CalciteError('bad_option', 'Pass either a server or a world, not both');
|
|
91
71
|
this.server = options.server ? parseServer(options.server) : undefined;
|
|
72
|
+
this.world = options.world ? parseWorld(options.world) : undefined;
|
|
92
73
|
if (!parseLoader(options.loader) && options.mods?.length) {
|
|
93
74
|
throw new CalciteError('bad_mod', 'Mods need a mod loader (loader "fabric", "forge" or "neoforge")');
|
|
94
75
|
}
|
|
@@ -115,16 +96,12 @@ export class ClientCore extends EventEmitter {
|
|
|
115
96
|
pushLog(source, line) {
|
|
116
97
|
const entry = { seq: ++this.seq, time: Date.now(), source, line };
|
|
117
98
|
this.logs.push(entry);
|
|
118
|
-
if (this.logs.length > MAX_LOG)
|
|
119
|
-
this.logs.splice(0, this.logs.length - MAX_LOG);
|
|
120
99
|
this.emit('log', entry);
|
|
121
100
|
if (source === 'game') {
|
|
122
101
|
const chat = /\[CHAT\] (.*)$/.exec(line);
|
|
123
102
|
if (chat) {
|
|
124
103
|
const c = { seq: entry.seq, time: entry.time, message: chat[1] };
|
|
125
104
|
this.chats.push(c);
|
|
126
|
-
if (this.chats.length > MAX_CHAT)
|
|
127
|
-
this.chats.splice(0, this.chats.length - MAX_CHAT);
|
|
128
105
|
this.emit('chat', c);
|
|
129
106
|
}
|
|
130
107
|
}
|
|
@@ -132,7 +109,7 @@ export class ClientCore extends EventEmitter {
|
|
|
132
109
|
// ------------------------------------------------------------------ lifecycle
|
|
133
110
|
/**
|
|
134
111
|
* Prepares everything (Java, game files, mappings), launches the client and resolves once it is in a world
|
|
135
|
-
* (or on the title screen when
|
|
112
|
+
* (or on the title screen when neither a server nor a world is configured).
|
|
136
113
|
*/
|
|
137
114
|
async start() {
|
|
138
115
|
if (!['idle', 'stopped', 'crashed'].includes(this.phaseValue)) {
|
|
@@ -196,80 +173,47 @@ export class ClientCore extends EventEmitter {
|
|
|
196
173
|
}
|
|
197
174
|
return false;
|
|
198
175
|
}
|
|
176
|
+
requireGame() {
|
|
177
|
+
if (!this.game)
|
|
178
|
+
throw new CalciteError('not_prepared', 'prepare() has not completed');
|
|
179
|
+
return this.game;
|
|
180
|
+
}
|
|
199
181
|
async launch() {
|
|
200
|
-
const
|
|
182
|
+
const game = this.requireGame();
|
|
183
|
+
const { json, java, loader, names } = game;
|
|
201
184
|
const probeJar = await installProbe(this.paths);
|
|
202
|
-
if (this.server)
|
|
203
|
-
this.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
// probe endpoint + config (paths inside may contain spaces; the config file itself lives in a safe dir)
|
|
224
|
-
this.probe = new ProbeServer();
|
|
225
|
-
await this.probe.listen();
|
|
226
|
-
this.probe.on('connected', () => this.onProbeConnected());
|
|
227
|
-
this.probe.on('disconnected', () => this.note('probe disconnected'));
|
|
228
|
-
this.probe.on('event', (e) => {
|
|
185
|
+
if (this.server)
|
|
186
|
+
await this.waitForServer(this.server);
|
|
187
|
+
// offline accounts may only render on a virtual display (HeadlessMC); on-demand clients stay invisible when Xvfb exists
|
|
188
|
+
const forceVirtual = this.account.type === 'offline' || (this.render === 'on-demand' && which('Xvfb') !== null);
|
|
189
|
+
const run = await GameRun.open({
|
|
190
|
+
dir: this.paths.probe,
|
|
191
|
+
name: this.options.name,
|
|
192
|
+
probe: {
|
|
193
|
+
mappings: names.kind === 'probe' ? names.candidates : [],
|
|
194
|
+
version: json.id,
|
|
195
|
+
extensions: this.extensionJars,
|
|
196
|
+
headless: this.headlessValue,
|
|
197
|
+
render: this.render,
|
|
198
|
+
},
|
|
199
|
+
display: this.headlessValue ? undefined : { forceVirtual },
|
|
200
|
+
});
|
|
201
|
+
this.current = run;
|
|
202
|
+
run.probe.on('connected', () => this.onProbeConnected(run));
|
|
203
|
+
run.probe.on('disconnected', () => this.note('probe disconnected'));
|
|
204
|
+
run.probe.on('event', (e) => {
|
|
229
205
|
const event = { seq: ++this.seq, time: e.time, name: e.name, data: e.data };
|
|
230
206
|
this.events.push(event);
|
|
231
|
-
|
|
232
|
-
this.events.splice(0, this.events.length - MAX_EVENTS);
|
|
233
|
-
this.emit('extension', event);
|
|
207
|
+
this.emit('event', event);
|
|
234
208
|
});
|
|
235
|
-
this.probeConfig = join(this.paths.probe, `${this.options.name}-${randomBytes(6).toString('hex')}.properties`);
|
|
236
|
-
const cfg = [
|
|
237
|
-
`port=${this.probe.port}`,
|
|
238
|
-
`token=${this.probe.token}`,
|
|
239
|
-
// name tables the probe tries in order (a mapping file or "official")
|
|
240
|
-
...(names.kind === 'probe' ? names.candidates.map((c, i) => `mappings.${i + 1}=${propValue(c)}`) : []),
|
|
241
|
-
`version=${propValue(json.id)}`,
|
|
242
|
-
...this.extensionJars.map((jar, i) => `extensions.${i + 1}=${propValue(jar)}`),
|
|
243
|
-
`headless=${this.headlessValue}`,
|
|
244
|
-
`render=${this.render === 'always' ? 'on' : 'off'}`,
|
|
245
|
-
'exitOnDisconnect=true',
|
|
246
|
-
'',
|
|
247
|
-
].join('\n');
|
|
248
|
-
await mkdir(this.paths.probe, { recursive: true });
|
|
249
|
-
await writeFile(this.probeConfig, cfg, { mode: 0o600 });
|
|
250
209
|
await writeOptions(this.gameDir, defaultOptions({ renderDistance: this.options.renderDistance, maxFps: this.options.maxFps }));
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
const proxyArgs = Object.entries(javaProxyProps()).map(([k, v]) => `-D${k}=${v}`);
|
|
260
|
-
const jvmArgs = [`-Xmx${this.options.memory ?? '2G'}`, ...proxyArgs, ...(this.options.jvmArgs ?? [])];
|
|
261
|
-
if (names.kind !== 'unsupported')
|
|
262
|
-
jvmArgs.unshift(`-javaagent:${probeJar}=${this.probeConfig}`);
|
|
263
|
-
// HeadlessMC's stubbed LWJGL buffers have no native address; JOML's Unsafe path writes to it and crashes the JVM
|
|
264
|
-
if (this.headlessValue)
|
|
265
|
-
jvmArgs.unshift('-Djoml.nounsafe=true');
|
|
266
|
-
const gameArgs = [...(this.options.gameArgs ?? [])];
|
|
267
|
-
if (this.server) {
|
|
268
|
-
if (supportsQuickPlay(json))
|
|
269
|
-
gameArgs.unshift('--quickPlayMultiplayer', `${this.server.host}:${this.server.port}`);
|
|
270
|
-
else
|
|
271
|
-
gameArgs.unshift('--server', this.server.host, '--port', String(this.server.port));
|
|
272
|
-
}
|
|
210
|
+
const jvmArgs = gameJvmArgs({
|
|
211
|
+
agent: names.kind === 'unsupported' ? undefined : `${probeJar}=${run.configFile}`,
|
|
212
|
+
headless: this.headlessValue,
|
|
213
|
+
memory: this.options.memory,
|
|
214
|
+
extra: this.options.jvmArgs,
|
|
215
|
+
});
|
|
216
|
+
const gameArgs = [...(this.server ? joinArgs(this.server, supportsQuickPlay(json)) : []), ...(this.options.gameArgs ?? [])];
|
|
273
217
|
const offline = this.account.type === 'offline';
|
|
274
218
|
// a private HeadlessMC location per client: its own account selection, config and caches
|
|
275
219
|
const location = join(this.gameDir, '.headlessmc');
|
|
@@ -282,164 +226,171 @@ export class ClientCore extends EventEmitter {
|
|
|
282
226
|
throw code ? new CalciteError(code, err.message) : err;
|
|
283
227
|
}
|
|
284
228
|
await writeHmcProfile(location, json.id, this.gameDir, java.major, loader);
|
|
285
|
-
const props = {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
...(gameArgs.length ? [`--game=${gameArgs.map(hmcQuote).join(' ')}`] : []),
|
|
300
|
-
];
|
|
301
|
-
const env = { ...process.env, ...displayEnv };
|
|
302
|
-
const sync = () => {
|
|
303
|
-
if (!offline)
|
|
304
|
-
void syncAccount(this.paths, location, accountName).catch((err) => this.note(`account sync failed: ${err.message}`));
|
|
229
|
+
const props = hmcProperties({
|
|
230
|
+
minecraftDir: this.paths.minecraft,
|
|
231
|
+
gameDir: this.gameDir,
|
|
232
|
+
javaPath: java.path,
|
|
233
|
+
virtualDisplay: run.display?.virtual ?? false,
|
|
234
|
+
assetsVerified: await assetsVerified(this.paths, json),
|
|
235
|
+
});
|
|
236
|
+
// the session HeadlessMC refreshed before launching goes back to the shared store
|
|
237
|
+
const syncSession = () => {
|
|
238
|
+
if (offline)
|
|
239
|
+
return;
|
|
240
|
+
syncAccount(this.paths, location, accountName).catch((err) => {
|
|
241
|
+
this.note(`account sync failed: ${err.message}`);
|
|
242
|
+
});
|
|
305
243
|
};
|
|
306
244
|
this.setPhase('starting');
|
|
307
245
|
this.joinRequested = false;
|
|
308
246
|
this.startedAt = Date.now();
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
};
|
|
357
|
-
await spawnGame();
|
|
247
|
+
this.spawn(run, {
|
|
248
|
+
javaPath: game.launcherJava.path,
|
|
249
|
+
hmcJar: game.hmcJar,
|
|
250
|
+
location,
|
|
251
|
+
props,
|
|
252
|
+
command: hmcLaunchCommand({ versionId: json.id, loader, offline, headless: this.headlessValue, jvmArgs, gameArgs }),
|
|
253
|
+
env: { ...process.env, ...run.display?.env },
|
|
254
|
+
onLaunched: syncSession,
|
|
255
|
+
onExit: syncSession,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async waitForServer(server) {
|
|
259
|
+
this.setPhase('waiting_for_server');
|
|
260
|
+
await waitForServer(server, {
|
|
261
|
+
timeoutMs: this.options.waitForServerMs ?? 120_000,
|
|
262
|
+
stopped: () => this.stopping,
|
|
263
|
+
onWaiting: (message) => {
|
|
264
|
+
this.note(message);
|
|
265
|
+
this.log.info(message);
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/** Starts HeadlessMC and follows the game's output until it exits. */
|
|
270
|
+
spawn(run, opts) {
|
|
271
|
+
let launched = false;
|
|
272
|
+
const backend = new BackendWatch((errors) => {
|
|
273
|
+
if (run !== this.current || this.stopping)
|
|
274
|
+
return;
|
|
275
|
+
this.fail('renderer_unavailable', `No graphics backend could be created: ${errors.join('; ')}. ${noBackendHint(process.platform)}`);
|
|
276
|
+
});
|
|
277
|
+
const hmc = runHmc({
|
|
278
|
+
...opts,
|
|
279
|
+
onLine: (line) => {
|
|
280
|
+
this.pushLog('game', line);
|
|
281
|
+
backend.line(line);
|
|
282
|
+
if (!launched && /Setting user:|Minecraft exited|LWJGL Version|Backend library/i.test(line)) {
|
|
283
|
+
launched = true;
|
|
284
|
+
opts.onLaunched();
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
run.hmc = hmc;
|
|
289
|
+
void hmc.exited.then((code) => {
|
|
290
|
+
backend.dispose();
|
|
291
|
+
opts.onExit();
|
|
292
|
+
this.onGameExit(run, code);
|
|
293
|
+
});
|
|
358
294
|
}
|
|
359
|
-
|
|
295
|
+
onProbeConnected(run) {
|
|
360
296
|
this.note('probe connected');
|
|
297
|
+
// the game JVM only starts once HeadlessMC has downloaded and verified every asset
|
|
298
|
+
if (this.game)
|
|
299
|
+
void markAssetsVerified(this.paths, this.game.json).catch(() => undefined);
|
|
361
300
|
if (this.phaseValue === 'starting' || this.phaseValue === 'reconnecting')
|
|
362
301
|
this.setPhase('connecting');
|
|
363
|
-
|
|
364
|
-
this.poller = setInterval(() => void this.poll(), 1000);
|
|
365
|
-
}
|
|
302
|
+
run.poll(() => void this.poll(run), 1000);
|
|
366
303
|
}
|
|
367
|
-
async poll() {
|
|
368
|
-
if (
|
|
304
|
+
async poll(run) {
|
|
305
|
+
if (run !== this.current || !run.probe.connected)
|
|
369
306
|
return;
|
|
370
307
|
let state;
|
|
371
308
|
try {
|
|
372
|
-
state = await
|
|
309
|
+
state = await run.probe.request('state', {}, 10_000);
|
|
373
310
|
}
|
|
374
|
-
catch
|
|
375
|
-
|
|
376
|
-
return;
|
|
377
|
-
return;
|
|
311
|
+
catch {
|
|
312
|
+
return; // not ready yet, or the connection is going away: the next poll tells
|
|
378
313
|
}
|
|
379
314
|
const prev = this.lastState;
|
|
380
315
|
this.lastState = state;
|
|
381
316
|
if (JSON.stringify(prev) !== JSON.stringify(state))
|
|
382
317
|
this.emit('state', state);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
void this.maybeReconnect();
|
|
318
|
+
switch (gameStage(state)) {
|
|
319
|
+
case 'mod_error':
|
|
320
|
+
if (!this.fatalCode)
|
|
321
|
+
this.failModLoading(state.screen ?? '?');
|
|
322
|
+
break;
|
|
323
|
+
case 'playing':
|
|
324
|
+
if (this.phaseValue !== 'in_game') {
|
|
325
|
+
this.inGameSince = Date.now();
|
|
326
|
+
this.everInGame = true;
|
|
327
|
+
this.setPhase('in_game');
|
|
328
|
+
}
|
|
329
|
+
else if (this.reconnects > 0 && Date.now() - this.inGameSince > 60_000) {
|
|
330
|
+
this.reconnects = 0; // stable again
|
|
331
|
+
}
|
|
332
|
+
break;
|
|
333
|
+
case 'title':
|
|
334
|
+
if (this.joinRequested || this.phaseValue !== 'connecting')
|
|
335
|
+
break;
|
|
336
|
+
// Pre-1.20 versions may ignore --server (e.g. 1.16.4+ when multiplayer privileges cannot be checked)
|
|
337
|
+
if (this.server)
|
|
338
|
+
this.joinFromTitle(run.probe, 'connect', { host: this.server.host, port: this.server.port });
|
|
339
|
+
else if (this.world)
|
|
340
|
+
this.joinFromTitle(run.probe, 'open_world', { ...this.world });
|
|
341
|
+
break;
|
|
342
|
+
case 'disconnected':
|
|
343
|
+
if (this.phaseValue !== 'disconnected' && this.phaseValue !== 'reconnecting') {
|
|
344
|
+
this.lastError = `Disconnected: ${state.disconnectReason ?? 'unknown reason'}`;
|
|
345
|
+
this.setPhase('disconnected');
|
|
346
|
+
void this.maybeReconnect();
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
case 'loading':
|
|
350
|
+
break;
|
|
417
351
|
}
|
|
418
352
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
353
|
+
/** Forge/NeoForge stay on an error screen when mods fail to load; relaunching would not help. */
|
|
354
|
+
failModLoading(screen) {
|
|
355
|
+
const errors = this.logs.since(0, (l) => l.source === 'game' && MOD_ERROR_LINE.test(l.line), 12);
|
|
356
|
+
this.fail('mod_loading_failed', `Mod loading failed (${screen}); check the mods and the loader version:\n${errors.map((l) => l.line).join('\n')}`);
|
|
357
|
+
}
|
|
358
|
+
/** A failure relaunching cannot fix: start() reports {@code code} and the game is killed. */
|
|
359
|
+
fail(code, message) {
|
|
360
|
+
this.fatalCode = code;
|
|
361
|
+
this.lastError = message;
|
|
362
|
+
this.note(message);
|
|
363
|
+
this.setPhase('crashed');
|
|
364
|
+
if (this.current?.hmc)
|
|
365
|
+
void killTree(this.current.hmc.child);
|
|
366
|
+
}
|
|
367
|
+
joinFromTitle(probe, op, args) {
|
|
368
|
+
this.joinRequested = true;
|
|
369
|
+
this.note(`${op} ${JSON.stringify(args)} from the title screen`);
|
|
370
|
+
probe.request(op, args, 90_000).catch((err) => {
|
|
371
|
+
const e = err;
|
|
372
|
+
this.note(`join failed: ${e.message}`);
|
|
373
|
+
if (e.code === 'unknown_world')
|
|
374
|
+
this.fail(e.code, e.message);
|
|
375
|
+
});
|
|
426
376
|
}
|
|
427
377
|
async maybeReconnect() {
|
|
428
|
-
if (this.stopping || this.relaunching || !this.server)
|
|
378
|
+
if (this.stopping || this.relaunching || !(this.server ?? this.world))
|
|
429
379
|
return;
|
|
430
|
-
if (this.reconnects >= this.
|
|
380
|
+
if (this.reconnects >= reconnectLimit(this.options.reconnect)) {
|
|
431
381
|
this.note(`not reconnecting: ${this.reconnects} attempts used`);
|
|
432
382
|
return;
|
|
433
383
|
}
|
|
434
384
|
this.relaunching = true;
|
|
435
385
|
try {
|
|
436
386
|
this.reconnects++;
|
|
437
|
-
const delay =
|
|
387
|
+
const delay = reconnectDelay(this.reconnects);
|
|
438
388
|
this.setPhase('reconnecting');
|
|
439
389
|
this.note(`reconnecting in ${delay}ms (attempt ${this.reconnects})`);
|
|
440
|
-
if (this.
|
|
441
|
-
await killTree(this.
|
|
390
|
+
if (this.current?.hmc)
|
|
391
|
+
await killTree(this.current.hmc.child);
|
|
442
392
|
await sleep(delay);
|
|
393
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- stop() may run while sleeping
|
|
443
394
|
if (this.stopping)
|
|
444
395
|
return;
|
|
445
396
|
await this.cleanupRun();
|
|
@@ -454,13 +405,16 @@ export class ClientCore extends EventEmitter {
|
|
|
454
405
|
}
|
|
455
406
|
}
|
|
456
407
|
onGameExit(run, code) {
|
|
457
|
-
if (run !== this.
|
|
408
|
+
if (run !== this.current)
|
|
458
409
|
return; // an older run
|
|
459
410
|
this.emit('exit', code);
|
|
460
411
|
this.note(`game process exited with code ${code}`);
|
|
461
412
|
if (this.stopping || this.relaunching || this.fatalCode)
|
|
462
413
|
return;
|
|
463
|
-
const tail = this.logs
|
|
414
|
+
const tail = this.logs
|
|
415
|
+
.since(0, (l) => l.source === 'game', 15)
|
|
416
|
+
.map((l) => l.line)
|
|
417
|
+
.join('\n');
|
|
464
418
|
this.lastError = `Game exited with code ${code}${tail ? `:\n${tail}` : ''}`;
|
|
465
419
|
this.setPhase('crashed');
|
|
466
420
|
// a crash during the very first start is reported to start(); later crashes are retried
|
|
@@ -473,11 +427,11 @@ export class ClientCore extends EventEmitter {
|
|
|
473
427
|
const phase = this.phaseValue;
|
|
474
428
|
if (phase === 'in_game')
|
|
475
429
|
return;
|
|
476
|
-
if (!this.server && this.lastState?.ready && this.lastState.loading === false && phase === 'connecting')
|
|
430
|
+
if (!this.server && !this.world && this.lastState?.ready && this.lastState.loading === false && phase === 'connecting')
|
|
477
431
|
return;
|
|
478
432
|
if (phase === 'crashed')
|
|
479
433
|
throw new CalciteError(this.fatalCode ?? 'crashed', this.lastError ?? 'The game crashed during startup');
|
|
480
|
-
if (phase === 'disconnected' && this.
|
|
434
|
+
if (phase === 'disconnected' && reconnectLimit(this.options.reconnect) === 0)
|
|
481
435
|
throw new CalciteError('disconnected', this.lastError ?? 'Disconnected');
|
|
482
436
|
if (Date.now() > deadline)
|
|
483
437
|
throw new CalciteError('start_timeout', `Client did not start within ${timeoutMs}ms (phase ${phase})`);
|
|
@@ -485,25 +439,10 @@ export class ClientCore extends EventEmitter {
|
|
|
485
439
|
}
|
|
486
440
|
}
|
|
487
441
|
async cleanupRun() {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
this.poller = null;
|
|
491
|
-
}
|
|
492
|
-
if (this.run) {
|
|
493
|
-
await killTree(this.run.child);
|
|
494
|
-
this.run = null;
|
|
495
|
-
}
|
|
496
|
-
if (this.probe) {
|
|
497
|
-
await this.probe.close();
|
|
498
|
-
this.probe = null;
|
|
499
|
-
}
|
|
500
|
-
if (this.probeConfig) {
|
|
501
|
-
await rm(this.probeConfig, { force: true });
|
|
502
|
-
this.probeConfig = null;
|
|
503
|
-
}
|
|
504
|
-
this.display?.release();
|
|
505
|
-
this.display = null;
|
|
442
|
+
const run = this.current;
|
|
443
|
+
this.current = null;
|
|
506
444
|
this.lastState = undefined;
|
|
445
|
+
await run?.dispose();
|
|
507
446
|
}
|
|
508
447
|
async shutdown(final) {
|
|
509
448
|
this.stopping = true;
|
|
@@ -518,9 +457,22 @@ export class ClientCore extends EventEmitter {
|
|
|
518
457
|
async stop() {
|
|
519
458
|
if (this.phaseValue === 'stopped' || this.phaseValue === 'idle')
|
|
520
459
|
return;
|
|
460
|
+
const saveWorld = this.world && this.phaseValue === 'in_game' && this.probeConnected;
|
|
521
461
|
this.setPhase('stopping');
|
|
462
|
+
if (saveWorld)
|
|
463
|
+
await this.closeWorld();
|
|
522
464
|
await this.shutdown('stopped');
|
|
523
465
|
}
|
|
466
|
+
/** Lets the integrated server save and stop before the game is killed. */
|
|
467
|
+
async closeWorld() {
|
|
468
|
+
try {
|
|
469
|
+
await this.requireProbe().request('close_world', {}, 60_000);
|
|
470
|
+
this.note('world saved');
|
|
471
|
+
}
|
|
472
|
+
catch (err) {
|
|
473
|
+
this.note(`saving the world failed: ${err.message}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
524
476
|
status() {
|
|
525
477
|
return {
|
|
526
478
|
name: this.options.name,
|
|
@@ -533,24 +485,29 @@ export class ClientCore extends EventEmitter {
|
|
|
533
485
|
headless: this.headlessValue,
|
|
534
486
|
account: this.account,
|
|
535
487
|
server: this.server,
|
|
488
|
+
world: this.world,
|
|
536
489
|
gameDir: this.gameDir,
|
|
537
|
-
pid: this.
|
|
490
|
+
pid: this.current?.hmc?.child.pid,
|
|
538
491
|
java: this.game?.java.version,
|
|
539
492
|
startedAt: this.startedAt,
|
|
540
493
|
reconnects: this.reconnects,
|
|
541
494
|
lastError: this.lastError,
|
|
542
|
-
probeConnected:
|
|
495
|
+
probeConnected: this.probeConnected,
|
|
543
496
|
game: this.lastState,
|
|
544
497
|
};
|
|
545
498
|
}
|
|
546
499
|
// ------------------------------------------------------------------ operations
|
|
500
|
+
get probeConnected() {
|
|
501
|
+
return !!this.current?.probe.connected;
|
|
502
|
+
}
|
|
547
503
|
requireProbe() {
|
|
548
504
|
if (this.game?.names.kind === 'unsupported') {
|
|
549
505
|
throw new CalciteError('unsupported_version', this.game.names.reason);
|
|
550
506
|
}
|
|
551
|
-
|
|
507
|
+
const probe = this.current?.probe;
|
|
508
|
+
if (!probe?.connected)
|
|
552
509
|
throw new CalciteError('not_connected', `Client "${this.options.name}" is not running (phase ${this.phaseValue})`);
|
|
553
|
-
return
|
|
510
|
+
return probe;
|
|
554
511
|
}
|
|
555
512
|
}
|
|
556
513
|
//# sourceMappingURL=client-core.js.map
|