@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.
Files changed (90) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +103 -20
  3. package/dist/accounts.js +4 -2
  4. package/dist/accounts.js.map +1 -1
  5. package/dist/cli.js +12 -141
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client-core.d.ts +22 -13
  8. package/dist/client-core.js +217 -260
  9. package/dist/client-core.js.map +1 -1
  10. package/dist/client.d.ts +44 -15
  11. package/dist/client.js +58 -21
  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/mods.js +6 -4
  66. package/dist/mods.js.map +1 -1
  67. package/dist/mojang.d.ts +5 -1
  68. package/dist/mojang.js.map +1 -1
  69. package/dist/names.js +30 -8
  70. package/dist/names.js.map +1 -1
  71. package/dist/net.js +25 -19
  72. package/dist/net.js.map +1 -1
  73. package/dist/paper.d.ts +48 -0
  74. package/dist/paper.js +215 -0
  75. package/dist/paper.js.map +1 -0
  76. package/dist/probe-server.d.ts +14 -4
  77. package/dist/probe-server.js +11 -10
  78. package/dist/probe-server.js.map +1 -1
  79. package/dist/repl.d.ts +6 -0
  80. package/dist/repl.js +207 -0
  81. package/dist/repl.js.map +1 -0
  82. package/dist/stage.d.ts +6 -0
  83. package/dist/stage.js +19 -0
  84. package/dist/stage.js.map +1 -0
  85. package/dist/types.d.ts +139 -5
  86. package/dist/types.js +12 -1
  87. package/dist/types.js.map +1 -1
  88. package/dist/zip.js.map +1 -1
  89. package/package.json +11 -3
  90. package/vendor/calcite-probe.jar +0 -0
