@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +206 -18
  3. package/dist/accounts.js +4 -2
  4. package/dist/accounts.js.map +1 -1
  5. package/dist/cli.js +16 -127
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client-core.d.ts +23 -12
  8. package/dist/client-core.js +227 -244
  9. package/dist/client-core.js.map +1 -1
  10. package/dist/client.d.ts +60 -13
  11. package/dist/client.js +76 -18
  12. package/dist/client.js.map +1 -1
  13. package/dist/display.d.ts +0 -6
  14. package/dist/display.js +31 -44
  15. package/dist/display.js.map +1 -1
  16. package/dist/feed.d.ts +17 -0
  17. package/dist/feed.js +47 -0
  18. package/dist/feed.js.map +1 -0
  19. package/dist/game-run.d.ts +30 -0
  20. package/dist/game-run.js +51 -0
  21. package/dist/game-run.js.map +1 -0
  22. package/dist/hmc.d.ts +7 -0
  23. package/dist/hmc.js +42 -31
  24. package/dist/hmc.js.map +1 -1
  25. package/dist/index.d.ts +4 -2
  26. package/dist/index.js +2 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/install.d.ts +6 -0
  29. package/dist/install.js +26 -3
  30. package/dist/install.js.map +1 -1
  31. package/dist/java.js +11 -2
  32. package/dist/java.js.map +1 -1
  33. package/dist/launch.d.ts +79 -0
  34. package/dist/launch.js +142 -0
  35. package/dist/launch.js.map +1 -0
  36. package/dist/lock.js +18 -18
  37. package/dist/lock.js.map +1 -1
  38. package/dist/mcp/accounts.d.ts +3 -0
  39. package/dist/mcp/accounts.js +45 -0
  40. package/dist/mcp/accounts.js.map +1 -0
  41. package/dist/mcp/act.d.ts +3 -0
  42. package/dist/mcp/act.js +129 -0
  43. package/dist/mcp/act.js.map +1 -0
  44. package/dist/mcp/extensions.d.ts +3 -0
  45. package/dist/mcp/extensions.js +21 -0
  46. package/dist/mcp/extensions.js.map +1 -0
  47. package/dist/mcp/index.d.ts +13 -0
  48. package/dist/mcp/index.js +58 -0
  49. package/dist/mcp/index.js.map +1 -0
  50. package/dist/mcp/items.d.ts +3 -0
  51. package/dist/mcp/items.js +71 -0
  52. package/dist/mcp/items.js.map +1 -0
  53. package/dist/mcp/lifecycle.d.ts +3 -0
  54. package/dist/mcp/lifecycle.js +120 -0
  55. package/dist/mcp/lifecycle.js.map +1 -0
  56. package/dist/mcp/observe.d.ts +3 -0
  57. package/dist/mcp/observe.js +110 -0
  58. package/dist/mcp/observe.js.map +1 -0
  59. package/dist/mcp/shared.d.ts +49 -0
  60. package/dist/mcp/shared.js +56 -0
  61. package/dist/mcp/shared.js.map +1 -0
  62. package/dist/mcp/tasks.d.ts +15 -0
  63. package/dist/mcp/tasks.js +46 -0
  64. package/dist/mcp/tasks.js.map +1 -0
  65. package/dist/mcp.js +35 -3
  66. package/dist/mcp.js.map +1 -1
  67. package/dist/mods.d.ts +2 -0
  68. package/dist/mods.js +35 -4
  69. package/dist/mods.js.map +1 -1
  70. package/dist/mojang.d.ts +5 -1
  71. package/dist/mojang.js.map +1 -1
  72. package/dist/names.js +30 -8
  73. package/dist/names.js.map +1 -1
  74. package/dist/net.js +25 -19
  75. package/dist/net.js.map +1 -1
  76. package/dist/paper.d.ts +46 -0
  77. package/dist/paper.js +200 -0
  78. package/dist/paper.js.map +1 -0
  79. package/dist/probe-server.d.ts +14 -4
  80. package/dist/probe-server.js +15 -10
  81. package/dist/probe-server.js.map +1 -1
  82. package/dist/repl.d.ts +6 -0
  83. package/dist/repl.js +207 -0
  84. package/dist/repl.js.map +1 -0
  85. package/dist/stage.d.ts +6 -0
  86. package/dist/stage.js +19 -0
  87. package/dist/stage.js.map +1 -0
  88. package/dist/types.d.ts +167 -3
  89. package/dist/types.js +12 -1
  90. package/dist/types.js.map +1 -1
  91. package/dist/zip.js.map +1 -1
  92. package/package.json +11 -3
  93. package/vendor/calcite-probe.jar +0 -0
