stonecut-map-builder 0.0.0-stage → 0.1.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -2
  3. package/SECURITY.md +31 -0
  4. package/config.example.json +18 -0
  5. package/dist/cli.js +45 -0
  6. package/dist/config.js +127 -0
  7. package/dist/content.js +7 -0
  8. package/dist/context.js +122 -0
  9. package/dist/doctor.js +202 -0
  10. package/dist/guide.js +22 -0
  11. package/dist/index.js +26 -0
  12. package/dist/mc/blocks.js +197 -0
  13. package/dist/mc/box.js +71 -0
  14. package/dist/mc/download.js +75 -0
  15. package/dist/mc/entities.js +241 -0
  16. package/dist/mc/java.js +97 -0
  17. package/dist/mc/properties.js +149 -0
  18. package/dist/mc/setup.js +167 -0
  19. package/dist/mc/testserver.js +309 -0
  20. package/dist/mc/versions.js +108 -0
  21. package/dist/nbt/nbt.js +282 -0
  22. package/dist/nbt/snbt.js +36 -0
  23. package/dist/ops/backup.js +69 -0
  24. package/dist/ops/files.js +118 -0
  25. package/dist/ops/readout.js +100 -0
  26. package/dist/ops/undo.js +45 -0
  27. package/dist/ops/world.js +370 -0
  28. package/dist/rcon/rcon.js +216 -0
  29. package/dist/safety/address.js +44 -0
  30. package/dist/safety/paths.js +103 -0
  31. package/dist/schemas.js +57 -0
  32. package/dist/schematic/sponge.js +264 -0
  33. package/dist/server.js +16 -0
  34. package/dist/tools/backup.js +38 -0
  35. package/dist/tools/blocks.js +140 -0
  36. package/dist/tools/define.js +37 -0
  37. package/dist/tools/files.js +51 -0
  38. package/dist/tools/schematic.js +59 -0
  39. package/dist/tools/server.js +97 -0
  40. package/dist/tools/setup.js +187 -0
  41. package/dist/version.js +2 -0
  42. package/dist/world/entities.js +56 -0
  43. package/dist/world/region.js +192 -0
  44. package/docs/reference.md +231 -0
  45. package/docs/setup-with-ai.md +83 -0
  46. package/package.json +54 -4
  47. package/skills/map-builder/SKILL.md +131 -0
