@bkm016/calcite 0.2.0 → 0.5.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/CHANGELOG.md +35 -0
- package/README.md +206 -18
- package/dist/accounts.js +4 -2
- package/dist/accounts.js.map +1 -1
- package/dist/cli.js +16 -127
- package/dist/cli.js.map +1 -1
- package/dist/client-core.d.ts +23 -12
- package/dist/client-core.js +227 -244
- package/dist/client-core.js.map +1 -1
- package/dist/client.d.ts +60 -13
- package/dist/client.js +76 -18
- 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/mcp.js +35 -3
- package/dist/mcp.js.map +1 -1
- package/dist/mods.d.ts +2 -0
- package/dist/mods.js +35 -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 +46 -0
- package/dist/paper.js +200 -0
- package/dist/paper.js.map +1 -0
- package/dist/probe-server.d.ts +14 -4
- package/dist/probe-server.js +15 -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 +167 -3
- 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,43 +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
|
-
import { resolveMods, syncMods } from './mods.js';
|
|
14
|
-
import { javaProxyProps } from './net.js';
|
|
13
|
+
import { resolveExtensions, resolveMods, syncMods } from './mods.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
|
-
|
|
24
|
-
function propValue(value) {
|
|
25
|
-
return value.replace(/\\/g, '\\\\').replace(/[:=]/g, (c) => `\\${c}`);
|
|
26
|
-
}
|
|
27
|
-
async function tcpReachable(host, port, timeoutMs = 3000) {
|
|
28
|
-
return new Promise((resolve) => {
|
|
29
|
-
const socket = new Socket();
|
|
30
|
-
const done = (ok) => {
|
|
31
|
-
socket.destroy();
|
|
32
|
-
resolve(ok);
|
|
33
|
-
};
|
|
34
|
-
socket.setTimeout(timeoutMs, () => done(false));
|
|
35
|
-
socket.once('error', () => done(false));
|
|
36
|
-
socket.connect(port, host, () => done(true));
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
/** Screens shown while joining or changing dimension; the player is not playable yet. */
|
|
40
|
-
const LOADING_SCREEN = /LevelLoading|ReceivingLevel|ProgressScreen|ConnectScreen|GenericMessage|DownloadingTerrain/;
|
|
22
|
+
const MAX_EVENTS = 1000;
|
|
41
23
|
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
42
24
|
/** Process lifecycle of a client: preparing, launching, the probe connection, reconnects and shutdown. */
|
|
43
25
|
export class ClientCore extends EventEmitter {
|
|
@@ -47,15 +29,15 @@ export class ClientCore extends EventEmitter {
|
|
|
47
29
|
render;
|
|
48
30
|
account;
|
|
49
31
|
server;
|
|
32
|
+
world;
|
|
50
33
|
phaseValue = 'idle';
|
|
51
|
-
logs =
|
|
52
|
-
chats =
|
|
34
|
+
logs = new Feed(MAX_LOG);
|
|
35
|
+
chats = new Feed(MAX_CHAT);
|
|
36
|
+
events = new Feed(MAX_EVENTS);
|
|
37
|
+
extensionJars = [];
|
|
53
38
|
seq = 0;
|
|
54
|
-
|
|
55
|
-
run = null;
|
|
56
|
-
display = null;
|
|
39
|
+
current = null;
|
|
57
40
|
releaseInstance = null;
|
|
58
|
-
probeConfig = null;
|
|
59
41
|
game = null;
|
|
60
42
|
mods = [];
|
|
61
43
|
headlessValue = false;
|
|
@@ -66,7 +48,6 @@ export class ClientCore extends EventEmitter {
|
|
|
66
48
|
startedAt;
|
|
67
49
|
reconnects = 0;
|
|
68
50
|
stopping = false;
|
|
69
|
-
poller = null;
|
|
70
51
|
inGameSince = 0;
|
|
71
52
|
relaunching = false;
|
|
72
53
|
everInGame = false;
|
|
@@ -85,7 +66,10 @@ export class ClientCore extends EventEmitter {
|
|
|
85
66
|
if (this.account.type === 'offline' && !/^[A-Za-z0-9_]{3,16}$/.test(this.account.username)) {
|
|
86
67
|
throw new CalciteError('bad_username', `Offline username must be 3-16 letters, digits or underscores: "${this.account.username}"`);
|
|
87
68
|
}
|
|
69
|
+
if (options.server && options.world)
|
|
70
|
+
throw new CalciteError('bad_option', 'Pass either a server or a world, not both');
|
|
88
71
|
this.server = options.server ? parseServer(options.server) : undefined;
|
|
72
|
+
this.world = options.world ? parseWorld(options.world) : undefined;
|
|
89
73
|
if (!parseLoader(options.loader) && options.mods?.length) {
|
|
90
74
|
throw new CalciteError('bad_mod', 'Mods need a mod loader (loader "fabric", "forge" or "neoforge")');
|
|
91
75
|
}
|
|
@@ -112,16 +96,12 @@ export class ClientCore extends EventEmitter {
|
|
|
112
96
|
pushLog(source, line) {
|
|
113
97
|
const entry = { seq: ++this.seq, time: Date.now(), source, line };
|
|
114
98
|
this.logs.push(entry);
|
|
115
|
-
if (this.logs.length > MAX_LOG)
|
|
116
|
-
this.logs.splice(0, this.logs.length - MAX_LOG);
|
|
117
99
|
this.emit('log', entry);
|
|
118
100
|
if (source === 'game') {
|
|
119
101
|
const chat = /\[CHAT\] (.*)$/.exec(line);
|
|
120
102
|
if (chat) {
|
|
121
103
|
const c = { seq: entry.seq, time: entry.time, message: chat[1] };
|
|
122
104
|
this.chats.push(c);
|
|
123
|
-
if (this.chats.length > MAX_CHAT)
|
|
124
|
-
this.chats.splice(0, this.chats.length - MAX_CHAT);
|
|
125
105
|
this.emit('chat', c);
|
|
126
106
|
}
|
|
127
107
|
}
|
|
@@ -129,7 +109,7 @@ export class ClientCore extends EventEmitter {
|
|
|
129
109
|
// ------------------------------------------------------------------ lifecycle
|
|
130
110
|
/**
|
|
131
111
|
* Prepares everything (Java, game files, mappings), launches the client and resolves once it is in a world
|
|
132
|
-
* (or on the title screen when
|
|
112
|
+
* (or on the title screen when neither a server nor a world is configured).
|
|
133
113
|
*/
|
|
134
114
|
async start() {
|
|
135
115
|
if (!['idle', 'stopped', 'crashed'].includes(this.phaseValue)) {
|
|
@@ -180,6 +160,9 @@ export class ClientCore extends EventEmitter {
|
|
|
180
160
|
await syncMods(this.gameDir, this.mods);
|
|
181
161
|
for (const m of this.mods)
|
|
182
162
|
this.note(`mod ${m.name} (${m.source})`);
|
|
163
|
+
this.extensionJars = await resolveExtensions(this.paths, this.options.extensions ?? []);
|
|
164
|
+
for (const jar of this.extensionJars)
|
|
165
|
+
this.note(`extension ${jar}`);
|
|
183
166
|
}
|
|
184
167
|
resolveHeadless() {
|
|
185
168
|
if (this.render === 'off')
|
|
@@ -190,61 +173,47 @@ export class ClientCore extends EventEmitter {
|
|
|
190
173
|
}
|
|
191
174
|
return false;
|
|
192
175
|
}
|
|
176
|
+
requireGame() {
|
|
177
|
+
if (!this.game)
|
|
178
|
+
throw new CalciteError('not_prepared', 'prepare() has not completed');
|
|
179
|
+
return this.game;
|
|
180
|
+
}
|
|
193
181
|
async launch() {
|
|
194
|
-
const
|
|
182
|
+
const game = this.requireGame();
|
|
183
|
+
const { json, java, loader, names } = game;
|
|
195
184
|
const probeJar = await installProbe(this.paths);
|
|
196
|
-
if (this.server)
|
|
197
|
-
this.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
this.
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
'exitOnDisconnect=true',
|
|
221
|
-
'',
|
|
222
|
-
].join('\n');
|
|
223
|
-
await mkdir(this.paths.probe, { recursive: true });
|
|
224
|
-
await writeFile(this.probeConfig, cfg, { mode: 0o600 });
|
|
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) => {
|
|
205
|
+
const event = { seq: ++this.seq, time: e.time, name: e.name, data: e.data };
|
|
206
|
+
this.events.push(event);
|
|
207
|
+
this.emit('event', event);
|
|
208
|
+
});
|
|
225
209
|
await writeOptions(this.gameDir, defaultOptions({ renderDistance: this.options.renderDistance, maxFps: this.options.maxFps }));
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
const proxyArgs = Object.entries(javaProxyProps()).map(([k, v]) => `-D${k}=${v}`);
|
|
235
|
-
const jvmArgs = [`-Xmx${this.options.memory ?? '2G'}`, ...proxyArgs, ...(this.options.jvmArgs ?? [])];
|
|
236
|
-
if (names.kind !== 'unsupported')
|
|
237
|
-
jvmArgs.unshift(`-javaagent:${probeJar}=${this.probeConfig}`);
|
|
238
|
-
// HeadlessMC's stubbed LWJGL buffers have no native address; JOML's Unsafe path writes to it and crashes the JVM
|
|
239
|
-
if (this.headlessValue)
|
|
240
|
-
jvmArgs.unshift('-Djoml.nounsafe=true');
|
|
241
|
-
const gameArgs = [...(this.options.gameArgs ?? [])];
|
|
242
|
-
if (this.server) {
|
|
243
|
-
if (supportsQuickPlay(json))
|
|
244
|
-
gameArgs.unshift('--quickPlayMultiplayer', `${this.server.host}:${this.server.port}`);
|
|
245
|
-
else
|
|
246
|
-
gameArgs.unshift('--server', this.server.host, '--port', String(this.server.port));
|
|
247
|
-
}
|
|
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 ?? [])];
|
|
248
217
|
const offline = this.account.type === 'offline';
|
|
249
218
|
// a private HeadlessMC location per client: its own account selection, config and caches
|
|
250
219
|
const location = join(this.gameDir, '.headlessmc');
|
|
@@ -257,164 +226,171 @@ export class ClientCore extends EventEmitter {
|
|
|
257
226
|
throw code ? new CalciteError(code, err.message) : err;
|
|
258
227
|
}
|
|
259
228
|
await writeHmcProfile(location, json.id, this.gameDir, java.major, loader);
|
|
260
|
-
const props = {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...(gameArgs.length ? [`--game=${gameArgs.map(hmcQuote).join(' ')}`] : []),
|
|
275
|
-
];
|
|
276
|
-
const env = { ...process.env, ...displayEnv };
|
|
277
|
-
const sync = () => {
|
|
278
|
-
if (!offline)
|
|
279
|
-
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
|
+
});
|
|
280
243
|
};
|
|
281
244
|
this.setPhase('starting');
|
|
282
245
|
this.joinRequested = false;
|
|
283
246
|
this.startedAt = Date.now();
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
};
|
|
332
|
-
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
|
+
});
|
|
333
294
|
}
|
|
334
|
-
|
|
295
|
+
onProbeConnected(run) {
|
|
335
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);
|
|
336
300
|
if (this.phaseValue === 'starting' || this.phaseValue === 'reconnecting')
|
|
337
301
|
this.setPhase('connecting');
|
|
338
|
-
|
|
339
|
-
this.poller = setInterval(() => void this.poll(), 1000);
|
|
340
|
-
}
|
|
302
|
+
run.poll(() => void this.poll(run), 1000);
|
|
341
303
|
}
|
|
342
|
-
async poll() {
|
|
343
|
-
if (
|
|
304
|
+
async poll(run) {
|
|
305
|
+
if (run !== this.current || !run.probe.connected)
|
|
344
306
|
return;
|
|
345
307
|
let state;
|
|
346
308
|
try {
|
|
347
|
-
state = await
|
|
309
|
+
state = await run.probe.request('state', {}, 10_000);
|
|
348
310
|
}
|
|
349
|
-
catch
|
|
350
|
-
|
|
351
|
-
return;
|
|
352
|
-
return;
|
|
311
|
+
catch {
|
|
312
|
+
return; // not ready yet, or the connection is going away: the next poll tells
|
|
353
313
|
}
|
|
354
314
|
const prev = this.lastState;
|
|
355
315
|
this.lastState = state;
|
|
356
316
|
if (JSON.stringify(prev) !== JSON.stringify(state))
|
|
357
317
|
this.emit('state', state);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
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;
|
|
392
351
|
}
|
|
393
352
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
+
});
|
|
401
376
|
}
|
|
402
377
|
async maybeReconnect() {
|
|
403
|
-
if (this.stopping || this.relaunching || !this.server)
|
|
378
|
+
if (this.stopping || this.relaunching || !(this.server ?? this.world))
|
|
404
379
|
return;
|
|
405
|
-
if (this.reconnects >= this.
|
|
380
|
+
if (this.reconnects >= reconnectLimit(this.options.reconnect)) {
|
|
406
381
|
this.note(`not reconnecting: ${this.reconnects} attempts used`);
|
|
407
382
|
return;
|
|
408
383
|
}
|
|
409
384
|
this.relaunching = true;
|
|
410
385
|
try {
|
|
411
386
|
this.reconnects++;
|
|
412
|
-
const delay =
|
|
387
|
+
const delay = reconnectDelay(this.reconnects);
|
|
413
388
|
this.setPhase('reconnecting');
|
|
414
389
|
this.note(`reconnecting in ${delay}ms (attempt ${this.reconnects})`);
|
|
415
|
-
if (this.
|
|
416
|
-
await killTree(this.
|
|
390
|
+
if (this.current?.hmc)
|
|
391
|
+
await killTree(this.current.hmc.child);
|
|
417
392
|
await sleep(delay);
|
|
393
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- stop() may run while sleeping
|
|
418
394
|
if (this.stopping)
|
|
419
395
|
return;
|
|
420
396
|
await this.cleanupRun();
|
|
@@ -429,13 +405,16 @@ export class ClientCore extends EventEmitter {
|
|
|
429
405
|
}
|
|
430
406
|
}
|
|
431
407
|
onGameExit(run, code) {
|
|
432
|
-
if (run !== this.
|
|
408
|
+
if (run !== this.current)
|
|
433
409
|
return; // an older run
|
|
434
410
|
this.emit('exit', code);
|
|
435
411
|
this.note(`game process exited with code ${code}`);
|
|
436
412
|
if (this.stopping || this.relaunching || this.fatalCode)
|
|
437
413
|
return;
|
|
438
|
-
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');
|
|
439
418
|
this.lastError = `Game exited with code ${code}${tail ? `:\n${tail}` : ''}`;
|
|
440
419
|
this.setPhase('crashed');
|
|
441
420
|
// a crash during the very first start is reported to start(); later crashes are retried
|
|
@@ -448,11 +427,11 @@ export class ClientCore extends EventEmitter {
|
|
|
448
427
|
const phase = this.phaseValue;
|
|
449
428
|
if (phase === 'in_game')
|
|
450
429
|
return;
|
|
451
|
-
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')
|
|
452
431
|
return;
|
|
453
432
|
if (phase === 'crashed')
|
|
454
433
|
throw new CalciteError(this.fatalCode ?? 'crashed', this.lastError ?? 'The game crashed during startup');
|
|
455
|
-
if (phase === 'disconnected' && this.
|
|
434
|
+
if (phase === 'disconnected' && reconnectLimit(this.options.reconnect) === 0)
|
|
456
435
|
throw new CalciteError('disconnected', this.lastError ?? 'Disconnected');
|
|
457
436
|
if (Date.now() > deadline)
|
|
458
437
|
throw new CalciteError('start_timeout', `Client did not start within ${timeoutMs}ms (phase ${phase})`);
|
|
@@ -460,25 +439,10 @@ export class ClientCore extends EventEmitter {
|
|
|
460
439
|
}
|
|
461
440
|
}
|
|
462
441
|
async cleanupRun() {
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
this.poller = null;
|
|
466
|
-
}
|
|
467
|
-
if (this.run) {
|
|
468
|
-
await killTree(this.run.child);
|
|
469
|
-
this.run = null;
|
|
470
|
-
}
|
|
471
|
-
if (this.probe) {
|
|
472
|
-
await this.probe.close();
|
|
473
|
-
this.probe = null;
|
|
474
|
-
}
|
|
475
|
-
if (this.probeConfig) {
|
|
476
|
-
await rm(this.probeConfig, { force: true });
|
|
477
|
-
this.probeConfig = null;
|
|
478
|
-
}
|
|
479
|
-
this.display?.release();
|
|
480
|
-
this.display = null;
|
|
442
|
+
const run = this.current;
|
|
443
|
+
this.current = null;
|
|
481
444
|
this.lastState = undefined;
|
|
445
|
+
await run?.dispose();
|
|
482
446
|
}
|
|
483
447
|
async shutdown(final) {
|
|
484
448
|
this.stopping = true;
|
|
@@ -493,38 +457,57 @@ export class ClientCore extends EventEmitter {
|
|
|
493
457
|
async stop() {
|
|
494
458
|
if (this.phaseValue === 'stopped' || this.phaseValue === 'idle')
|
|
495
459
|
return;
|
|
460
|
+
const saveWorld = this.world && this.phaseValue === 'in_game' && this.probeConnected;
|
|
496
461
|
this.setPhase('stopping');
|
|
462
|
+
if (saveWorld)
|
|
463
|
+
await this.closeWorld();
|
|
497
464
|
await this.shutdown('stopped');
|
|
498
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
|
+
}
|
|
499
476
|
status() {
|
|
500
477
|
return {
|
|
501
478
|
name: this.options.name,
|
|
502
479
|
version: this.game?.json.id ?? this.options.version,
|
|
503
480
|
loader: this.game?.loader ? `${this.game.loader.kind}@${this.game.loader.build}` : this.options.loader,
|
|
504
481
|
mods: this.mods.length ? this.mods.map((m) => m.name) : undefined,
|
|
482
|
+
extensions: this.extensionJars.length ? this.extensionJars : undefined,
|
|
505
483
|
phase: this.phaseValue,
|
|
506
484
|
render: this.render,
|
|
507
485
|
headless: this.headlessValue,
|
|
508
486
|
account: this.account,
|
|
509
487
|
server: this.server,
|
|
488
|
+
world: this.world,
|
|
510
489
|
gameDir: this.gameDir,
|
|
511
|
-
pid: this.
|
|
490
|
+
pid: this.current?.hmc?.child.pid,
|
|
512
491
|
java: this.game?.java.version,
|
|
513
492
|
startedAt: this.startedAt,
|
|
514
493
|
reconnects: this.reconnects,
|
|
515
494
|
lastError: this.lastError,
|
|
516
|
-
probeConnected:
|
|
495
|
+
probeConnected: this.probeConnected,
|
|
517
496
|
game: this.lastState,
|
|
518
497
|
};
|
|
519
498
|
}
|
|
520
499
|
// ------------------------------------------------------------------ operations
|
|
500
|
+
get probeConnected() {
|
|
501
|
+
return !!this.current?.probe.connected;
|
|
502
|
+
}
|
|
521
503
|
requireProbe() {
|
|
522
504
|
if (this.game?.names.kind === 'unsupported') {
|
|
523
505
|
throw new CalciteError('unsupported_version', this.game.names.reason);
|
|
524
506
|
}
|
|
525
|
-
|
|
507
|
+
const probe = this.current?.probe;
|
|
508
|
+
if (!probe?.connected)
|
|
526
509
|
throw new CalciteError('not_connected', `Client "${this.options.name}" is not running (phase ${this.phaseValue})`);
|
|
527
|
-
return
|
|
510
|
+
return probe;
|
|
528
511
|
}
|
|
529
512
|
}
|
|
530
513
|
//# sourceMappingURL=client-core.js.map
|