@@ -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 { acquireDisplay, which } from './display.js';
8
- import { hmcListEntry, hmcQuote, hmcVersionArgs, killTree, runHmc, writeHmcProfile } from './hmc.js';
9
- import { installProbe, prepareGame } from './install.js';
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 { ProbeError, ProbeServer } from './probe-server.js';
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
- /** Escapes a value for a java.util.Properties file. */
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
- probe = null;
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 no server is configured).
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 { json, java, loader, names, hmcJar } = this.game;
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.setPhase('waiting_for_server');
198
- const deadline = Date.now() + (this.options.waitForServerMs ?? 120_000);
199
- while (!(await tcpReachable(this.server.host, this.server.port))) {
200
- if (this.stopping)
201
- throw new CalciteError('stopped', 'Stopped while waiting for the server');
202
- if (Date.now() > deadline)
203
- throw new CalciteError('server_unreachable', `Server ${this.server.host}:${this.server.port} is not reachable`);
204
- await sleep(2000);
205
- }
206
- }
207
- // probe endpoint + config (paths inside may contain spaces; the config file itself lives in a safe dir)
208
- this.probe = new ProbeServer();
209
- await this.probe.listen();
210
- this.probe.on('connected', () => this.onProbeConnected());
211
- this.probe.on('disconnected', () => this.note('probe disconnected'));
212
- this.probeConfig = join(this.paths.probe, `${this.options.name}-${randomBytes(6).toString('hex')}.properties`);
213
- const cfg = [
214
- `port=${this.probe.port}`,
215
- `token=${this.probe.token}`,
216
- // name tables the probe tries in order (a mapping file or "official")
217
- ...(names.kind === 'probe' ? names.candidates.map((c, i) => `mappings.${i + 1}=${propValue(c)}`) : []),
218
- `headless=${this.headlessValue}`,
219
- `render=${this.render === 'always' ? 'on' : 'off'}`,
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
- let displayEnv = {};
227
- let virtualDisplay = false;
228
- if (!this.headlessValue) {
229
- // offline accounts may only render on a virtual display (HeadlessMC); on-demand clients stay invisible when Xvfb exists
230
- this.display = await acquireDisplay({ forceVirtual: this.account.type === 'offline' || (this.render === 'on-demand' && which('Xvfb') !== null) });
231
- displayEnv = this.display.env;
232
- virtualDisplay = this.display.virtual;
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
- 'hmc.files.mc': this.paths.minecraft,
262
- 'hmc.files.game': this.gameDir,
263
- 'hmc.java.versions': hmcListEntry(java.path),
264
- 'hmc.java.download': 'false',
265
- // HeadlessMC only lets offline accounts render when it sees Xvfb running
266
- 'hmc.xvfb.check': String(virtualDisplay),
267
- };
268
- const command = [
269
- 'launch',
270
- ...hmcVersionArgs(json.id, loader),
271
- ...(offline ? ['--offline'] : []),
272
- ...(this.headlessValue ? ['--headless'] : []),
273
- `--jvm=${jvmArgs.map(hmcQuote).join(' ')}`,
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
- const spawnGame = async () => {
285
- let launched;
286
- const launchedPromise = new Promise((r) => (launched = r));
287
- // 26.x+: when no graphics backend can be created the game opens a modal error box and blocks forever
288
- const backendErrors = [];
289
- let backendTimer;
290
- const noBackend = () => {
291
- if (run !== this.run || this.stopping)
292
- return;
293
- const hint = process.platform === 'linux'
294
- ? ' On Linux without a GPU install Mesa EGL (apt install libegl1 libegl-mesa0) or Vulkan (apt install mesa-vulkan-drivers), or use render "off".'
295
- : ' Update the graphics drivers, or use render "off".';
296
- this.fatalCode = 'renderer_unavailable';
297
- this.lastError = `No graphics backend could be created: ${backendErrors.join('; ')}.${hint}`;
298
- this.note(this.lastError);
299
- this.setPhase('crashed');
300
- void killTree(run.child);
301
- };
302
- this.run = runHmc({
303
- javaPath: this.game.launcherJava.path,
304
- hmcJar,
305
- location,
306
- props,
307
- command,
308
- env,
309
- onLine: (line) => {
310
- this.pushLog('game', line);
311
- if (/Setting user:|Minecraft exited|LWJGL Version|Backend library/i.test(line))
312
- launched();
313
- const failure = /BackendCreationException: (.*)/.exec(line);
314
- if (failure) {
315
- backendErrors.push(failure[1].trim());
316
- clearTimeout(backendTimer);
317
- backendTimer = setTimeout(noBackend, 20_000);
318
- }
319
- else if (/Using graphics backend/.test(line))
320
- clearTimeout(backendTimer);
321
- },
322
- });
323
- const run = this.run;
324
- void run.exited.then(() => clearTimeout(backendTimer));
325
- void run.exited.then((code) => {
326
- sync();
327
- this.onGameExit(run, code);
328
- });
329
- // the session HeadlessMC refreshed before launching goes back to the shared store
330
- void launchedPromise.then(sync);
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
- async onProbeConnected() {
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
- if (!this.poller) {
339
- this.poller = setInterval(() => void this.poll(), 1000);
340
- }
302
+ run.poll(() => void this.poll(run), 1000);
341
303
  }
342
- async poll() {
343
- if (!this.probe?.connected)
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 this.probe.request('state', {}, 10_000);
309
+ state = await run.probe.request('state', {}, 10_000);
348
310
  }
349
- catch (err) {
350
- if (err instanceof ProbeError && err.code === 'not_ready')
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
- if (state.screen && /LoadingErrorScreen|ModLoadingError/.test(state.screen) && !this.fatalCode && this.run) {
359
- // Forge/NeoForge stay on an error screen when mods fail to load; relaunching would not help
360
- const errors = this.logs.filter((l) => l.source === 'game' && /\/(ERROR|FATAL)\]|Exception|[Mm]issing|requires/.test(l.line)).slice(-12);
361
- this.fatalCode = 'mod_loading_failed';
362
- this.lastError = `Mod loading failed (${state.screen}); check the mods and the loader version:\n${errors.map((l) => l.line).join('\n')}`;
363
- this.note(this.lastError);
364
- this.setPhase('crashed');
365
- void killTree(this.run.child);
366
- return;
367
- }
368
- if (state.inGame && !state.loading && !LOADING_SCREEN.test(state.screen ?? '')) {
369
- if (this.phaseValue !== 'in_game') {
370
- this.inGameSince = Date.now();
371
- this.everInGame = true;
372
- this.setPhase('in_game');
373
- }
374
- else if (this.reconnects > 0 && Date.now() - this.inGameSince > 60_000) {
375
- this.reconnects = 0; // stable again
376
- }
377
- }
378
- else if (this.server &&
379
- !this.joinRequested &&
380
- this.phaseValue === 'connecting' &&
381
- state.screen === 'TitleScreen' &&
382
- !state.loading) {
383
- // Pre-1.20 versions may ignore --server (e.g. 1.16.4+ when multiplayer privileges cannot be checked)
384
- this.joinRequested = true;
385
- this.note(`joining ${this.server.host}:${this.server.port} from the title screen`);
386
- this.probe.request('connect', { host: this.server.host, port: this.server.port }).catch((err) => this.note(`join failed: ${err.message}`));
387
- }
388
- else if (state.screen === 'DisconnectedScreen' && this.phaseValue !== 'disconnected' && this.phaseValue !== 'reconnecting') {
389
- this.lastError = `Disconnected: ${state.disconnectReason ?? 'unknown reason'}`;
390
- this.setPhase('disconnected');
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
- reconnectLimit() {
395
- const r = this.options.reconnect;
396
- if (r === false)
397
- return 0;
398
- if (typeof r === 'object')
399
- return r.maxAttempts ?? 10;
400
- return 10;
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.reconnectLimit()) {
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 = Math.min(5000 * 2 ** (this.reconnects - 1), 60_000);
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.run)
416
- await killTree(this.run.child);
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.run)
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.filter((l) => l.source === 'game').slice(-15).map((l) => l.line).join('\n');
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.reconnectLimit() === 0)
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
- if (this.poller) {
464
- clearInterval(this.poller);
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.run?.child.pid,
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: !!this.probe?.connected,
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
- if (!this.probe?.connected)
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 this.probe;
510
+ return probe;
528
511
  }
529
512
  }
530
513
  //# sourceMappingURL=client-core.js.map