cyclops-infobook-html 5.0.0 → 5.2.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 (56) hide show
  1. package/README.md +35 -14
  2. package/bin/compress-icons.d.ts +2 -0
  3. package/bin/compress-icons.js +82 -0
  4. package/bin/convert-pom-to-modpack.d.ts +2 -0
  5. package/bin/convert-pom-to-modpack.js +96 -0
  6. package/bin/generate-icons.d.ts +2 -0
  7. package/bin/generate-icons.js +103 -0
  8. package/bin/generate-infobook-html.js +60 -16
  9. package/bin/generate-mod-metadata.js +62 -16
  10. package/index.d.ts +25 -23
  11. package/index.js +39 -24
  12. package/lib/icon/IconsCompressor.d.ts +18 -0
  13. package/lib/icon/IconsCompressor.js +112 -0
  14. package/lib/icon/IconsGenerator.d.ts +150 -0
  15. package/lib/icon/IconsGenerator.js +683 -0
  16. package/lib/infobook/FileWriter.d.ts +3 -4
  17. package/lib/infobook/FileWriter.js +15 -7
  18. package/lib/infobook/IFileWriter.d.ts +2 -3
  19. package/lib/infobook/IInfoAppendix.d.ts +3 -3
  20. package/lib/infobook/IInfoBook.d.ts +2 -4
  21. package/lib/infobook/IInfoSection.d.ts +1 -1
  22. package/lib/infobook/IInfobookPlugin.d.ts +5 -5
  23. package/lib/infobook/InfoBookInitializer.d.ts +7 -9
  24. package/lib/infobook/InfoBookInitializer.js +11 -3
  25. package/lib/infobook/appendix/IInfoBookAppendixHandler.d.ts +2 -2
  26. package/lib/infobook/appendix/InfoBookAppendixAd.d.ts +3 -3
  27. package/lib/infobook/appendix/InfoBookAppendixAd.js +17 -4
  28. package/lib/infobook/appendix/InfoBookAppendixHandlerAbstractRecipe.d.ts +12 -14
  29. package/lib/infobook/appendix/InfoBookAppendixHandlerAbstractRecipe.js +51 -10
  30. package/lib/infobook/appendix/InfoBookAppendixHandlerAdvancementRewards.d.ts +3 -3
  31. package/lib/infobook/appendix/InfoBookAppendixHandlerAdvancementRewards.js +15 -6
  32. package/lib/infobook/appendix/InfoBookAppendixHandlerCraftingRecipe.d.ts +6 -5
  33. package/lib/infobook/appendix/InfoBookAppendixHandlerCraftingRecipe.js +14 -5
  34. package/lib/infobook/appendix/InfoBookAppendixHandlerImage.d.ts +3 -3
  35. package/lib/infobook/appendix/InfoBookAppendixHandlerImage.js +14 -6
  36. package/lib/infobook/appendix/InfoBookAppendixHandlerKeybinding.d.ts +3 -3
  37. package/lib/infobook/appendix/InfoBookAppendixHandlerKeybinding.js +13 -4
  38. package/lib/infobook/appendix/InfoBookAppendixHandlerSmeltingRecipe.d.ts +6 -5
  39. package/lib/infobook/appendix/InfoBookAppendixHandlerSmeltingRecipe.js +13 -4
  40. package/lib/infobook/appendix/InfoBookAppendixHandlerTextfield.d.ts +3 -3
  41. package/lib/infobook/appendix/InfoBookAppendixHandlerTextfield.js +13 -6
  42. package/lib/infobook/appendix/InfoBookAppendixTagIndex.d.ts +4 -4
  43. package/lib/infobook/appendix/InfoBookAppendixTagIndex.js +12 -3
  44. package/lib/modloader/ModLoader.d.ts +15 -5
  45. package/lib/modloader/ModLoader.js +189 -86
  46. package/lib/modloader/PomConverter.d.ts +19 -0
  47. package/lib/modloader/PomConverter.js +138 -0
  48. package/lib/parse/XmlInfoBookParser.d.ts +5 -7
  49. package/lib/parse/XmlInfoBookParser.js +42 -9
  50. package/lib/resource/ResourceHandler.d.ts +7 -13
  51. package/lib/resource/ResourceHandler.js +16 -11
  52. package/lib/resource/ResourceLoader.d.ts +4 -5
  53. package/lib/resource/ResourceLoader.js +54 -44
  54. package/lib/serialize/HtmlInfoBookSerializer.d.ts +10 -16
  55. package/lib/serialize/HtmlInfoBookSerializer.js +102 -91
  56. package/package.json +35 -25
