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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +71 -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 +53 -0
  46. package/package.json +54 -4
  47. package/skills/map-builder/SKILL.md +131 -0
@@ -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
+ }