@@ -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 { 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
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 { 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
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
- probe = null;
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 no server is configured).
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 { json, java, loader, names, hmcJar } = this.game;
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.setPhase('waiting_for_server');
204
- const waitMs = this.options.waitForServerMs ?? 120_000;
205
- const since = Date.now();
206
- let reported = since;
207
- while (!(await tcpReachable(this.server.host, this.server.port))) {
208
- if (this.stopping)
209
- throw new CalciteError('stopped', 'Stopped while waiting for the server');
210
- const waited = Date.now() - since;
211
- if (waited > waitMs) {
212
- throw new CalciteError('server_unreachable', `Server ${this.server.host}:${this.server.port} is not reachable (waited ${Math.round(waited / 1000)}s)`);
213
- }
214
- if (Date.now() - reported >= 10_000) {
215
- reported = Date.now();
216
- const msg = `still waiting for ${this.server.host}:${this.server.port} (${Math.round(waited / 1000)}s of ${Math.round(waitMs / 1000)}s)`;
217
- this.note(msg);
218
- this.log.info(msg);
219
- }
220
- await sleep(2000);
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
- if (this.events.length > MAX_EVENTS)
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
- let displayEnv = {};
252
- let virtualDisplay = false;
253
- if (!this.headlessValue) {
254
- // offline accounts may only render on a virtual display (HeadlessMC); on-demand clients stay invisible when Xvfb exists
255
- this.display = await acquireDisplay({ forceVirtual: this.account.type === 'offline' || (this.render === 'on-demand' && which('Xvfb') !== null) });
256
- displayEnv = this.display.env;
257
- virtualDisplay = this.display.virtual;
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
- 'hmc.files.mc': this.paths.minecraft,
287
- 'hmc.files.game': this.gameDir,
288
- 'hmc.java.versions': hmcListEntry(java.path),
289
- 'hmc.java.download': 'false',
290
- // HeadlessMC only lets offline accounts render when it sees Xvfb running
291
- 'hmc.xvfb.check': String(virtualDisplay),
292
- };
293
- const command = [
294
- 'launch',
295
- ...hmcVersionArgs(json.id, loader),
296
- ...(offline ? ['--offline'] : []),
297
- ...(this.headlessValue ? ['--headless'] : []),
298
- `--jvm=${jvmArgs.map(hmcQuote).join(' ')}`,
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
- const spawnGame = async () => {
310
- let launched;
311
- const launchedPromise = new Promise((r) => (launched = r));
312
- // 26.x+: when no graphics backend can be created the game opens a modal error box and blocks forever
313
- const backendErrors = [];
314
- let backendTimer;
315
- const noBackend = () => {
316
- if (run !== this.run || this.stopping)
317
- return;
318
- const hint = process.platform === 'linux'
319
- ? ' On Linux without a GPU install Mesa EGL (apt install libegl1 libegl-mesa0) or Vulkan (apt install mesa-vulkan-drivers), or use render "off".'
320
- : ' Update the graphics drivers, or use render "off".';
321
- this.fatalCode = 'renderer_unavailable';
322
- this.lastError = `No graphics backend could be created: ${backendErrors.join('; ')}.${hint}`;
323
- this.note(this.lastError);
324
- this.setPhase('crashed');
325
- void killTree(run.child);
326
- };
327
- this.run = runHmc({
328
- javaPath: this.game.launcherJava.path,
329
- hmcJar,
330
- location,
331
- props,
332
- command,
333
- env,
334
- onLine: (line) => {
335
- this.pushLog('game', line);
336
- if (/Setting user:|Minecraft exited|LWJGL Version|Backend library/i.test(line))
337
- launched();
338
- const failure = /BackendCreationException: (.*)/.exec(line);
339
- if (failure) {
340
- backendErrors.push(failure[1].trim());
341
- clearTimeout(backendTimer);
342
- backendTimer = setTimeout(noBackend, 20_000);
343
- }
344
- else if (/Using graphics backend/.test(line))
345
- clearTimeout(backendTimer);
346
- },
347
- });
348
- const run = this.run;
349
- void run.exited.then(() => clearTimeout(backendTimer));
350
- void run.exited.then((code) => {
351
- sync();
352
- this.onGameExit(run, code);
353
- });
354
- // the session HeadlessMC refreshed before launching goes back to the shared store
355
- void launchedPromise.then(sync);
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
- async onProbeConnected() {
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
- if (!this.poller) {
364
- this.poller = setInterval(() => void this.poll(), 1000);
365
- }
302
+ run.poll(() => void this.poll(run), 1000);
366
303
  }
367
- async poll() {
368
- if (!this.probe?.connected)
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 this.probe.request('state', {}, 10_000);
309
+ state = await run.probe.request('state', {}, 10_000);
373
310
  }
374
- catch (err) {
375
- if (err instanceof ProbeError && err.code === 'not_ready')
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
- if (state.screen && /LoadingErrorScreen|ModLoadingError/.test(state.screen) && !this.fatalCode && this.run) {
384
- // Forge/NeoForge stay on an error screen when mods fail to load; relaunching would not help
385
- const errors = this.logs.filter((l) => l.source === 'game' && /\/(ERROR|FATAL)\]|Exception|[Mm]issing|requires/.test(l.line)).slice(-12);
386
- this.fatalCode = 'mod_loading_failed';
387
- this.lastError = `Mod loading failed (${state.screen}); check the mods and the loader version:\n${errors.map((l) => l.line).join('\n')}`;
388
- this.note(this.lastError);
389
- this.setPhase('crashed');
390
- void killTree(this.run.child);
391
- return;
392
- }
393
- if (state.inGame && !state.loading && !LOADING_SCREEN.test(state.screen ?? '')) {
394
- if (this.phaseValue !== 'in_game') {
395
- this.inGameSince = Date.now();
396
- this.everInGame = true;
397
- this.setPhase('in_game');
398
- }
399
- else if (this.reconnects > 0 && Date.now() - this.inGameSince > 60_000) {
400
- this.reconnects = 0; // stable again
401
- }
402
- }
403
- else if (this.server &&
404
- !this.joinRequested &&
405
- this.phaseValue === 'connecting' &&
406
- state.screen === 'TitleScreen' &&
407
- !state.loading) {
408
- // Pre-1.20 versions may ignore --server (e.g. 1.16.4+ when multiplayer privileges cannot be checked)
409
- this.joinRequested = true;
410
- this.note(`joining ${this.server.host}:${this.server.port} from the title screen`);
411
- this.probe.request('connect', { host: this.server.host, port: this.server.port }).catch((err) => this.note(`join failed: ${err.message}`));
412
- }
413
- else if (state.screen === 'DisconnectedScreen' && this.phaseValue !== 'disconnected' && this.phaseValue !== 'reconnecting') {
414
- this.lastError = `Disconnected: ${state.disconnectReason ?? 'unknown reason'}`;
415
- this.setPhase('disconnected');
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
- reconnectLimit() {
420
- const r = this.options.reconnect;
421
- if (r === false)
422
- return 0;
423
- if (typeof r === 'object')
424
- return r.maxAttempts ?? 10;
425
- 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
+ });
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.reconnectLimit()) {
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 = Math.min(5000 * 2 ** (this.reconnects - 1), 60_000);
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.run)
441
- await killTree(this.run.child);
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.run)
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.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');
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.reconnectLimit() === 0)
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
- if (this.poller) {
489
- clearInterval(this.poller);
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.run?.child.pid,
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: !!this.probe?.connected,
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
- if (!this.probe?.connected)
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 this.probe;
510
+ return probe;
554
511
  }
555
512
  }
556
513
  //# sourceMappingURL=client-core.js.map