@@ -0,0 +1,683 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
36
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
37
+ return new (P || (P = Promise))(function (resolve, reject) {
38
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
39
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
40
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
41
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
42
+ });
43
+ };
44
+ var __importDefault = (this && this.__importDefault) || function (mod) {
45
+ return (mod && mod.__esModule) ? mod : { "default": mod };
46
+ };
47
+ Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.IconsGenerator = void 0;
49
+ /* Tslint:disable:ter-indent */
50
+ const node_child_process_1 = require("node:child_process");
51
+ const fs = __importStar(require("node:fs"));
52
+ const node_path_1 = require("node:path");
53
+ const node_fetch_1 = __importDefault(require("node-fetch"));
54
+ /**
55
+ * Generates icons using the IconExporter mod and HeadlessMC.
56
+ * This class downloads HeadlessMC and the IconExporter mod, sets up a headless
57
+ * Minecraft client with NeoForge and all specified mods, launches the game,
58
+ * runs the iconexporter export command, and copies the resulting icons.
59
+ */
60
+ class IconsGenerator {
61
+ constructor(args) {
62
+ if (!args.modsDir) {
63
+ throw new Error('Missing modsDir field for icons generation');
64
+ }
65
+ if (!args.iconsDir) {
66
+ throw new Error('Missing iconsDir field for icons generation');
67
+ }
68
+ if (!args.minecraftVersion) {
69
+ throw new Error('Missing minecraftVersion field for icons generation');
70
+ }
71
+ if (!args.neoforgeVersion) {
72
+ throw new Error('Missing neoforgeVersion field for icons generation');
73
+ }
74
+ this.modsDir = args.modsDir;
75
+ this.iconsDir = args.iconsDir;
76
+ this.workDir = args.workDir;
77
+ this.minecraftVersion = args.minecraftVersion;
78
+ this.neoforgeVersion = args.neoforgeVersion;
79
+ this.iconExporterVersion = args.iconExporterVersion;
80
+ this.headlessMcVersion = args.headlessMcVersion || '2.8.0';
81
+ // 15 minutes
82
+ this.launchTimeoutMs = args.launchTimeoutMs || (15 * 60 * 1000);
83
+ }
84
+ /**
85
+ * Run the full icon generation pipeline.
86
+ */
87
+ generate() {
88
+ return __awaiter(this, void 0, void 0, function* () {
89
+ // Ensure working directory exists
90
+ if (!fs.existsSync(this.workDir)) {
91
+ yield fs.promises.mkdir(this.workDir, { recursive: true });
92
+ }
93
+ process.stdout.write('Downloading HeadlessMC...\n');
94
+ yield this.downloadHeadlessMc();
95
+ process.stdout.write('Downloading IconExporter...\n');
96
+ yield this.downloadIconExporter();
97
+ process.stdout.write('Setting up mods directory...\n');
98
+ yield this.setupGameDirectory();
99
+ process.stdout.write('Writing HeadlessMC config...\n');
100
+ this.writeHmcConfig();
101
+ process.stdout.write('Launching Minecraft to generate icons...\n');
102
+ yield this.runGameAndExportIcons();
103
+ process.stdout.write('Copying icons to output...\n');
104
+ yield this.copyIcons();
105
+ });
106
+ }
107
+ /**
108
+ * Download the HeadlessMC launcher jar.
109
+ */
110
+ downloadHeadlessMc() {
111
+ return __awaiter(this, void 0, void 0, function* () {
112
+ const jarPath = (0, node_path_1.join)(this.workDir, IconsGenerator.headlessMcJar);
113
+ if (fs.existsSync(jarPath)) {
114
+ process.stdout.write('HeadlessMC already downloaded, skipping.\n');
115
+ return;
116
+ }
117
+ const url = `https://github.com/headlesshq/headlessmc/releases/download/${this.headlessMcVersion}/headlessmc-launcher-${this.headlessMcVersion}.jar`;
118
+ yield this.downloadFile(url, jarPath);
119
+ process.stdout.write(`Downloaded HeadlessMC to ${jarPath}\n`);
120
+ });
121
+ }
122
+ /**
123
+ * Download the IconExporter mod from Modrinth.
124
+ * If an explicit version is configured, fetches that specific version; otherwise fetches the
125
+ * latest version for the configured Minecraft version. No authentication is required.
126
+ */
127
+ downloadIconExporter() {
128
+ return __awaiter(this, void 0, void 0, function* () {
129
+ const jarPath = (0, node_path_1.join)(this.workDir, IconsGenerator.iconExporterJar);
130
+ if (fs.existsSync(jarPath)) {
131
+ process.stdout.write('IconExporter already downloaded, skipping.\n');
132
+ return;
133
+ }
134
+ if (this.iconExporterVersion) {
135
+ process.stdout.write(`Fetching IconExporter ${this.iconExporterVersion} for Minecraft ${this.minecraftVersion} from Modrinth...\n`);
136
+ }
137
+ else {
138
+ process.stdout.write(`Fetching latest IconExporter version for Minecraft ${this.minecraftVersion} from Modrinth...\n`);
139
+ }
140
+ const { url: downloadUrl, filename } = yield this.fetchIconExporterFromModrinth(this.iconExporterVersion);
141
+ process.stdout.write(`Found IconExporter: ${filename}\n`);
142
+ yield this.downloadFile(downloadUrl, jarPath);
143
+ process.stdout.write(`Downloaded IconExporter to ${jarPath}\n`);
144
+ });
145
+ }
146
+ /**
147
+ * Fetch an IconExporter release from Modrinth for the configured Minecraft version and the
148
+ * NeoForge loader. If a specific version number is provided, that version is matched;
149
+ * otherwise the latest (newest) version is returned.
150
+ * Returns the primary file's download URL and filename.
151
+ */
152
+ fetchIconExporterFromModrinth(version) {
153
+ return __awaiter(this, void 0, void 0, function* () {
154
+ const gameVersions = JSON.stringify([this.minecraftVersion]);
155
+ const loaders = JSON.stringify(['neoforge']);
156
+ const apiUrl = `${IconsGenerator.modrinthApiBase}/project/${IconsGenerator.iconExporterModrinthSlug}/version` +
157
+ `?game_versions=${encodeURIComponent(gameVersions)}&loaders=${encodeURIComponent(loaders)}`;
158
+ const response = yield (0, node_fetch_1.default)(apiUrl);
159
+ if (!response.ok) {
160
+ throw new Error(`Failed to fetch IconExporter versions from Modrinth: ${response.status} ${response.statusText}`);
161
+ }
162
+ const versions = yield response.json();
163
+ if (!versions || versions.length === 0) {
164
+ throw new Error(`No IconExporter versions found on Modrinth for Minecraft ${this.minecraftVersion} with NeoForge`);
165
+ }
166
+ let matched;
167
+ if (version) {
168
+ // Modrinth version_number is typically "{mcVersion}-{modVersion}" (e.g. "1.21.1-1.4.0-174").
169
+ // Match either an exact version_number or the canonical "<mcVersion>-<version>" form.
170
+ const canonicalVersionNumber = `${this.minecraftVersion}-${version}`;
171
+ const found = versions.find(v => v.version_number === version || v.version_number === canonicalVersionNumber);
172
+ if (!found) {
173
+ throw new Error(`IconExporter version "${version}" not found on Modrinth for Minecraft ${this.minecraftVersion} with NeoForge`);
174
+ }
175
+ matched = found;
176
+ }
177
+ else {
178
+ // Modrinth returns versions newest-first
179
+ matched = versions[0];
180
+ }
181
+ const primaryFile = matched.files.find(f => f.primary) || matched.files[0];
182
+ if (!primaryFile) {
183
+ throw new Error(`No files found for IconExporter version ${matched.version_number}`);
184
+ }
185
+ return { url: primaryFile.url, filename: primaryFile.filename };
186
+ });
187
+ }
188
+ /**
189
+ * Set up the game directory with mods and options.
190
+ */
191
+ setupGameDirectory() {
192
+ return __awaiter(this, void 0, void 0, function* () {
193
+ const gameDir = (0, node_path_1.join)(this.workDir, IconsGenerator.hmcGameSubdir);
194
+ const modsDir = (0, node_path_1.join)(gameDir, 'mods');
195
+ if (!fs.existsSync(modsDir)) {
196
+ yield fs.promises.mkdir(modsDir, { recursive: true });
197
+ }
198
+ // Copy mods from server/mods directory
199
+ if (fs.existsSync(this.modsDir)) {
200
+ const mods = yield fs.promises.readdir(this.modsDir);
201
+ let copied = 0;
202
+ for (const mod of mods) {
203
+ if (mod.endsWith('.jar')) {
204
+ yield fs.promises.copyFile((0, node_path_1.join)(this.modsDir, mod), (0, node_path_1.join)(modsDir, mod));
205
+ copied++;
206
+ }
207
+ }
208
+ process.stdout.write(`Copied ${copied} mods from ${this.modsDir} to ${modsDir}\n`);
209
+ }
210
+ else {
211
+ process.stdout.write(`Warning: mods directory not found: ${this.modsDir}\n`);
212
+ }
213
+ // Copy IconExporter to mods directory
214
+ const iconExporterSrc = (0, node_path_1.join)(this.workDir, IconsGenerator.iconExporterJar);
215
+ if (fs.existsSync(iconExporterSrc)) {
216
+ yield fs.promises.copyFile(iconExporterSrc, (0, node_path_1.join)(modsDir, IconsGenerator.iconExporterJar));
217
+ process.stdout.write('Added IconExporter to mods directory\n');
218
+ }
219
+ // Download hmc-specifics if not already present (avoids relying on HeadlessMC's
220
+ // GitHub API lookup which may hit rate limits in CI environments)
221
+ const existingMods = yield fs.promises.readdir(modsDir);
222
+ const hasHmcSpecifics = existingMods.some(f => f.startsWith('hmc-specifics-'));
223
+ if (!hasHmcSpecifics) {
224
+ yield this.downloadHmcSpecifics(modsDir);
225
+ }
226
+ // Write options.txt to disable accessibility screen and pauseOnLostFocus
227
+ const optionsPath = (0, node_path_1.join)(gameDir, 'options.txt');
228
+ if (!fs.existsSync(optionsPath)) {
229
+ yield fs.promises.writeFile(optionsPath, 'pauseOnLostFocus:false\nonboardAccessibility:false\n');
230
+ }
231
+ });
232
+ }
233
+ /**
234
+ * Write the HeadlessMC configuration file.
235
+ */
236
+ writeHmcConfig() {
237
+ const configDir = (0, node_path_1.join)(this.workDir, IconsGenerator.hmcConfigSubdir);
238
+ if (!fs.existsSync(configDir)) {
239
+ fs.mkdirSync(configDir, { recursive: true });
240
+ }
241
+ const gameDir = (0, node_path_1.join)(this.workDir, IconsGenerator.hmcGameSubdir);
242
+ const configPath = (0, node_path_1.join)(configDir, 'config.properties');
243
+ const config = [
244
+ `hmc.mcdir=${gameDir}`,
245
+ 'hmc.jline.enabled=false',
246
+ ].join('\n');
247
+ fs.writeFileSync(configPath, config);
248
+ process.stdout.write(`Wrote HeadlessMC config to ${configPath}\n`);
249
+ }
250
+ /**
251
+ * Run the Minecraft client using HeadlessMC and export icons.
252
+ */
253
+ runGameAndExportIcons() {
254
+ return __awaiter(this, void 0, void 0, function* () {
255
+ const jarPath = (0, node_path_1.join)(this.workDir, IconsGenerator.headlessMcJar);
256
+ return new Promise((resolve, reject) => {
257
+ const proc = (0, node_child_process_1.spawn)('java', [
258
+ `-Dhmc.mcdir=${(0, node_path_1.join)(this.workDir, IconsGenerator.hmcGameSubdir)}`,
259
+ // Tell HeadlessMC that Xvfb is in use so it skips offline headless checks
260
+ '-Dhmc.check.xvfb=true',
261
+ '-jar',
262
+ jarPath,
263
+ ], {
264
+ cwd: this.workDir,
265
+ stdio: ['pipe', 'pipe', 'pipe'],
266
+ env: Object.assign(Object.assign({}, process.env), {
267
+ // Disable ANSI escape sequences for easier parsing
268
+ // eslint-disable-next-line ts/naming-convention
269
+ TERM: 'dumb',
270
+ // eslint-disable-next-line ts/naming-convention
271
+ NO_COLOR: '1' }),
272
+ });
273
+ let outputBuffer = '';
274
+ // Per-state buffer: cleared on every state transition, so checks only match
275
+ // output produced AFTER entering the current state (avoids stale matches).
276
+ let stateBuffer = '';
277
+ // Track the most recently observed screen name from HeadlessMC `gui` output.
278
+ let lastKnownScreen = '';
279
+ let state = 'waiting_for_prompt';
280
+ let stateSettledAt = Date.now();
281
+ let commandSent = false;
282
+ const sendCommand = (command) => {
283
+ process.stdout.write(`[HMC] > ${command}\n`);
284
+ proc.stdin.write(`${command}\n`);
285
+ };
286
+ // Click a button by its text label, resolving to numeric ID from the last gui output.
287
+ // HeadlessMC's click command requires the numeric id, not button text.
288
+ // IMPORTANT: search only the most recent gui response block (from the last 'Screen:' line
289
+ // onward) so that stale button IDs from earlier gui responses in outputBuffer are ignored.
290
+ const clickByText = (buttonText) => {
291
+ const lastScreenIdx = outputBuffer.lastIndexOf('\nScreen:');
292
+ const recentGuiOutput = lastScreenIdx >= 0 ? outputBuffer.slice(lastScreenIdx) : outputBuffer;
293
+ const id = this.findButtonIdByText(recentGuiOutput, buttonText);
294
+ if (id === null) {
295
+ process.stdout.write(`[HMC] Warning: button "${buttonText}" not found in gui output, trying text fallback\n`);
296
+ sendCommand(`click ${buttonText}`);
297
+ }
298
+ else {
299
+ sendCommand(`click ${id}`);
300
+ }
301
+ };
302
+ const transitionTo = (newState) => {
303
+ if (state !== newState) {
304
+ process.stdout.write(`[HMC] State: ${state} -> ${newState}\n`);
305
+ state = newState;
306
+ stateSettledAt = Date.now();
307
+ commandSent = false;
308
+ // Reset per-state buffer on every transition
309
+ stateBuffer = '';
310
+ }
311
+ };
312
+ const handleOutput = (chunk) => {
313
+ outputBuffer += chunk;
314
+ stateBuffer += chunk;
315
+ // Keep global buffer size manageable
316
+ if (outputBuffer.length > 100000) {
317
+ outputBuffer = outputBuffer.slice(-20000);
318
+ }
319
+ // Track the current screen from each gui output chunk
320
+ const screenMatch = /^Screen:\s*(.+)$/mu.exec(chunk);
321
+ if (screenMatch) {
322
+ lastKnownScreen = screenMatch[1].trim();
323
+ process.stdout.write(`[HMC] Current screen: ${lastKnownScreen}\n`);
324
+ }
325
+ switch (state) {
326
+ case 'waiting_for_prompt':
327
+ // HeadlessMC in non-TTY mode does not print a '>' prompt character.
328
+ // Detect readiness from its actual startup output (version table header or
329
+ // the DefaultCommandLineProvider warning printed just before it waits for input).
330
+ // A time-based fallback ensures we never hang indefinitely.
331
+ if (!commandSent && (this.isHeadlessMcReady(outputBuffer) ||
332
+ Date.now() - stateSettledAt > 10000)) {
333
+ // HeadlessMC is ready for input - set offline and launch
334
+ commandSent = true;
335
+ sendCommand('offline true');
336
+ // Allow a moment for offline mode to register
337
+ setTimeout(() => {
338
+ sendCommand(`launch neoforge:${this.minecraftVersion} -offline --jvm "-Djava.awt.headless=true"`);
339
+ transitionTo('game_launching');
340
+ }, 1000);
341
+ }
342
+ break;
343
+ case 'game_launching':
344
+ // Wait for NeoForge to finish loading
345
+ if (this.isGameFullyLoaded(stateBuffer) && !commandSent) {
346
+ commandSent = true;
347
+ // Wait a moment for hmc-specifics to be fully initialized
348
+ setTimeout(() => {
349
+ sendCommand('gui');
350
+ transitionTo('checking_screen');
351
+ }, 5000);
352
+ }
353
+ else if (Date.now() - stateSettledAt > 300000 && !commandSent) {
354
+ // 5-minute timeout: if game output is not being relayed to HMC stdout
355
+ // (e.g. HMCLog4JAppender terminal is null), proceed assuming game is loaded
356
+ commandSent = true;
357
+ process.stdout.write('[HMC] Game load detection timeout, proceeding assuming game is loaded...\n');
358
+ setTimeout(() => {
359
+ sendCommand('gui');
360
+ transitionTo('checking_screen');
361
+ }, 5000);
362
+ }
363
+ break;
364
+ case 'checking_screen':
365
+ // Use lastKnownScreen (from the most recent `gui` output) to avoid acting on
366
+ // stale screen names that appeared earlier in the accumulated outputBuffer.
367
+ if (lastKnownScreen.includes('TitleScreen') && !commandSent) {
368
+ commandSent = true;
369
+ clickByText('Singleplayer');
370
+ transitionTo('navigating_singleplayer');
371
+ }
372
+ else if ((lastKnownScreen.includes('SelectWorldScreen') ||
373
+ lastKnownScreen.includes('WorldSelectionScreen')) && !commandSent) {
374
+ // World selection screen - click Create New World by numeric ID
375
+ commandSent = true;
376
+ clickByText('Create New World');
377
+ transitionTo('navigating_singleplayer');
378
+ }
379
+ else if (lastKnownScreen.includes('CreateWorldScreen') && !commandSent) {
380
+ // World creation settings screen - confirm by clicking Create New World
381
+ commandSent = true;
382
+ clickByText('Create New World');
383
+ transitionTo('creating_world');
384
+ }
385
+ else if (stateBuffer.includes('Couldn\'t find command for \'[gui]\'') && !commandSent) {
386
+ // Gui not yet recognized; hmc-specifics may still be initializing - retry
387
+ commandSent = true;
388
+ setTimeout(() => {
389
+ sendCommand('gui');
390
+ commandSent = false;
391
+ }, 5000);
392
+ }
393
+ else if (!commandSent) {
394
+ // Poll for screen changes by sending gui periodically
395
+ commandSent = true;
396
+ setTimeout(() => {
397
+ sendCommand('gui');
398
+ commandSent = false;
399
+ }, 2000);
400
+ }
401
+ break;
402
+ case 'navigating_singleplayer':
403
+ // Poll gui to detect which screen we're on after clicking Singleplayer.
404
+ // Use lastKnownScreen (from most recent gui output) to avoid stale matches.
405
+ // IMPORTANT: check world-load signals BEFORE the SelectWorldScreen check so that
406
+ // a stale lastKnownScreen value of SelectWorldScreen does not trigger another
407
+ // "Create New World" click after the world has already loaded silently.
408
+ if (lastKnownScreen.includes('CreateWorldScreen') && !commandSent) {
409
+ // World creation settings screen - confirm by clicking Create New World
410
+ commandSent = true;
411
+ clickByText('Create New World');
412
+ transitionTo('creating_world');
413
+ }
414
+ else if ((stateBuffer.includes('logged in') || stateBuffer.includes('not displaying a Gui')) &&
415
+ !commandSent) {
416
+ // World already loaded (detected either from HMC login message or from a gui poll)
417
+ commandSent = true;
418
+ setTimeout(() => {
419
+ sendCommand(`/iconexporter export ${IconsGenerator.defaultIconSize}`);
420
+ transitionTo('exporting_icons');
421
+ }, 3000);
422
+ }
423
+ else if ((lastKnownScreen.includes('SelectWorldScreen') ||
424
+ lastKnownScreen.includes('WorldSelectionScreen')) && !commandSent) {
425
+ // World selection list: click Create New World, then actively poll gui so we detect
426
+ // either CreateWorldScreen (needs a second click) or a loaded world ("not displaying
427
+ // a Gui") even if the game produces no further stdout after the click.
428
+ commandSent = true;
429
+ clickByText('Create New World');
430
+ setTimeout(() => {
431
+ sendCommand('gui');
432
+ commandSent = false;
433
+ }, 8000);
434
+ }
435
+ else if (Date.now() - stateSettledAt > 60000 && !commandSent) {
436
+ // Fallback: re-check current GUI
437
+ commandSent = true;
438
+ sendCommand('gui');
439
+ transitionTo('checking_screen');
440
+ }
441
+ else if (!commandSent) {
442
+ // Poll for screen change
443
+ commandSent = true;
444
+ setTimeout(() => {
445
+ sendCommand('gui');
446
+ commandSent = false;
447
+ }, 2000);
448
+ }
449
+ break;
450
+ case 'creating_world':
451
+ // Wait for world to load - look for "logged in" or "not displaying a Gui"
452
+ // Use stateBuffer (cleared on transition) to avoid matching text from earlier states.
453
+ if ((stateBuffer.includes('logged in') || stateBuffer.includes('not displaying a Gui')) && !commandSent) {
454
+ commandSent = true;
455
+ // Wait for world to fully initialize before triggering export
456
+ setTimeout(() => {
457
+ sendCommand(`/iconexporter export ${IconsGenerator.defaultIconSize}`);
458
+ transitionTo('exporting_icons');
459
+ }, 3000);
460
+ }
461
+ else if ((lastKnownScreen.includes('SelectWorldScreen') ||
462
+ lastKnownScreen.includes('WorldSelectionScreen')) && !commandSent) {
463
+ // World creation was cancelled/failed and game returned to world-selection screen.
464
+ // Both 'SelectWorldScreen' and 'WorldSelectionScreen' are checked defensively
465
+ // in case the class name differs across Minecraft/HeadlessMC versions.
466
+ // Re-enter navigating_singleplayer to retry world creation.
467
+ process.stdout.write('[HMC] World creation returned to SelectWorldScreen, retrying...\n');
468
+ transitionTo('navigating_singleplayer');
469
+ }
470
+ else if (Date.now() - stateSettledAt > 120000 && !commandSent) {
471
+ // Timeout waiting for world - try to proceed anyway
472
+ commandSent = true;
473
+ sendCommand(`/iconexporter export ${IconsGenerator.defaultIconSize}`);
474
+ transitionTo('exporting_icons');
475
+ }
476
+ else if (!commandSent) {
477
+ // Actively poll gui so we can detect world load ("not displaying a Gui") even
478
+ // when the game produces no stdout after loading completes.
479
+ commandSent = true;
480
+ setTimeout(() => {
481
+ sendCommand('gui');
482
+ commandSent = false;
483
+ }, 5000);
484
+ }
485
+ break;
486
+ case 'exporting_icons':
487
+ // Use stateBuffer (cleared on transition) so early startup '[iconexporter] version check'
488
+ // lines in the global outputBuffer don't fire this prematurely.
489
+ // Set commandSent immediately to prevent queuing multiple quit timers.
490
+ // Only match the literal completion string from gui.itemexporter.finished ("Finished exporting")
491
+ // — do NOT use path fragments like 'icon-exports' which appear in Mekanism error stack traces
492
+ // and would cause a premature exit before the export finishes.
493
+ if (!commandSent) {
494
+ if (stateBuffer.includes('Finished exporting')) {
495
+ // Export completion detected - wait a moment for files to flush, then quit
496
+ commandSent = true;
497
+ process.stdout.write('[HMC] Icon export completion detected, quitting in 5s...\n');
498
+ setTimeout(() => {
499
+ sendCommand('quit');
500
+ transitionTo('quitting');
501
+ }, 5000);
502
+ // 20 minutes
503
+ }
504
+ else if (Date.now() - stateSettledAt > 1200000) {
505
+ // Timeout - quit anyway
506
+ commandSent = true;
507
+ process.stdout.write('[HMC] Warning: icon export may not have completed, quitting...\n');
508
+ sendCommand('quit');
509
+ transitionTo('quitting');
510
+ }
511
+ }
512
+ break;
513
+ case 'quitting':
514
+ // Just waiting for the process to end
515
+ break;
516
+ default:
517
+ break;
518
+ }
519
+ };
520
+ proc.stdout.on('data', (data) => {
521
+ const chunk = data.toString();
522
+ process.stdout.write(chunk);
523
+ handleOutput(chunk);
524
+ });
525
+ proc.stderr.on('data', (data) => {
526
+ const errChunk = data.toString();
527
+ process.stderr.write(errChunk);
528
+ handleOutput(errChunk);
529
+ });
530
+ // Heartbeat: drive state-machine advances (timeouts, polling) even when the game
531
+ // produces no stdout/stderr output. Without this the machine freezes permanently
532
+ // after the game goes silent (e.g. after a world finishes loading).
533
+ const heartbeatInterval = setInterval(() => {
534
+ handleOutput('');
535
+ }, 5000);
536
+ const launchTimeout = setTimeout(() => {
537
+ clearInterval(heartbeatInterval);
538
+ proc.kill('SIGTERM');
539
+ reject(new Error(`HeadlessMC timed out after ${this.launchTimeoutMs / 1000}s in state: ${state}`));
540
+ }, this.launchTimeoutMs);
541
+ proc.on('exit', (code) => {
542
+ clearInterval(heartbeatInterval);
543
+ clearTimeout(launchTimeout);
544
+ if (state === 'quitting' || code === 0) {
545
+ resolve();
546
+ }
547
+ else {
548
+ reject(new Error(`HeadlessMC exited with code ${code} in state: ${state}`));
549
+ }
550
+ });
551
+ proc.on('error', (err) => {
552
+ clearInterval(heartbeatInterval);
553
+ clearTimeout(launchTimeout);
554
+ reject(err);
555
+ });
556
+ });
557
+ });
558
+ }
559
+ /**
560
+ * Copy exported icons to the output icons directory.
561
+ */
562
+ copyIcons() {
563
+ return __awaiter(this, void 0, void 0, function* () {
564
+ const exportDir = (0, node_path_1.join)(this.workDir, IconsGenerator.hmcGameSubdir, IconsGenerator.iconExportSubdir);
565
+ if (!fs.existsSync(exportDir)) {
566
+ throw new Error(`Icon export directory not found: ${exportDir}. Make sure the IconExporter command ran successfully.`);
567
+ }
568
+ if (!fs.existsSync(this.iconsDir)) {
569
+ yield fs.promises.mkdir(this.iconsDir, { recursive: true });
570
+ }
571
+ const files = yield fs.promises.readdir(exportDir);
572
+ const pngFiles = files.filter(f => f.endsWith('.png'));
573
+ for (const file of pngFiles) {
574
+ yield fs.promises.copyFile((0, node_path_1.join)(exportDir, file), (0, node_path_1.join)(this.iconsDir, file));
575
+ }
576
+ process.stdout.write(`Copied ${pngFiles.length} icons to ${this.iconsDir}\n`);
577
+ });
578
+ }
579
+ /**
580
+ * Parse the HeadlessMC `gui` command output and find the numeric button ID for a given
581
+ * button text label. HeadlessMC's `click` command requires a numeric id, not the text.
582
+ * The gui table format is:
583
+ * id text x y w h on type
584
+ * 0 Multiplayer 140 123 200 20 1 Button
585
+ * 1 Singleplayer 140 99 200 20 1 Button
586
+ * Columns are separated by two or more spaces.
587
+ */
588
+ findButtonIdByText(guiOutput, buttonText) {
589
+ for (const line of guiOutput.split('\n')) {
590
+ const cols = line.trim().split(/\s{2,}/u);
591
+ if (cols.length >= 2 && /^\d+$/u.test(cols[0]) && cols[1].trim() === buttonText) {
592
+ return Number.parseInt(cols[0], 10);
593
+ }
594
+ }
595
+ return null;
596
+ }
597
+ /**
598
+ * Determine if the Minecraft game has fully loaded based on log output.
599
+ */
600
+ isGameFullyLoaded(output) {
601
+ // NeoForge loading complete indicator
602
+ return output.includes('Mod loading complete') ||
603
+ // Vanilla: logged by minecraft/Minecraft when there are 0 advancements
604
+ output.includes('[minecraft/Minecraft]: Loaded 0 advancements') ||
605
+ // Normal case: logged by AdvancementTree after all advancements are loaded;
606
+ // appears near the very end of the loading sequence, just before TitleScreen.
607
+ // NOTE: do NOT use a broad '[Render thread/INFO] + Loaded' match here —
608
+ // that fires prematurely on earlier lines such as RecipeManager output.
609
+ output.includes('[minecraft/AdvancementTree]: Loaded ') ||
610
+ output.includes('HMC Specifics initialized') ||
611
+ output.includes('HMC-Specifics initialized');
612
+ }
613
+ /**
614
+ * Determine if the HeadlessMC launcher is ready to accept commands based on its startup output.
615
+ * In non-TTY mode HeadlessMC does not print a '>' prompt, so we detect readiness from the
616
+ * version-table header or the DefaultCommandLineProvider warning it prints on startup.
617
+ */
618
+ isHeadlessMcReady(output) {
619
+ return output.includes('id name parent') ||
620
+ output.includes('DefaultCommandLineProvider');
621
+ }
622
+ /**
623
+ * Download the hmc-specifics mod from GitHub Releases into the given mods directory.
624
+ * This avoids relying on HeadlessMC's built-in `-specifics` auto-download which checks
625
+ * the GitHub API without authentication and can hit rate limits.
626
+ */
627
+ downloadHmcSpecifics(modsDir) {
628
+ return __awaiter(this, void 0, void 0, function* () {
629
+ process.stdout.write('Downloading hmc-specifics...\n');
630
+ const apiUrl = `https://api.github.com/repos/${IconsGenerator.hmcSpecificsRepo}/releases/latest`;
631
+ const apiResponse = yield (0, node_fetch_1.default)(apiUrl);
632
+ if (!apiResponse.ok) {
633
+ throw new Error(`Failed to fetch hmc-specifics release info: ${apiResponse.status} ${apiResponse.statusText}`);
634
+ }
635
+ const release = yield apiResponse.json();
636
+ const asset = release.assets.find(a => a.name.includes(this.minecraftVersion) && a.name.includes('neoforge'));
637
+ if (!asset) {
638
+ throw new Error(`Could not find hmc-specifics asset for MC ${this.minecraftVersion} in release ${release.tag_name}`);
639
+ }
640
+ const destPath = (0, node_path_1.join)(modsDir, asset.name);
641
+ yield this.downloadFile(asset.browser_download_url, destPath);
642
+ process.stdout.write(`Downloaded hmc-specifics to ${destPath}\n`);
643
+ });
644
+ }
645
+ /**
646
+ * Download a file from a URL to a destination path.
647
+ */
648
+ downloadFile(url, destPath, headers) {
649
+ return __awaiter(this, void 0, void 0, function* () {
650
+ const options = {};
651
+ if (headers && Object.keys(headers).length > 0) {
652
+ options.headers = headers;
653
+ }
654
+ const response = yield (0, node_fetch_1.default)(url, options);
655
+ if (!response.ok) {
656
+ throw new Error(`Failed to download from ${url}: ${response.status} ${response.statusText}`);
657
+ }
658
+ const parentDir = (0, node_path_1.join)(destPath, '..');
659
+ if (!fs.existsSync(parentDir)) {
660
+ yield fs.promises.mkdir(parentDir, { recursive: true });
661
+ }
662
+ yield new Promise((resolve, reject) => {
663
+ const stream = fs.createWriteStream(destPath);
664
+ response.body
665
+ .on('error', reject)
666
+ .pipe(stream);
667
+ stream.on('finish', resolve);
668
+ stream.on('error', reject);
669
+ });
670
+ });
671
+ }
672
+ }
673
+ exports.IconsGenerator = IconsGenerator;
674
+ IconsGenerator.headlessMcJar = 'headlessmc-launcher.jar';
675
+ IconsGenerator.iconExporterJar = 'iconexporter.jar';
676
+ IconsGenerator.hmcGameSubdir = 'game';
677
+ IconsGenerator.hmcConfigSubdir = 'HeadlessMC';
678
+ IconsGenerator.iconExportSubdir = 'icon-exports-x64';
679
+ IconsGenerator.defaultIconSize = 64;
680
+ IconsGenerator.hmcSpecificsRepo = 'headlesshq/hmc-specifics';
681
+ IconsGenerator.modrinthApiBase = 'https://api.modrinth.com/v2';
682
+ IconsGenerator.iconExporterModrinthSlug = 'icon-exporter';
683
+ //# sourceMappingURL=IconsGenerator.js.map