@@ -0,0 +1,241 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { get, nbt, num } from '../nbt/nbt.js';
3
+ import { SnbtError, toSnbt } from '../nbt/snbt.js';
4
+ import { MAX_COMMAND_BYTES } from './blocks.js';
5
+ // Turns saved entities back into /summon commands at a new place. Every copy gets a fresh UUID,
6
+ // and every reference to an entity of the same import (leads tied to another mob, riders and the
7
+ // like) is pointed at the new UUID, so copies never clash with the originals or with each other.
8
+ // Positions inside the data (the block a frame or painting hangs on, the fence a lead is tied to,
9
+ // homes and hives) move by the same offset as the entity.
10
+ export const ENTITY_ID = /^[a-z0-9_.-]+:[a-z0-9_./-]+$/;
11
+ export class EntityError extends Error {
12
+ }
13
+ /** Whole-number world positions stored as [I; x, y, z]. */
14
+ const POS_ARRAYS = [
15
+ 'block_pos', 'leash', 'home_pos', 'hive_pos', 'flower_pos', 'sleeping_pos', 'beam_target', 'anchor_pos',
16
+ 'wander_target', 'patrol_target', 'bound_pos', 'travel_pos', 'jukebox_pos',
17
+ ];
18
+ /** Older form: a compound with X, Y and Z. */
19
+ const POS_COMPOUNDS = ['Leash', 'HivePos', 'FlowerPos', 'BeamTarget', 'PatrolTarget', 'WanderTarget'];
20
+ /** Oldest form: three separate ints. */
21
+ const POS_TRIPLES = [
22
+ ['TileX', 'TileY', 'TileZ'], ['SleepingX', 'SleepingY', 'SleepingZ'], ['BoundX', 'BoundY', 'BoundZ'],
23
+ ['HomePosX', 'HomePosY', 'HomePosZ'], ['TravelPosX', 'TravelPosY', 'TravelPosZ'], ['APX', 'APY', 'APZ'],
24
+ ];
25
+ export function uuidString(u) {
26
+ const hex = Array.from(u, (n) => (n >>> 0).toString(16).padStart(8, '0')).join('');
27
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
28
+ }
29
+ /** A random version 4 UUID as four ints, the way the game stores it. */
30
+ export function randomUuid() {
31
+ const b = randomBytes(16);
32
+ b[6] = (b[6] & 0x0f) | 0x40;
33
+ b[8] = (b[8] & 0x3f) | 0x80;
34
+ return [0, 4, 8, 12].map((i) => b.readInt32BE(i));
35
+ }
36
+ export function cloneTag(t) {
37
+ switch (t.type) {
38
+ case 'compound': return { type: 'compound', value: new Map([...t.value].map(([k, v]) => [k, cloneTag(v)])) };
39
+ case 'list': return { type: 'list', itemType: t.itemType, value: t.value.map(cloneTag) };
40
+ case 'byteArray': return { type: t.type, value: Int8Array.from(t.value) };
41
+ case 'intArray': return { type: t.type, value: Int32Array.from(t.value) };
42
+ case 'longArray': return { type: t.type, value: BigInt64Array.from(t.value) };
43
+ default: return { ...t };
44
+ }
45
+ }
46
+ /** Moves every known position in the entity (and its riders) by d. Changes c in place. */
47
+ export function shiftPositions(c, d) {
48
+ const pos = c.value.get('Pos');
49
+ if (pos?.type === 'list')
50
+ pos.value.forEach((t, i) => t.type === 'double' && (t.value += d[i]));
51
+ for (const k of POS_ARRAYS) {
52
+ const t = c.value.get(k);
53
+ if (t?.type === 'intArray' && t.value.length === 3)
54
+ t.value.forEach((v, i) => (t.value[i] = v + d[i]));
55
+ }
56
+ for (const k of POS_COMPOUNDS) {
57
+ const t = get(c, k, 'compound');
58
+ if (!t)
59
+ continue;
60
+ ['X', 'Y', 'Z'].forEach((axis, i) => {
61
+ const v = t.value.get(axis);
62
+ if (v?.type === 'int')
63
+ v.value += d[i];
64
+ });
65
+ }
66
+ for (const keys of POS_TRIPLES) {
67
+ keys.forEach((k, i) => {
68
+ const v = c.value.get(k);
69
+ if (v?.type === 'int')
70
+ v.value += d[i];
71
+ });
72
+ }
73
+ const riders = get(c, 'Passengers', 'list');
74
+ for (const r of riders?.value ?? [])
75
+ if (r.type === 'compound')
76
+ shiftPositions(r, d);
77
+ }
78
+ const uuidKey = (u) => Array.from(u).join(',');
79
+ /** Collects the UUIDs of entities and their riders. */
80
+ export function collectUuids(c, into = []) {
81
+ const u = get(c, 'UUID', 'intArray');
82
+ if (u && u.value.length === 4)
83
+ into.push(Array.from(u.value));
84
+ for (const r of get(c, 'Passengers', 'list')?.value ?? [])
85
+ if (r.type === 'compound')
86
+ collectUuids(r, into);
87
+ return into;
88
+ }
89
+ /** Replaces, anywhere in the tag, each four-int UUID found in map by its new value. Changes t in place. */
90
+ export function remapUuids(t, map) {
91
+ if (t.type === 'intArray' && t.value.length === 4) {
92
+ const next = map.get(uuidKey(t.value));
93
+ if (next)
94
+ next.forEach((v, i) => (t.value[i] = v));
95
+ }
96
+ else if (t.type === 'compound') {
97
+ for (const v of t.value.values())
98
+ remapUuids(v, map);
99
+ }
100
+ else if (t.type === 'list') {
101
+ for (const v of t.value)
102
+ remapUuids(v, map);
103
+ }
104
+ }
105
+ /** The block a frame, painting or lead knot hangs on, if the entity is one. */
106
+ export function hangingBlock(c) {
107
+ const bp = get(c, 'block_pos', 'intArray');
108
+ if (bp && bp.value.length === 3)
109
+ return [bp.value[0], bp.value[1], bp.value[2]];
110
+ const [x, y, z] = ['TileX', 'TileY', 'TileZ'].map((k) => num(c, k));
111
+ return x !== undefined && y !== undefined && z !== undefined ? [x, y, z] : null;
112
+ }
113
+ function coord(n) {
114
+ const s = String(n);
115
+ return /e/i.test(s) ? n.toFixed(10) : s;
116
+ }
117
+ function dropDeep(c, key) {
118
+ c.value.delete(key);
119
+ for (const r of get(c, 'Passengers', 'list')?.value ?? [])
120
+ if (r.type === 'compound')
121
+ dropDeep(r, key);
122
+ }
123
+ /**
124
+ * Builds /summon commands for schematic entities pasted with their 0,0,0 corner at origin.
125
+ * oldOrigin is where that corner was when exported, if known; otherwise the world position inside
126
+ * each entity's data is used to work out the offset.
127
+ */
128
+ export function summonCommands(entities, origin, oldOrigin, budget = MAX_COMMAND_BYTES) {
129
+ const plan = { commands: [], uuids: [], warnings: [] };
130
+ const fresh = new Map();
131
+ for (const e of entities)
132
+ for (const u of collectUuids(e.data))
133
+ fresh.set(uuidKey(u), randomUuid());
134
+ const hanging = [];
135
+ const others = [];
136
+ for (const e of entities) {
137
+ if (e.id === 'minecraft:player' || e.id === 'player') {
138
+ plan.warnings.push('Skipped a player entity: players cannot be copied');
139
+ continue;
140
+ }
141
+ const id = e.id.includes(':') ? e.id : `minecraft:${e.id}`;
142
+ if (!ENTITY_ID.test(id)) {
143
+ plan.warnings.push(`Skipped an entity with an invalid id: ${e.id.slice(0, 60)}`);
144
+ continue;
145
+ }
146
+ const at = [origin[0] + e.pos[0], origin[1] + e.pos[1], origin[2] + e.pos[2]];
147
+ const data = cloneTag(e.data);
148
+ let d;
149
+ const saved = data.value.get('Pos');
150
+ const savedPos = saved?.type === 'list' && saved.value.length === 3 ? saved.value.map((t) => (t.type === 'double' ? t.value : NaN)) : null;
151
+ if (oldOrigin)
152
+ d = [origin[0] - oldOrigin[0], origin[1] - oldOrigin[1], origin[2] - oldOrigin[2]];
153
+ else if (savedPos && savedPos.every(Number.isFinite))
154
+ d = [0, 1, 2].map((i) => Math.round(at[i] - savedPos[i]));
155
+ else {
156
+ const hb = hangingBlock(data);
157
+ d = hb ? [0, 1, 2].map((i) => Math.floor(at[i]) - hb[i]) : [0, 0, 0];
158
+ }
159
+ shiftPositions(data, d);
160
+ if (!get(data, 'UUID', 'intArray')) {
161
+ const u = randomUuid();
162
+ data.value.set('UUID', nbt.intArray(u));
163
+ fresh.set(uuidKey(u), u);
164
+ }
165
+ remapUuids(data, fresh);
166
+ const newUuid = uuidString(get(data, 'UUID', 'intArray').value);
167
+ for (const u of collectUuids(data))
168
+ plan.uuids.push(uuidString(u));
169
+ data.value.delete('Pos');
170
+ data.value.delete('id');
171
+ dropDeep(data, 'Brain');
172
+ const hb = hangingBlock(data);
173
+ const place = hb ?? at;
174
+ const head = `summon ${id} ${place.map(coord).join(' ')} `;
175
+ const cmds = fitCommands(head, newUuid, data, budget, plan.warnings);
176
+ if (!cmds) {
177
+ plan.warnings.push(`Skipped ${id} at ${at.map((v) => v.toFixed(1)).join(' ')}: its data cannot go into a command`);
178
+ continue;
179
+ }
180
+ (hb ? hanging : others).push(cmds);
181
+ }
182
+ plan.commands = [...hanging.flat(), ...others.flat()];
183
+ return plan;
184
+ }
185
+ /** One summon if it fits; otherwise a summon with what fits and a data merge per remaining key. */
186
+ function fitCommands(head, uuid, data, budget, warnings) {
187
+ let full;
188
+ try {
189
+ full = head + toSnbt(data);
190
+ }
191
+ catch (e) {
192
+ if (!(e instanceof SnbtError))
193
+ throw e;
194
+ full = '';
195
+ }
196
+ if (full && Buffer.byteLength(full) <= budget)
197
+ return [full];
198
+ const core = nbt.compound({ UUID: data.value.get('UUID') });
199
+ const merges = [];
200
+ for (const [k, v] of data.value) {
201
+ if (k === 'UUID')
202
+ continue;
203
+ let part;
204
+ try {
205
+ part = toSnbt(nbt.compound({ [k]: v }));
206
+ }
207
+ catch {
208
+ warnings.push(`Left out ${k} of an entity: it has text that cannot go into a command`);
209
+ continue;
210
+ }
211
+ const trial = nbt.compound(new Map([...core.value, [k, v]]));
212
+ if (Buffer.byteLength(head + toSnbt(trial)) <= budget)
213
+ core.value.set(k, v);
214
+ else if (k !== 'Passengers' && Buffer.byteLength(`data merge entity ${uuid} ${part}`) <= budget)
215
+ merges.push(`data merge entity ${uuid} ${part}`);
216
+ else
217
+ warnings.push(`Left out ${k} of an entity: it is too long for RCON`);
218
+ }
219
+ const summon = head + toSnbt(core);
220
+ return Buffer.byteLength(summon) <= budget ? [summon, ...merges] : null;
221
+ }
222
+ /** Commands that remove entities without loot: living ones get an empty loot table first. */
223
+ export function removeCommands(list) {
224
+ const out = [];
225
+ const remove = (data) => {
226
+ // Riders first: killing a vehicle would only throw them off.
227
+ for (const r of get(data, 'Passengers', 'list')?.value ?? [])
228
+ if (r.type === 'compound')
229
+ remove(r);
230
+ const uuid = get(data, 'UUID', 'intArray');
231
+ if (!uuid || uuid.value.length !== 4)
232
+ return;
233
+ const u = uuidString(uuid.value);
234
+ if (data.value.has('Health'))
235
+ out.push(`data merge entity ${u} {DeathLootTable:"minecraft:empty"}`);
236
+ out.push(`kill ${u}`);
237
+ };
238
+ for (const e of list)
239
+ remove(e.data);
240
+ return out;
241
+ }
@@ -0,0 +1,97 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ export const JAVA_SOURCES = [
6
+ { name: 'Eclipse Temurin (Adoptium)', url: 'https://adoptium.net/temurin/releases/' },
7
+ { name: 'Microsoft Build of OpenJDK', url: 'https://learn.microsoft.com/java/openjdk/download' },
8
+ ];
9
+ export function javaInstallHint(major, platform = process.platform) {
10
+ const hints = JAVA_SOURCES.map((s) => `${s.name}, Java ${major}: ${s.url}`);
11
+ if (platform === 'win32')
12
+ hints.push(`Windows terminal: winget install EclipseAdoptium.Temurin.${major}.JRE`);
13
+ else if (platform === 'darwin')
14
+ hints.push(`macOS with Homebrew: brew install --cask temurin@${major}`);
15
+ else
16
+ hints.push(`Linux: your package manager, for example sudo apt install openjdk-${major}-jre-headless`);
17
+ return hints;
18
+ }
19
+ export function parseJavaVersion(output) {
20
+ const m = /version "([^"]+)"/.exec(output);
21
+ if (!m)
22
+ return null;
23
+ const v = m[1];
24
+ const parts = v.split(/[._+-]/).map((n) => parseInt(n, 10));
25
+ const major = parts[0] === 1 ? parts[1] : parts[0];
26
+ return Number.isFinite(major) ? { major, version: v } : null;
27
+ }
28
+ function exe() {
29
+ return process.platform === 'win32' ? 'java.exe' : 'java';
30
+ }
31
+ function subdirs(dir) {
32
+ try {
33
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(dir, d.name));
34
+ }
35
+ catch {
36
+ return [];
37
+ }
38
+ }
39
+ /** Places Java is commonly installed, built from environment variables and the home folder only. */
40
+ export function candidatePaths(env = process.env, home = homedir()) {
41
+ const out = [];
42
+ const bin = (dir) => join(dir, 'bin', exe());
43
+ if (env.JAVA_HOME)
44
+ out.push(bin(env.JAVA_HOME));
45
+ for (const p of (env.PATH ?? env.Path ?? '').split(delimiter))
46
+ if (p)
47
+ out.push(join(p, exe()));
48
+ const vendorRoots = [];
49
+ if (process.platform === 'win32') {
50
+ for (const pf of [env.ProgramFiles, env['ProgramFiles(x86)']]) {
51
+ if (!pf)
52
+ continue;
53
+ for (const v of ['Java', 'Eclipse Adoptium', 'Microsoft', 'Zulu', 'Amazon Corretto', 'BellSoft', 'Semeru', 'OpenJDK'])
54
+ vendorRoots.push(join(pf, v));
55
+ }
56
+ }
57
+ else if (process.platform === 'darwin') {
58
+ for (const root of ['/Library/Java/JavaVirtualMachines', join(home, 'Library', 'Java', 'JavaVirtualMachines')]) {
59
+ for (const d of subdirs(root))
60
+ out.push(bin(join(d, 'Contents', 'Home')));
61
+ }
62
+ }
63
+ else {
64
+ vendorRoots.push('/usr/lib/jvm', '/usr/java', '/opt/java');
65
+ }
66
+ vendorRoots.push(join(home, '.jdks'), join(home, '.sdkman', 'candidates', 'java'));
67
+ for (const root of vendorRoots)
68
+ for (const d of subdirs(root))
69
+ out.push(bin(d));
70
+ return [...new Set(out)];
71
+ }
72
+ export function probeJava(path) {
73
+ return new Promise((resolve) => {
74
+ execFile(path, ['-version'], { timeout: 15_000, windowsHide: true }, (err, stdout, stderr) => {
75
+ if (err && !stderr)
76
+ return resolve(null);
77
+ const v = parseJavaVersion(`${stderr}\n${stdout}`);
78
+ resolve(v ? { path, ...v } : null);
79
+ });
80
+ });
81
+ }
82
+ export async function findJava(extra = [], env = process.env, home = homedir()) {
83
+ const paths = [...extra, ...candidatePaths(env, home)].filter((p) => existsSync(p));
84
+ const found = await Promise.all(paths.map(probeJava));
85
+ const seen = new Set();
86
+ return found.filter((j) => {
87
+ if (!j || seen.has(j.path.toLowerCase()))
88
+ return false;
89
+ seen.add(j.path.toLowerCase());
90
+ return true;
91
+ });
92
+ }
93
+ /** The best Java for a required major version: the exact version if present, otherwise the lowest newer one. */
94
+ export function pickJava(found, major) {
95
+ const ok = found.filter((j) => j.major >= major).sort((a, b) => a.major - b.major);
96
+ return ok.find((j) => j.major === major) ?? ok[0] ?? null;
97
+ }
@@ -0,0 +1,149 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isLoopback, RCON_HOST } from '../safety/address.js';
5
+ export class PropertiesError extends Error {
6
+ }
7
+ /** Java's whitespace in properties files: space, tab and form feed. */
8
+ const SPACE = new Set([' ', '\t', '\f']);
9
+ const ESCAPES = { t: '\t', n: '\n', r: '\r', f: '\f' };
10
+ function unescape(s, line) {
11
+ let out = '';
12
+ for (let i = 0; i < s.length; i++) {
13
+ const c = s[i];
14
+ if (c !== '\\' || i + 1 >= s.length) {
15
+ out += c;
16
+ continue;
17
+ }
18
+ const n = s[++i];
19
+ if (n === 'u') {
20
+ const hex = s.slice(i + 1, i + 5);
21
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
22
+ throw new PropertiesError(`server.properties line ${line} has a broken \\u escape, so Minecraft cannot read the file either. Fix that line.`);
23
+ out += String.fromCharCode(parseInt(hex, 16));
24
+ i += 4;
25
+ }
26
+ else
27
+ out += ESCAPES[n] ?? n;
28
+ }
29
+ return out;
30
+ }
31
+ function escape(s) {
32
+ return s.replace(/\\/g, '\\\\').replace(/([:=#!])/g, '\\$1').replace(/^ /, '\\ ');
33
+ }
34
+ function skipSpace(s) {
35
+ let i = 0;
36
+ while (i < s.length && SPACE.has(s[i]))
37
+ i++;
38
+ return s.slice(i);
39
+ }
40
+ function continues(s) {
41
+ let n = 0;
42
+ while (n < s.length && s[s.length - 1 - n] === '\\')
43
+ n++;
44
+ return n % 2 === 1;
45
+ }
46
+ function entries(lines) {
47
+ const out = [];
48
+ for (let i = 0; i < lines.length; i++) {
49
+ let part = skipSpace(lines[i]);
50
+ if (part === '' || part[0] === '#' || part[0] === '!')
51
+ continue;
52
+ const first = i;
53
+ let text = '';
54
+ while (continues(part) && i + 1 < lines.length) {
55
+ text += part.slice(0, -1);
56
+ part = skipSpace(lines[++i]);
57
+ }
58
+ text += continues(part) ? part.slice(0, -1) : part;
59
+ // The key ends at the first unescaped =, : or whitespace; after whitespace one = or : may follow.
60
+ let k = 0;
61
+ let valueStart = text.length;
62
+ let hasSep = false;
63
+ let escaped = false;
64
+ for (; k < text.length; k++) {
65
+ const c = text[k];
66
+ if (!escaped && (c === '=' || c === ':' || SPACE.has(c))) {
67
+ hasSep = !SPACE.has(c);
68
+ valueStart = k + 1;
69
+ break;
70
+ }
71
+ escaped = c === '\\' ? !escaped : false;
72
+ }
73
+ while (valueStart < text.length) {
74
+ const c = text[valueStart];
75
+ if (!SPACE.has(c)) {
76
+ if (hasSep || (c !== '=' && c !== ':'))
77
+ break;
78
+ hasSep = true;
79
+ }
80
+ valueStart++;
81
+ }
82
+ out.push({ key: unescape(text.slice(0, k), first + 1), value: unescape(text.slice(valueStart), first + 1), first, last: i });
83
+ }
84
+ return out;
85
+ }
86
+ const physicalLines = (text) => text.split(/\r\n|\r|\n/);
87
+ export function parseProperties(text) {
88
+ const out = new Map();
89
+ for (const e of entries(physicalLines(text)))
90
+ out.set(e.key, e.value);
91
+ return out;
92
+ }
93
+ export function updateProperties(text, changes) {
94
+ const lines = physicalLines(text);
95
+ const left = new Map(Object.entries(changes));
96
+ for (const e of entries(lines)) {
97
+ if (!Object.hasOwn(changes, e.key))
98
+ continue;
99
+ lines[e.first] = `${e.key}=${escape(changes[e.key])}`;
100
+ for (let i = e.first + 1; i <= e.last; i++)
101
+ lines[i] = null;
102
+ left.delete(e.key);
103
+ }
104
+ const kept = lines.filter((l) => l !== null);
105
+ while (kept.length && kept[kept.length - 1] === '')
106
+ kept.pop();
107
+ for (const [k, v] of left)
108
+ kept.push(`${k}=${escape(v)}`);
109
+ return `${kept.join('\n')}\n`;
110
+ }
111
+ export function propertiesFile(serverDir) {
112
+ return join(serverDir, 'server.properties');
113
+ }
114
+ export function readProperties(serverDir) {
115
+ const f = propertiesFile(serverDir);
116
+ return existsSync(f) ? parseProperties(readFileSync(f, 'utf8')) : new Map();
117
+ }
118
+ export function newPassword() {
119
+ return randomBytes(24).toString('base64url');
120
+ }
121
+ /** Settings the tool always keeps: local only, RCON on, no query port, no RCON echo to players. */
122
+ export function lockedChanges(props, rconPort) {
123
+ const pw = props.get('rcon.password') ?? '';
124
+ const management = props.get('management-server-host');
125
+ return {
126
+ 'server-ip': isLoopback(props.get('server-ip') ?? '') ? props.get('server-ip') : RCON_HOST,
127
+ 'enable-rcon': 'true',
128
+ 'rcon.port': String(rconPort),
129
+ 'rcon.password': pw.length >= 20 ? pw : newPassword(),
130
+ 'enable-query': 'false',
131
+ 'broadcast-rcon-to-ops': 'false',
132
+ // Minecraft 1.21.9 and newer has a management server, off by default; when it is set up it stays local too.
133
+ ...(management !== undefined && !isLoopback(management) ? { 'management-server-host': RCON_HOST } : {}),
134
+ };
135
+ }
136
+ /** Rewrites the locked settings if anything changed them. Returns the keys it had to fix. */
137
+ export function enforceLocked(serverDir, rconPort) {
138
+ const f = propertiesFile(serverDir);
139
+ const text = existsSync(f) ? readFileSync(f, 'utf8') : '';
140
+ const props = parseProperties(text);
141
+ const changes = lockedChanges(props, rconPort);
142
+ const fixed = Object.entries(changes).filter(([k, v]) => props.get(k) !== v).map(([k]) => k);
143
+ if (fixed.length)
144
+ writeFileSync(f, updateProperties(text, changes));
145
+ return fixed;
146
+ }
147
+ export function redactProperties(text) {
148
+ return text.replace(/^(\s*(?:rcon\.password|management-server-secret)\s*[=:]).*$/gm, '$1<hidden>');
149
+ }
@@ -0,0 +1,167 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { basename, isAbsolute, join } from 'node:path';
5
+ import { checkAllowedFolder } from '../safety/paths.js';
6
+ import { downloadFile } from './download.js';
7
+ import { findJava, javaInstallHint, pickJava } from './java.js';
8
+ import { newPassword, updateProperties } from './properties.js';
9
+ import { findFreePort, portFree, STATE_DIR, writeEula, writeMeta } from './testserver.js';
10
+ import { EULA_URL, neoforgeSha256, resolveNeoForge, resolveVersion, serverDownload } from './versions.js';
11
+ const PLAN_LIFETIME_MS = 30 * 60_000;
12
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/;
13
+ export class SetupError extends Error {
14
+ }
15
+ /**
16
+ * A Java the agent names must be a program called java (java.exe) given by full path, and not inside
17
+ * a server folder: the file tools can write there, and the tool runs this program.
18
+ */
19
+ export function checkJavaPath(config, path) {
20
+ const name = basename(path).toLowerCase();
21
+ if (!isAbsolute(path) || (name !== 'java' && name !== 'java.exe'))
22
+ throw new SetupError(`java_path must be the full path of a program called java or java.exe, not ${path}`);
23
+ let inServerFolder = true;
24
+ try {
25
+ checkAllowedFolder(config.serverFolders, path);
26
+ }
27
+ catch {
28
+ inServerFolder = false;
29
+ }
30
+ if (inServerFolder)
31
+ throw new SetupError(`java_path cannot be inside a server folder: ${path}. Use a Java installed on this computer.`);
32
+ }
33
+ export async function planSetup(config, input) {
34
+ if (!NAME.test(input.name))
35
+ throw new SetupError('Server name: 1-40 characters of A-Z a-z 0-9 _ -, starting with a letter or digit');
36
+ const root = input.serverFolder ?? config.serverFolders[0];
37
+ checkAllowedFolder(config.serverFolders, root);
38
+ const folder = checkAllowedFolder(config.serverFolders, join(root, input.name));
39
+ if (existsSync(folder) && readdirSync(folder).length > 0)
40
+ throw new SetupError(`${folder} already exists and is not empty. Pick another name, or use_server to use it.`);
41
+ if (input.javaPath)
42
+ checkJavaPath(config, input.javaPath);
43
+ const entry = await resolveVersion(input.minecraft);
44
+ const server = await serverDownload(entry);
45
+ const downloads = [];
46
+ let loaderVersion;
47
+ let loaderIsBeta;
48
+ if (input.loader === 'vanilla') {
49
+ downloads.push({ what: `Minecraft ${entry.id} server`, url: server.url, source: 'Mojang (official version manifest)', hash: { algo: 'sha1', value: server.sha1 }, size: server.size });
50
+ }
51
+ else {
52
+ const build = await resolveNeoForge(entry.id);
53
+ loaderVersion = build.version;
54
+ loaderIsBeta = build.beta;
55
+ downloads.push({ what: `NeoForge ${build.version} installer`, url: build.installerUrl, source: 'NeoForged (maven.neoforged.net)', hash: { algo: 'sha256', value: await neoforgeSha256(build) } });
56
+ downloads.push({ what: `Minecraft ${entry.id} server and NeoForge libraries, fetched and hash-checked by the NeoForge installer`, url: 'https://piston-data.mojang.com and https://maven.neoforged.net', source: 'Mojang and NeoForged', hash: { algo: 'sha1', value: server.sha1 } });
57
+ }
58
+ const extra = [input.javaPath, config.javaPath].filter((p) => !!p);
59
+ const javaFound = await findJava(extra);
60
+ const java = input.javaPath ? javaFound.find((j) => j.path === input.javaPath && j.major >= server.javaMajor) ?? null : pickJava(javaFound, server.javaMajor);
61
+ const gamePort = input.gamePort ?? (await findFreePort(25565));
62
+ const rconPort = input.rconPort ?? (await findFreePort(25575, [gamePort]));
63
+ if (gamePort === rconPort)
64
+ throw new SetupError('Game port and RCON port must differ');
65
+ return {
66
+ planId: randomUUID(),
67
+ folder,
68
+ minecraft: entry.id,
69
+ loader: input.loader,
70
+ ...(loaderVersion ? { loaderVersion, loaderIsBeta } : {}),
71
+ javaNeeded: server.javaMajor,
72
+ java,
73
+ javaFound,
74
+ ...(java ? {} : { javaMissing: javaInstallHint(server.javaMajor) }),
75
+ downloads,
76
+ gamePort,
77
+ rconPort,
78
+ memoryMb: input.memoryMb ?? (input.loader === 'neoforge' ? 4096 : 2048),
79
+ worldType: input.worldType ?? 'flat',
80
+ eulaUrl: EULA_URL,
81
+ ready: java !== null,
82
+ expires: Date.now() + PLAN_LIFETIME_MS,
83
+ };
84
+ }
85
+ export function serverProperties(plan) {
86
+ return updateProperties('#Minecraft server properties, made by Stonecut Map Builder\n', {
87
+ 'server-ip': '127.0.0.1',
88
+ 'server-port': String(plan.gamePort),
89
+ 'enable-rcon': 'true',
90
+ 'rcon.port': String(plan.rconPort),
91
+ 'rcon.password': newPassword(),
92
+ 'broadcast-rcon-to-ops': 'false',
93
+ 'enable-query': 'false',
94
+ 'online-mode': 'true',
95
+ motd: 'Stonecut test server (this computer only)',
96
+ gamemode: 'creative',
97
+ difficulty: 'peaceful',
98
+ 'level-type': plan.worldType === 'flat' ? 'minecraft:flat' : 'minecraft:normal',
99
+ 'generate-structures': plan.worldType === 'flat' ? 'false' : 'true',
100
+ 'spawn-protection': '0',
101
+ 'max-players': '8',
102
+ 'enable-command-block': 'true',
103
+ 'allow-flight': 'true',
104
+ });
105
+ }
106
+ function run(java, args, cwd, timeoutMs) {
107
+ return new Promise((resolve, reject) => {
108
+ execFile(java, args, { cwd, timeout: timeoutMs, windowsHide: true, maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
109
+ const out = `${stdout}\n${stderr}`;
110
+ if (err)
111
+ reject(new SetupError(`${err.message}\n${out.split(/\r?\n/).slice(-20).join('\n')}`));
112
+ else
113
+ resolve(out);
114
+ });
115
+ });
116
+ }
117
+ export async function createServer(plan) {
118
+ const began = Date.now();
119
+ if (Date.now() > plan.expires)
120
+ throw new SetupError('This plan has expired. Run plan_setup again and show the person the new plan.');
121
+ if (!plan.java)
122
+ throw new SetupError(`Java ${plan.javaNeeded} or newer is needed and was not found. Show the person: ${plan.javaMissing?.join('; ')}`);
123
+ if (existsSync(plan.folder) && readdirSync(plan.folder).length > 0)
124
+ throw new SetupError(`${plan.folder} is no longer empty`);
125
+ for (const p of [plan.gamePort, plan.rconPort])
126
+ if (!(await portFree(p)))
127
+ throw new SetupError(`Port ${p} is now in use; run plan_setup again`);
128
+ mkdirSync(join(plan.folder, STATE_DIR, 'downloads'), { recursive: true });
129
+ const downloaded = [];
130
+ let launch;
131
+ if (plan.loader === 'vanilla') {
132
+ const d = plan.downloads[0];
133
+ const r = await downloadFile(d.url, join(plan.folder, 'server.jar'), { algo: d.hash.algo, hash: d.hash.value, ...(d.size ? { size: d.size } : {}) });
134
+ downloaded.push({ what: d.what, ...r });
135
+ launch = { type: 'jar', jar: 'server.jar' };
136
+ }
137
+ else {
138
+ const d = plan.downloads[0];
139
+ const installer = join(plan.folder, STATE_DIR, 'downloads', 'neoforge-installer.jar');
140
+ const r = await downloadFile(d.url, installer, { algo: d.hash.algo, hash: d.hash.value });
141
+ downloaded.push({ what: d.what, ...r });
142
+ const out = await run(plan.java.path, ['-jar', installer, '--installServer', plan.folder], join(plan.folder, STATE_DIR, 'downloads'), 20 * 60_000);
143
+ writeFileSync(join(plan.folder, STATE_DIR, 'install.log'), out);
144
+ rmSync(installer, { force: true });
145
+ const artifact = plan.minecraft === '1.20.1' ? 'forge' : 'neoforge';
146
+ const libDir = `libraries/net/neoforged/${artifact}/${plan.loaderVersion}`;
147
+ if (!existsSync(join(plan.folder, libDir)))
148
+ throw new SetupError(`The NeoForge installer finished but ${libDir} is missing. See ${STATE_DIR}/install.log.`);
149
+ launch = { type: 'argfile', libDir };
150
+ }
151
+ writeFileSync(join(plan.folder, 'server.properties'), serverProperties(plan));
152
+ writeEula(plan.folder, false);
153
+ const meta = {
154
+ minecraft: plan.minecraft,
155
+ loader: plan.loader,
156
+ ...(plan.loaderVersion ? { loaderVersion: plan.loaderVersion } : {}),
157
+ javaPath: plan.java.path,
158
+ javaMajor: plan.java.major,
159
+ memoryMb: plan.memoryMb,
160
+ launch,
161
+ rconPort: plan.rconPort,
162
+ createdAt: new Date().toISOString(),
163
+ createdByTool: true,
164
+ };
165
+ writeMeta(plan.folder, meta);
166
+ return { folder: plan.folder, meta, downloaded, seconds: (Date.now() - began) / 1000 };
167
+ }