stonecut-map-builder 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -2
- package/SECURITY.md +31 -0
- package/config.example.json +18 -0
- package/dist/cli.js +45 -0
- package/dist/config.js +127 -0
- package/dist/content.js +7 -0
- package/dist/context.js +122 -0
- package/dist/doctor.js +202 -0
- package/dist/guide.js +22 -0
- package/dist/index.js +26 -0
- package/dist/mc/blocks.js +197 -0
- package/dist/mc/box.js +71 -0
- package/dist/mc/download.js +75 -0
- package/dist/mc/entities.js +241 -0
- package/dist/mc/java.js +97 -0
- package/dist/mc/properties.js +149 -0
- package/dist/mc/setup.js +167 -0
- package/dist/mc/testserver.js +309 -0
- package/dist/mc/versions.js +108 -0
- package/dist/nbt/nbt.js +282 -0
- package/dist/nbt/snbt.js +36 -0
- package/dist/ops/backup.js +69 -0
- package/dist/ops/files.js +118 -0
- package/dist/ops/readout.js +100 -0
- package/dist/ops/undo.js +45 -0
- package/dist/ops/world.js +370 -0
- package/dist/rcon/rcon.js +216 -0
- package/dist/safety/address.js +44 -0
- package/dist/safety/paths.js +103 -0
- package/dist/schemas.js +57 -0
- package/dist/schematic/sponge.js +264 -0
- package/dist/server.js +16 -0
- package/dist/tools/backup.js +38 -0
- package/dist/tools/blocks.js +140 -0
- package/dist/tools/define.js +37 -0
- package/dist/tools/files.js +51 -0
- package/dist/tools/schematic.js +59 -0
- package/dist/tools/server.js +97 -0
- package/dist/tools/setup.js +187 -0
- package/dist/version.js +2 -0
- package/dist/world/entities.js +56 -0
- package/dist/world/region.js +192 -0
- package/docs/reference.md +231 -0
- package/docs/setup-with-ai.md +53 -0
- package/package.json +54 -4
- package/skills/map-builder/SKILL.md +131 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { connect, createServer } from 'node:net';
|
|
4
|
+
import { uptime } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { RCON_HOST } from '../safety/address.js';
|
|
7
|
+
import { resolveInside } from '../safety/paths.js';
|
|
8
|
+
import { Rcon } from '../rcon/rcon.js';
|
|
9
|
+
import { EULA_URL } from './versions.js';
|
|
10
|
+
import { enforceLocked, readProperties } from './properties.js';
|
|
11
|
+
// One local test server: its folder, how to launch it, and the RCON connections to it.
|
|
12
|
+
export const STATE_DIR = '.stonecut';
|
|
13
|
+
export const META_FILE = 'server.json';
|
|
14
|
+
export class ServerError extends Error {
|
|
15
|
+
}
|
|
16
|
+
export function metaPath(dir) {
|
|
17
|
+
return join(dir, STATE_DIR, META_FILE);
|
|
18
|
+
}
|
|
19
|
+
export function writeMeta(dir, meta) {
|
|
20
|
+
mkdirSync(join(dir, STATE_DIR), { recursive: true });
|
|
21
|
+
writeFileSync(metaPath(dir), `${JSON.stringify(meta, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
export function eulaAccepted(dir) {
|
|
24
|
+
const f = join(dir, 'eula.txt');
|
|
25
|
+
return existsSync(f) && /^\s*eula\s*=\s*true\s*$/im.test(readFileSync(f, 'utf8'));
|
|
26
|
+
}
|
|
27
|
+
export function writeEula(dir, accepted) {
|
|
28
|
+
writeFileSync(join(dir, 'eula.txt'), `#By changing the setting below to TRUE you are indicating your agreement to the Minecraft EULA (${EULA_URL}).\n` +
|
|
29
|
+
`#${accepted ? 'Accepted by the person through Stonecut Map Builder' : 'Not accepted yet'} on ${new Date().toISOString()}\n` +
|
|
30
|
+
`eula=${accepted}\n`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* True when no program on this computer uses the port. Listens on 127.0.0.1 for a moment (never on
|
|
34
|
+
* a network address, so no firewall question comes up) and tries to connect there, which also
|
|
35
|
+
* finds a program that listens on every address.
|
|
36
|
+
*/
|
|
37
|
+
export function portFree(port) {
|
|
38
|
+
const canListen = new Promise((resolve) => {
|
|
39
|
+
const s = createServer();
|
|
40
|
+
s.once('error', () => resolve(false));
|
|
41
|
+
s.listen({ host: RCON_HOST, port, exclusive: true }, () => s.close(() => resolve(true)));
|
|
42
|
+
});
|
|
43
|
+
const answers = () => new Promise((resolve) => {
|
|
44
|
+
const c = connect({ host: RCON_HOST, port });
|
|
45
|
+
const done = (open) => {
|
|
46
|
+
c.destroy();
|
|
47
|
+
resolve(open);
|
|
48
|
+
};
|
|
49
|
+
c.setTimeout(500, () => done(false));
|
|
50
|
+
c.once('connect', () => done(true));
|
|
51
|
+
c.once('error', () => done(false));
|
|
52
|
+
});
|
|
53
|
+
return canListen.then(async (free) => free && !(await answers()));
|
|
54
|
+
}
|
|
55
|
+
export async function findFreePort(start, avoid = []) {
|
|
56
|
+
for (let p = start; p < start + 200 && p <= 65535; p++) {
|
|
57
|
+
if (!avoid.includes(p) && (await portFree(p)))
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
throw new ServerError(`No free port found from ${start}`);
|
|
61
|
+
}
|
|
62
|
+
function alive(pid) {
|
|
63
|
+
try {
|
|
64
|
+
process.kill(pid, 0);
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
return e.code === 'EPERM';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
72
|
+
function tail(file, lines) {
|
|
73
|
+
if (!existsSync(file))
|
|
74
|
+
return [];
|
|
75
|
+
const size = statSync(file).size;
|
|
76
|
+
const fd = openSync(file, 'r');
|
|
77
|
+
try {
|
|
78
|
+
const len = Math.min(size, 4 * 1024 * 1024);
|
|
79
|
+
const buf = Buffer.alloc(len);
|
|
80
|
+
readSync(fd, buf, 0, len, size - len);
|
|
81
|
+
const all = buf.toString('utf8').split(/\r?\n/);
|
|
82
|
+
if (all[all.length - 1] === '')
|
|
83
|
+
all.pop();
|
|
84
|
+
return all.slice(-lines);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export class TestServer {
|
|
91
|
+
dir;
|
|
92
|
+
meta;
|
|
93
|
+
conns = [];
|
|
94
|
+
childExit = null;
|
|
95
|
+
constructor(dir, meta) {
|
|
96
|
+
this.dir = dir;
|
|
97
|
+
this.meta = meta;
|
|
98
|
+
}
|
|
99
|
+
static open(dir) {
|
|
100
|
+
const f = metaPath(dir);
|
|
101
|
+
if (!existsSync(f))
|
|
102
|
+
throw new ServerError(`${dir} has no ${STATE_DIR}/${META_FILE}. Use use_server to adopt an existing server folder.`);
|
|
103
|
+
return new TestServer(dir, JSON.parse(readFileSync(f, 'utf8')));
|
|
104
|
+
}
|
|
105
|
+
get stateDir() {
|
|
106
|
+
return join(this.dir, STATE_DIR);
|
|
107
|
+
}
|
|
108
|
+
get pidFile() {
|
|
109
|
+
return join(this.stateDir, 'server.pid');
|
|
110
|
+
}
|
|
111
|
+
get consoleLog() {
|
|
112
|
+
return join(this.stateDir, 'console.log');
|
|
113
|
+
}
|
|
114
|
+
get logFile() {
|
|
115
|
+
return join(this.dir, 'logs', 'latest.log');
|
|
116
|
+
}
|
|
117
|
+
properties() {
|
|
118
|
+
return readProperties(this.dir);
|
|
119
|
+
}
|
|
120
|
+
get gamePort() {
|
|
121
|
+
return Number(this.properties().get('server-port') ?? 25565);
|
|
122
|
+
}
|
|
123
|
+
/** The world folder named by level-name. A name that leaves the server folder is refused. */
|
|
124
|
+
get worldDir() {
|
|
125
|
+
const name = this.properties().get('level-name') || 'world';
|
|
126
|
+
try {
|
|
127
|
+
return resolveInside(this.dir, name, { hidden: [STATE_DIR] });
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
throw new ServerError(`level-name in server.properties must be a folder inside the server folder, not "${name}": ${e instanceof Error ? e.message : String(e)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
pid() {
|
|
134
|
+
if (!existsSync(this.pidFile))
|
|
135
|
+
return null;
|
|
136
|
+
// Written before the computer last started: that process is gone, and the number may now belong to another program.
|
|
137
|
+
if (statSync(this.pidFile).mtimeMs < Date.now() - uptime() * 1000)
|
|
138
|
+
return null;
|
|
139
|
+
const pid = Number(readFileSync(this.pidFile, 'utf8').trim());
|
|
140
|
+
return Number.isInteger(pid) && pid > 0 && alive(pid) ? pid : null;
|
|
141
|
+
}
|
|
142
|
+
async status() {
|
|
143
|
+
const pid = this.pid();
|
|
144
|
+
let rcon = false;
|
|
145
|
+
try {
|
|
146
|
+
const c = await this.connection();
|
|
147
|
+
await c.command('list');
|
|
148
|
+
rcon = true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
rcon = false;
|
|
152
|
+
}
|
|
153
|
+
return { running: rcon || pid !== null, pid, rcon };
|
|
154
|
+
}
|
|
155
|
+
javaArgs() {
|
|
156
|
+
const mem = [`-Xmx${this.meta.memoryMb}M`];
|
|
157
|
+
if (this.meta.launch.type === 'jar')
|
|
158
|
+
return [...mem, '-jar', this.meta.launch.jar, 'nogui'];
|
|
159
|
+
const file = process.platform === 'win32' ? 'win_args.txt' : 'unix_args.txt';
|
|
160
|
+
return [...mem, `@${this.meta.launch.libDir}/${file}`, 'nogui'];
|
|
161
|
+
}
|
|
162
|
+
async start(timeoutMs = 300_000) {
|
|
163
|
+
const began = Date.now();
|
|
164
|
+
if ((await this.status()).running)
|
|
165
|
+
throw new ServerError('The server is already running');
|
|
166
|
+
if (!eulaAccepted(this.dir)) {
|
|
167
|
+
throw new ServerError(`The Minecraft EULA is not accepted for this server. Show the person ${EULA_URL} and ask; if they accept, call accept_eula.`);
|
|
168
|
+
}
|
|
169
|
+
if (!existsSync(this.meta.javaPath))
|
|
170
|
+
throw new ServerError(`Java not found at ${this.meta.javaPath}. Run plan_setup to see what is needed.`);
|
|
171
|
+
void this.worldDir; // Minecraft makes the world where level-name says; refused if that leaves the server folder.
|
|
172
|
+
const fixed = enforceLocked(this.dir, this.meta.rconPort);
|
|
173
|
+
for (const [what, port] of [['Game', this.gamePort], ['RCON', this.meta.rconPort]]) {
|
|
174
|
+
if (!(await portFree(port)))
|
|
175
|
+
throw new ServerError(`${what} port ${port} is in use by another program. Pick another port in server.properties.`);
|
|
176
|
+
}
|
|
177
|
+
mkdirSync(this.stateDir, { recursive: true });
|
|
178
|
+
const out = openSync(this.consoleLog, 'w');
|
|
179
|
+
this.childExit = null;
|
|
180
|
+
const child = spawn(this.meta.javaPath, this.javaArgs(), { cwd: this.dir, detached: true, stdio: ['ignore', out, out], windowsHide: true });
|
|
181
|
+
closeSync(out);
|
|
182
|
+
child.on('exit', (code) => (this.childExit = code ?? -1));
|
|
183
|
+
child.unref();
|
|
184
|
+
if (child.pid)
|
|
185
|
+
writeFileSync(this.pidFile, String(child.pid));
|
|
186
|
+
while (Date.now() - began < timeoutMs) {
|
|
187
|
+
await sleep(1000);
|
|
188
|
+
if (this.childExit !== null) {
|
|
189
|
+
throw new ServerError(`The server stopped while starting (exit ${this.childExit}). Last output:\n${this.logs(30).join('\n')}`);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const c = await this.connection();
|
|
193
|
+
await c.command('list');
|
|
194
|
+
return { seconds: (Date.now() - began) / 1000, fixed };
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
// not up yet
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
throw new ServerError(`The server did not answer on RCON within ${timeoutMs / 1000}s. Check server_logs.`);
|
|
201
|
+
}
|
|
202
|
+
async stop(force = false, timeoutMs = 120_000) {
|
|
203
|
+
const began = Date.now();
|
|
204
|
+
const pid = this.pid();
|
|
205
|
+
let asked = false;
|
|
206
|
+
try {
|
|
207
|
+
const c = await this.connection();
|
|
208
|
+
await c.command('stop').catch(() => undefined);
|
|
209
|
+
asked = true;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
asked = false;
|
|
213
|
+
}
|
|
214
|
+
this.closeConnections();
|
|
215
|
+
if (!asked && pid === null)
|
|
216
|
+
throw new ServerError('The server is not running');
|
|
217
|
+
if (!asked && !force)
|
|
218
|
+
throw new ServerError(`The server does not answer on RCON (process ${pid}). Call stop_server with force: true to end the process; unsaved changes may be lost.`);
|
|
219
|
+
if (!asked && pid !== null) {
|
|
220
|
+
process.kill(pid);
|
|
221
|
+
await this.waitGone(pid, 30_000);
|
|
222
|
+
return { seconds: (Date.now() - began) / 1000, forced: true };
|
|
223
|
+
}
|
|
224
|
+
if (pid !== null) {
|
|
225
|
+
if (!(await this.waitGone(pid, timeoutMs))) {
|
|
226
|
+
if (!force)
|
|
227
|
+
throw new ServerError(`The server is still saving after ${timeoutMs / 1000}s. Wait, or call stop_server with force: true.`);
|
|
228
|
+
process.kill(pid);
|
|
229
|
+
await this.waitGone(pid, 30_000);
|
|
230
|
+
return { seconds: (Date.now() - began) / 1000, forced: true };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
while (Date.now() - began < timeoutMs && (await this.rconUp()))
|
|
235
|
+
await sleep(500);
|
|
236
|
+
}
|
|
237
|
+
return { seconds: (Date.now() - began) / 1000, forced: false };
|
|
238
|
+
}
|
|
239
|
+
async waitGone(pid, ms) {
|
|
240
|
+
const until = Date.now() + ms;
|
|
241
|
+
while (Date.now() < until) {
|
|
242
|
+
if (!alive(pid) || this.childExit !== null)
|
|
243
|
+
return true;
|
|
244
|
+
await sleep(250);
|
|
245
|
+
}
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
async rconUp() {
|
|
249
|
+
try {
|
|
250
|
+
const c = await Rcon.connect(this.meta.rconPort, this.password(), 2000);
|
|
251
|
+
c.close();
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
password() {
|
|
259
|
+
const pw = this.properties().get('rcon.password');
|
|
260
|
+
if (!pw)
|
|
261
|
+
throw new ServerError('server.properties has no rcon.password; start the server through this tool to set one');
|
|
262
|
+
return pw;
|
|
263
|
+
}
|
|
264
|
+
/** An open RCON connection (the first of the pool). */
|
|
265
|
+
async connection() {
|
|
266
|
+
return (await this.connections(1))[0];
|
|
267
|
+
}
|
|
268
|
+
/** Up to n open RCON connections, made on demand and reused. */
|
|
269
|
+
async connections(n) {
|
|
270
|
+
this.conns = this.conns.filter((c) => c.isOpen);
|
|
271
|
+
while (this.conns.length < n)
|
|
272
|
+
this.conns.push(await Rcon.connect(this.meta.rconPort, this.password()));
|
|
273
|
+
return this.conns.slice(0, n);
|
|
274
|
+
}
|
|
275
|
+
closeConnections() {
|
|
276
|
+
for (const c of this.conns)
|
|
277
|
+
c.close();
|
|
278
|
+
this.conns = [];
|
|
279
|
+
}
|
|
280
|
+
/** The last lines of the game log, or of the console output if the game log is missing or older. */
|
|
281
|
+
logs(lines = 100, filter) {
|
|
282
|
+
const useConsole = !existsSync(this.logFile) || (existsSync(this.consoleLog) && statSync(this.consoleLog).mtimeMs > statSync(this.logFile).mtimeMs + 2000 && this.childExit !== null);
|
|
283
|
+
let out = tail(useConsole ? this.consoleLog : this.logFile, filter ? 5000 : lines);
|
|
284
|
+
if (filter)
|
|
285
|
+
out = out.filter((l) => l.toLowerCase().includes(filter.toLowerCase())).slice(-lines);
|
|
286
|
+
let pw;
|
|
287
|
+
try {
|
|
288
|
+
pw = this.password();
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
pw = undefined;
|
|
292
|
+
}
|
|
293
|
+
return pw ? out.map((l) => l.split(pw).join('<hidden>')) : out;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Finds how to launch a server folder that was not made by this tool. */
|
|
297
|
+
export function detectLaunch(dir) {
|
|
298
|
+
for (const artifact of ['neoforge', 'forge']) {
|
|
299
|
+
const base = join(dir, 'libraries', 'net', 'neoforged', artifact);
|
|
300
|
+
if (!existsSync(base))
|
|
301
|
+
continue;
|
|
302
|
+
const versions = readdirSync(base).filter((v) => existsSync(join(base, v, 'win_args.txt')) || existsSync(join(base, v, 'unix_args.txt')));
|
|
303
|
+
if (versions.length)
|
|
304
|
+
return { type: 'argfile', libDir: `libraries/net/neoforged/${artifact}/${versions.sort().pop()}` };
|
|
305
|
+
}
|
|
306
|
+
if (existsSync(join(dir, 'server.jar')))
|
|
307
|
+
return { type: 'jar', jar: 'server.jar' };
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { fetchJson, fetchText } from './download.js';
|
|
2
|
+
// Minecraft versions and where the server files come from: Mojang's version manifest for the
|
|
3
|
+
// game, maven.neoforged.net for NeoForge.
|
|
4
|
+
export const MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json';
|
|
5
|
+
export const NEOFORGE_MAVEN = 'https://maven.neoforged.net/releases/net/neoforged';
|
|
6
|
+
export const EULA_URL = 'https://aka.ms/MinecraftEULA';
|
|
7
|
+
export const MIN_VERSION = '1.20.1';
|
|
8
|
+
/** Numeric parts of a release id: 1.21.11 -> [1, 21, 11], 26.1 -> [26, 1, 0]. Null for snapshots. */
|
|
9
|
+
export function versionParts(id) {
|
|
10
|
+
if (!/^\d+\.\d+(\.\d+)?$/.test(id))
|
|
11
|
+
return null;
|
|
12
|
+
const p = id.split('.').map(Number);
|
|
13
|
+
while (p.length < 3)
|
|
14
|
+
p.push(0);
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
export function compareVersions(a, b) {
|
|
18
|
+
const pa = versionParts(a), pb = versionParts(b);
|
|
19
|
+
if (!pa || !pb)
|
|
20
|
+
throw new Error(`Not a release version: ${!pa ? a : b}`);
|
|
21
|
+
for (let i = 0; i < 3; i++)
|
|
22
|
+
if (pa[i] !== pb[i])
|
|
23
|
+
return pa[i] - pb[i];
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
export function isSupportedVersion(id) {
|
|
27
|
+
return versionParts(id) !== null && compareVersions(id, MIN_VERSION) >= 0;
|
|
28
|
+
}
|
|
29
|
+
/** The Java each range of releases needs, as Mojang publishes it per version. For doctor, which stays offline. */
|
|
30
|
+
export const JAVA_FOR_MINECRAFT = [
|
|
31
|
+
{ java: 17, minecraft: '1.20.1 to 1.20.4' },
|
|
32
|
+
{ java: 21, minecraft: '1.20.5 to 1.21.11' },
|
|
33
|
+
{ java: 25, minecraft: '26.1 and newer' },
|
|
34
|
+
];
|
|
35
|
+
export function javaNeededFor(minecraft) {
|
|
36
|
+
if (!isSupportedVersion(minecraft))
|
|
37
|
+
return null;
|
|
38
|
+
if (compareVersions(minecraft, '1.20.5') < 0)
|
|
39
|
+
return 17;
|
|
40
|
+
return compareVersions(minecraft, '26.1') < 0 ? 21 : 25;
|
|
41
|
+
}
|
|
42
|
+
export async function resolveVersion(requested) {
|
|
43
|
+
const m = await fetchJson(MANIFEST_URL);
|
|
44
|
+
const id = requested === 'latest' ? m.latest.release : requested;
|
|
45
|
+
if (!isSupportedVersion(id))
|
|
46
|
+
throw new Error(`Minecraft ${id} is not supported. Use a release from ${MIN_VERSION} on (snapshots are not supported).`);
|
|
47
|
+
const entry = m.versions.find((v) => v.id === id);
|
|
48
|
+
if (!entry || entry.type !== 'release')
|
|
49
|
+
throw new Error(`Mojang has no release called ${id}. Latest release: ${m.latest.release}`);
|
|
50
|
+
return entry;
|
|
51
|
+
}
|
|
52
|
+
export async function serverDownload(entry) {
|
|
53
|
+
const v = await fetchJson(entry.url, entry.sha1);
|
|
54
|
+
const s = v.downloads?.server;
|
|
55
|
+
if (!s)
|
|
56
|
+
throw new Error(`Mojang publishes no server for ${entry.id}`);
|
|
57
|
+
return { javaMajor: v.javaVersion?.majorVersion ?? 17, url: s.url, sha1: s.sha1, size: s.size };
|
|
58
|
+
}
|
|
59
|
+
/** NeoForge numbers its builds after the game: 1.21.4 -> 21.4.x, 1.21 -> 21.0.x, 26.1.2 -> 26.1.2.x. 1.20.1 is the older "forge" artifact. */
|
|
60
|
+
export function neoforgePrefix(mc) {
|
|
61
|
+
const p = versionParts(mc);
|
|
62
|
+
if (!p)
|
|
63
|
+
throw new Error(`Not a release version: ${mc}`);
|
|
64
|
+
if (mc === '1.20.1')
|
|
65
|
+
return { artifact: 'forge', prefix: '1.20.1-' };
|
|
66
|
+
if (p[0] === 1)
|
|
67
|
+
return { artifact: 'neoforge', prefix: `${p[1]}.${p[2]}.` };
|
|
68
|
+
return { artifact: 'neoforge', prefix: `${p[0]}.${p[1]}.${p[2]}.` };
|
|
69
|
+
}
|
|
70
|
+
export function pickNeoForge(mc, versions) {
|
|
71
|
+
const { artifact, prefix } = neoforgePrefix(mc);
|
|
72
|
+
// The version goes into a URL and a folder name, so only plain version characters are used.
|
|
73
|
+
const usable = versions.filter((v) => v.startsWith(prefix) && /^[\w.-]+$/.test(v) && !/alpha|snapshot|rc/i.test(v));
|
|
74
|
+
const num = (v) => v.slice(prefix.length).split(/[.-]/).filter((x) => /^\d+$/.test(x)).map(Number);
|
|
75
|
+
const cmp = (a, b) => {
|
|
76
|
+
const na = num(a), nb = num(b);
|
|
77
|
+
for (let i = 0; i < Math.max(na.length, nb.length); i++)
|
|
78
|
+
if ((na[i] ?? 0) !== (nb[i] ?? 0))
|
|
79
|
+
return (na[i] ?? 0) - (nb[i] ?? 0);
|
|
80
|
+
return 0;
|
|
81
|
+
};
|
|
82
|
+
const stable = usable.filter((v) => !/beta/i.test(v)).sort(cmp);
|
|
83
|
+
const beta = usable.filter((v) => /beta/i.test(v)).sort(cmp);
|
|
84
|
+
const version = stable[stable.length - 1] ?? beta[beta.length - 1];
|
|
85
|
+
if (!version)
|
|
86
|
+
return null;
|
|
87
|
+
return {
|
|
88
|
+
artifact,
|
|
89
|
+
version,
|
|
90
|
+
beta: !stable.length,
|
|
91
|
+
installerUrl: `${NEOFORGE_MAVEN}/${artifact}/${version}/${artifact}-${version}-installer.jar`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export async function resolveNeoForge(mc) {
|
|
95
|
+
const { artifact } = neoforgePrefix(mc);
|
|
96
|
+
const xml = await fetchText(`${NEOFORGE_MAVEN}/${artifact}/maven-metadata.xml`);
|
|
97
|
+
const versions = [...xml.matchAll(/<version>([^<]+)<\/version>/g)].map((m) => m[1]);
|
|
98
|
+
const build = pickNeoForge(mc, versions);
|
|
99
|
+
if (!build)
|
|
100
|
+
throw new Error(`NeoForge has no build for Minecraft ${mc} yet`);
|
|
101
|
+
return build;
|
|
102
|
+
}
|
|
103
|
+
export async function neoforgeSha256(build) {
|
|
104
|
+
const text = (await fetchText(`${build.installerUrl}.sha256`)).trim().split(/\s+/)[0] ?? '';
|
|
105
|
+
if (!/^[0-9a-f]{64}$/i.test(text))
|
|
106
|
+
throw new Error('NeoForge did not publish a usable sha256 for this installer');
|
|
107
|
+
return text.toLowerCase();
|
|
108
|
+
}
|